RCYD857/857-dsh-file-attach

857-dsh-file-attach

把文件或图片拖进 DSH Web 输入框,以一排小卡片展示,发送时以 @path 引用传给模型。

未分類

dsh-file-attach

Drop or paste anything into the DSH Web composer — a file or a screenshot — and it appears as a chip inside the input box, the form Doubao's composer uses: one scrolling row of compact chips (badge, name, state) with a page arrow floating over whichever end still hides one, and a remove badge on every chip's top-right corner. The text box itself never receives a path; when you send, each file chip is expanded into a real @path reference the model opens with its tools, while pictures keep going to the model as pictures.

中文说明 · Behaviour · Install · How files reach the model · The chip row · Limitations · Design notes · Development

Behaviour

You doYou get
Drag a file anywhere on the pagea full-screen hint; on release a chip appears in the input box, above the text
Paste a file into the inputthe same
Drag or paste a pictureit enters the platform's image pipeline — which is what sends it as a picture — and is drawn as a chip in this same row
Read what you attachedthe chip: badge, name, ext · size (or the step it is on)
Click × on a chipthe chip fades out, and is not sent
Click a chippreview panel — full image, the first 200 KiB of text or code, file metadata otherwise
Attach more files than fitthe row scrolls one page at a time; an end that still hides a chip shows a floating arrow, and the chip caught under it fades into the card
Sendevery ready chip is expanded into the prompt as its @path
A file whose path must be found firstits chip shows 正在定位… → 正在上传…, then becomes ready
Drag a folderit resolves to the folder's own path and the prompt carries @folder/ — the model lists and reads inside it. A folder that lives outside every search root says so and suggests dragging the files inside instead
A name that exists twice with different bytesthe chip asks which one before it can be sent
Click 附加失败,重新试一次 on a failed chipthe locate/stage step runs again
Press Enter while a chip is still resolving or waiting on a pickthe notice names that attachment and says why, and the chip stays

Up to 20 attachments per message.

A pasted picture is handed to the platform the way the platform itself would hand it over, so a screenshot from WeChat, Snipping Tool or anything else that copies a bitmap arrives as an ordinary image attachment. The media type comes from the file, then from its extension, then from its first bytes — a picture that describes itself as nothing at all still lands as a picture, and one that never arrives as a file is named in the diagnostics instead of vanishing.

Install

dsh plugin --profile web add github:RCYD857/857-dsh-file-attach

The package has no build step, so no allowBuilds approval is needed. Restart DSH afterwards and hard-refresh the page. To remove it: dsh plugin --profile web remove dsh-file-attach.

For local development, link the source directory instead:

dsh plugin --profile web add link:/path/to/dsh-file-attach

Requires a DSH profile with the Web client (--profile web). Developed and verified on DSH Desktop for Windows.

How files reach the model

DSH gives plugins no general-purpose file attachment channel — only images. So the plugin splits the work:

  • Images (image/*) go through the platform's existing image pipeline and are sent as visual input. The door is the platform's own: conversation.createDraftImages(files) validates the media type and registers each file, and the session facade's addImages(ids) takes the ids it returns. Handing that facade the files themselves attaches nothing — its draft registry never issued those values, and the composer's own reconciliation pass drops them on the next render.
  • Every other file becomes a card inside the input, and at send time the wrapped conversation.sendSession expands it into @path (quoted as @"path" when it contains spaces) — one choke point every submission passes through, image-only sends included. Nothing is written into the draft: the text box stays exactly what you typed, and a failed send keeps its cards so resending carries them again.

Already-sent messages need no extra work: projectUserText renders @path as a file-reference chip, so the file reads as a reference in the transcript too.

Where the path comes from (three layers, most faithful first)

  1. The drag payload's own path. Explorer attaches text/uri-list, which is the user's original file location. Nothing is copied.
  2. The content fingerprint of a pasted file. A file copied in Explorer and pasted into the composer arrives with no path at all — only a name and its bytes. Those bytes are the original's bytes, so the browser sends their SHA-256 (up to 64 MiB) with the locate request, and the host resolves a same-name crowd by that: the copy that hashes to what the user handed over is the file they copied, wherever they copied it from, and the picker is never shown. A fingerprint that matches nothing, or is missing because the file was too big to hash, falls back to the rules below.
  3. A bounded name search on the host — POST /file-attach/locate. Search roots, in order: the current workspace → other workspaces → inbox-style directories directly under each workspace (inbox, input, inputs, drops) → Desktop, Documents, Downloads → directories that produced a hit before (remembered in <DSH_HOME>/file-attach-roots.json, at most 24 of them). The walk matches folders as well as files: a folder dragged out of Explorer carries no path either, so matching only files is what produced the dead end "a folder cannot be attached: the machine never gave its original path" — for a folder sitting on the Desktop. A folder that resolves becomes @folder/, which the model lists and reads inside. Each root is walked breadth-first with a depth limit of 5 and 20,000 entries visited, a 3 s budget for the search as a whole, and at most 20 candidates. Same-name candidates are narrowed by byte size, then by the fingerprint of what the user handed over, then by content among themselves: copies holding identical bytes resolve themselves (the one inside the session's workspace wins, so the model gets a path it can open), and only genuinely different files with no matching fingerprint make the card offer a picker. A card that is still unresolved when you send is named in the prompt's notice — see Nothing is dropped in silence.
  4. A staged copy — POST /file-attach/stage. The browser uploads the bytes and the host writes them to <workspace>/.dsh-attachments/<name>. This is the fallback that makes dropping always work: a browser is not obliged to reveal where a dropped file came from (Electron's drag payload usually reveals nothing at all), and the bytes are what the model ultimately needs.

The staging directory ships its own .gitignore (contents: *), so copies never enter version control. The per-file cap is 256 MiB, and names are sanitized against traversal and illegal characters. A card tagged as a copy took this layer; the whole .dsh-attachments directory can be deleted at any time without touching the originals.

Reuse is decided by bytes alone, across every same-named copy (name, name-2, name-3, …). Two earlier rules were wrong and were replaced: comparing size only served stale content after an equal-length edit, and checking only the first candidate (name) never matched a copy that had landed in name-2, so every drop created yet another file. Bytes are the only thing that identifies a file: a same-named file with different content always lands in a new copy (name-2.ext) and is never silently overwritten; only byte-identical content is reused.

Nothing is dropped in silence

Only a resolved card has a path to expand, so a file that is still locating, still staging, or waiting on a pick contributes nothing to the prompt. That used to happen without a word: a user attached a .yml that happened to exist in two places under the same name and the same size, the host answered choose, the card sat in the strip looking attached exactly like a resolved one, and the message went out carrying only the text. The bug was the silence, not the pick.

Three things changed, at the layer each belongs to:

  • The host decides when the answer cannot matter. Same-name candidates are compared byte for byte (bounded to 8 candidates and 4 MiB each, so a drop can never stall on it). If every candidate holds the same bytes, the question is answered rather than asked. Past either bound the answer is "not proven identical", which keeps the picker instead of guessing.
  • The card stops looking ready. A card awaiting a pick shows 同名文件有 2 个,请先选择 where a resolved card shows yml · 502 B, so the state is legible without clicking anything.
  • The send reports what it left behind. The send boundary names every unresolved card — the file, why it could not ride, and what to do — and keeps the card, so the next attempt carries it.

Recorded in the suites: check-host.mjs pins identical-versus-different resolution (including the comparison ceiling falling back to the picker), check-client.mjs pins that an unresolved card adds no mention, raises the notice, and survives the send, and check-spec.mjs pins the card copy.

The chip row

  • Where they are — in the composer card's own top area, the same place the platform's image rail lives, pushing the text row down. The rail is mounted in the card-internal slot anchor, a zero-height absolute box at the card's top edge, so the room the tiles take is claimed by the card's own padding-top: the rail measures itself (offsetHeight, which includes its padding) and writes that height straight onto the card's inline padding-top — inline because a stylesheet rule was tried first and lost to the platform's own padding (the report file from a real DSH showed the card keeping its 8px while a tile sat on top of it). With nothing attached the inline value is restored to what the card had before and the composer is exactly as tall as it is without this plugin.
  • Layout — one row, 8 px between chips, 12 px side padding, 244 px chips, scrolled by the arrow at each end (one page per click) rather than wrapped. The arrows float over the chips and exist only while that end still hides one: a row that fits shows neither, a row at its left end shows only the right one. They take no layout space, so the first and last chip sit flush with the row, and the chip caught underneath one fades into the card rather than being cut off — a CSS mask on the scroller (54 px per hidden edge), driven by the same measurement as the arrows, so the two always appear and disappear together and the arrow itself stays fully opaque above the fade.
  • Pictures ride along — a picture still goes through the platform's image pipeline (that is what makes it visual input), but it is drawn as a chip in this row instead of in the platform's own thumbnail row, which sits in the very same place and is hidden by one stylesheet rule. Its badge is the picture itself; its × releases the platform's registry entry and drops the draft id, exactly like the composer's own ×.
  • Chip — 12 px radius, 1px rgb(0 0 0 / 6%) border, #f5f5f7 surface, hover #ebecef. On the left a 34 px badge (the picture itself for a picture, the file family's glyph otherwise), on the right two lines: the name at 13.5 px #1d1d1f (ellipsised) and the state at 11.5 px #86868b.
  • Remove — a 20 px dark circle on the chip's top-right corner, always visible; the row's vertical padding is what keeps it from being clipped.
  • States — a pending chip spins in its badge and names the step it is on (正在定位… · 2.0 KB, 正在上传… · 1.5 MB); a failure turns border, surface and text red (#ffccc7 / #fff1f0 / #d4380d) and offers 附加失败,重新试一次 under the name; a same-name pick puts the question there and a picker under it. Drag hover outlines the row in #1677ff.

Diagnostics

The desktop shell has no developer tools, so the browser half reports measured facts back through POST /file-attach/report into <DSH_HOME>/file-attach-report.json, appended by checkpoint (apply → slot-probe → slot-registered → slot-component → rail-mounted → rail-rendered → strip-geometry → drop → drop-admitted, plus paste, paste-no-files and drop-ignored for the cases that never reach a card) and including the rail's measured box against the composer card, the card's computed padding-top and the number of tiles. node read-report.mjs prints it.

Reports are written from useEffect, never during render — writing module state while rendering trips React's re-render protection.

The same route doubles as the quickest liveness check on a running host: a registered route answers GET with 405, while a host that never loaded the plugin answers 404.

Limitations

  • The text area and the bottom action bar belong to DSH. The cards sit in the card's top area but they are this plugin's own markup; styling, placeholder and send button remain the platform's.
  • Making room means setting one property on the platform's card. The rail is absolute inside the card's zero-height anchor, so it writes the measured height onto [data-composer-card]'s inline padding-top (and restores the value it found when it empties). That is the one place this plugin writes to an element it does not own; it is inline rather than a stylesheet rule because the platform — or another composer-restyling plugin — can hold a rule of the same specificity, and the measured 8px on the card is what a stylesheet rule lost to.
  • The platform's image thumbnail row is hidden by one stylesheet rule (a suffix match on its CSS-module class, scoped to the attachments slot). The platform still owns the pictures' bytes, previews and vision payload — the plugin only draws their chips in the same row as the files. If the platform renames that class the rule stops matching and pictures show up twice (once in this row, once in its own) until the selector is updated.
  • The rail is anchored to the conversation.input.overlay slot and the send expansion hooks conversation.sendSession. A change to either in DSH's client UI needs a matching update here.
  • On Windows, Explorer does not reliably send dragleave when a drag ends, so overlay visibility is driven by a dragover heartbeat (hidden after 320 ms of silence) rather than by dragenter/dragleave counting.

Design notes

Things that were tried and abandoned, recorded so they are not tried again:

  • Not in the document flow. Registering the rail in composer.dock with order: -1 and a negative margin did push the conversation upward, but it moved the input card with it, and the pull-back amount was wrong in every variant. The rail belongs inside the card, where the platform puts its own attachments, not above it.
  • Not a second panel. An earlier form floated a white panel with its own shadow above the card (bottom: 100%), and before that translateY(100%) plus a shared background tried to merge the two into one form, which changed the platform card's appearance and was reverted. The cards are now chrome-less tiles laid directly on the card's own surface, so there is nothing to merge.
  • The card's growth is measured, not guessed. The rail sits in a zero-height absolute anchor, so the only way its height can push the text row down is through the card's own padding. That value is published by the rail (--fa-card-tiles, with a marker attribute scoping the rule) and re-measured by a ResizeObserver, because tiles re-wrap, names re-ellipsise and thumbnails decode after the first paint. Removing the marker on empty is what keeps an unused composer exactly its normal height.
  • The slot component cannot rely on a session prop. conversation.input.overlay renders its components without one (only composer.dock passes session), so the rail reads the current session from the store (ctx.sessions.list.getSnapshot().current). Judging by the prop makes the strip return null forever — the symptom is "I dropped a file and no attachment box appeared".
  • Big tiles were tried; the row is the wanted form. A revision drew Codex-style tiles (a 118 px glyph area over the name, wrapping into a grid, the card growing with every row). It is a faithful copy of that form, and it was rejected on sight: with seven files it fills the composer. The row — chips, two arrows, one page per click — keeps the input box its own size, which is what the user asked for after pointing at Doubao's composer.
  • The drop outline follows the heartbeat, never the drag events. The rail first drew its dashed border from its own dragenter/dragleave pairs, and on Windows a drag that ends without ever delivering dragleave left the border stuck on screen. It now reads the same dragover heartbeat the full-screen invitation already uses (DRAG_IDLE_MS of silence retires both).
  • Pictures were folded into the row, not left beside it. The platform draws draft pictures in conversation.input.attachments — a single slot in the same place this rail occupies — so there was no way to add file chips to that row; the row had to become this plugin's, and the platform's own thumbnail strip is hidden behind it. The alternative (a second row of thumbnails under the chips) is what the user rejected, and a picture's chip still keeps the platform's pipeline, preview URL and removal path untouched.
  • Media types are filled in from the extension. The platform's image pipeline accepts only png/jpeg/webp/gif and has no extension fallback, while a file dragged out of Explorer frequently arrives with an empty file.type (a paste carries a type, so pasting always worked). An empty type throws UnsupportedImageMediaTypeError, which aborts the entire drop handler — and it does so silently, so the visible symptom was "images cannot be dropped". Formats that cannot be mapped (such as tif) fall back to a path card, which at least gives feedback. Sniffing the file header instead of trusting the extension is the next step here: exported images sometimes carry JPEG bytes under a .png name.
  • The plugin owns the drop. The platform's own image drop target needs dragActive, a canAcceptDrop snapshot and media admission to hold at once, and whether it received a drop at all is not observable from the same task. The plugin therefore takes the drop with stopImmediatePropagation and then issues the exact same image intake the platform would have made — same pipeline, triggered from a place that actually fires.
  • addImages takes draft ids, not files. The session facade's addImages(ids) links ids the composer's draft-image registry already owns; the files go to conversation.createDraftImages, which validates their media type and returns the ids. Calling addImages(files) instead pushed values no registry had ever issued: draftImages(ids) resolved them to nothing, the composer's reconcile pass dropped them one render later, and the visible result was a pasted screenshot that produced no thumbnail, no card and no error. The paste made it worse than a no-op — this plugin listens in the capture phase and had already preventDefault-ed the event, so Lexical's own PASTE_COMMAND (which would have attached the picture correctly) never ran. Pictures now travel createDraftImages → addImages(ids); a paste is swallowed only once there is a session to attach to; and every refusal past that point is spoken out loud. The old check-client.mjs fake blessed the wrong contract — it let addImages take files — which is exactly why the suite stayed green while the app stayed silent; the fake now models the registry.
  • An inline @ chip was tried and is not what was asked for. Reading "make it like Codex" as "insert the file into the text", an earlier revision inserted the platform's own atomic reference chip at the caret through the scoped slash/input-insert-reference event. That worked, and is recorded here because it is a reasonable thing to want and would be easy to rebuild — but the form the user pointed at is the one in the picture: cards in the input box, with the text left alone. So the chips are gone, and the card is once again the only surface a file has.
  • The platform's drag overlay is removed, not restyled. It labelled every file "drop an image here", counted dragenter/dragleave in a way that never balances on Windows (so it stuck on screen), and only accepted a drop that landed on the input card. A MutationObserver with subtree: true removes any element whose class contains _mask while a file drag is in progress, with a CSS rule as a fallback. Since the plugin handles file drags itself, the overlay has nothing left to do.
  • Slot registration is probed, not assumed. The slot is checked with ctx.slots.spec and the registration is wrapped in try/catch, so a host without that slot cannot take the drag listeners down with it.

Development

npm run check            # four suites: browser half / host routes / visual spec / rail rendering
node probe-locate.mjs "<workspace root>" --name <filename>   # timing and hits of the locator

All four suites run under plain Node — no DSH and no browser. The visual spec suite asserts the strip's geometry, colours, spacing and the arrow/overflow relationship one rule at a time; the rail render probe mounts the real component through a minimal hook engine and fails on any throw.

Editing lib/client.js or lib/index.js requires a DSH restart before either half picks up the new code (the client's HMR only runs under dev:web), followed by a hard refresh of the page.

FileResponsibility
lib/index.jshost half: /file-attach/locate bounded name search, /file-attach/stage copy staging, /file-attach/report diagnostics
lib/client.jsbrowser half: attachment state, the card strip, the preview panel, drag/paste capture, send-time expansion
cordis.patch.ymlbundle patch layer that mounts the host half into a profile
check-client.mjsadmission and routing, the draft staying untouched, send-time expansion, staging fallback, retry, overlay liveness, layout contract
check-host.mjshost routes and staging boundaries, including traversal, name conflicts and the size cap
check-spec.mjsthe visual spec assertions listed above
probe-rail-render.mjsrenders the strip without a browser and asserts no throw and image cards present
probe-locate.mjslocator hits and timing against a real directory
read-report.mjsprints the layout and lifecycle checkpoints written back by the browser

License

MIT — see LICENSE.


中文说明

把任意东西——文件或截图——拖进/粘进 DSH Web 输入框,它都会变成输入框内部的一枚标签,就是豆包那种形式:一行紧凑的标签条(图标 + 文件名 + 状态),哪一侧还有藏着的标签哪一侧才浮出一个翻页箭头,每枚标签右上角是删除按钮。 文本框本身永远不收路径:发送时文件标签展开成 @路径 引用交给模型用自己的工具打开,图片则照旧作为图片送给模型。

English · 行为 · 安装 · 文件怎么到模型手里 · 标签条 · 限制 · 设计取舍 · 开发

行为