RCYD857/857-dsh-file-attach ↗★ 0
dsh-file-attach
Drag or paste files or pictures into the DSH Web composer as one scrolling row of chips inside the input box — the form Doubao uses — with the files sent as @path references 适合需要直观管理待发送文件、并以路径引用或图片形式传给模型的用户
같은 패키지 이름의 다른 저장소
설치
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:RCYD857/857-dsh-file-attachdsh-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 do | You get |
|---|---|
| Drag a file anywhere on the page | a full-screen hint; on release a chip appears in the input box, above the text |
| Paste a file into the input | the same |
| Drag or paste a picture | it 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 attached | the chip: badge, name, ext · size (or the step it is on) |
| Click × on a chip | the chip fades out, and is not sent |
| Click a chip | preview panel — full image, the first 200 KiB of text or code, file metadata otherwise |
| Attach more files than fit | the 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 |
| Send | every ready chip is expanded into the prompt as its @path |
| A file whose path must be found first | its chip shows 正在定位… → 正在上传…, then becomes ready |
| Drag a folder | it 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 bytes | the chip asks which one before it can be sent |
| Click 附加失败,重新试一次 on a failed chip | the locate/stage step runs again |
| Press Enter while a chip is still resolving or waiting on a pick | the 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'saddImages(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.sendSessionexpands 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)
- The drag payload's own path. Explorer attaches
text/uri-list, which is the user's original file location. Nothing is copied. - 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.
- 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/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. - A staged copy —
POST /file-attach/stage. The browser uploads the bytes and the host writes them to/.dsh-attachments/. 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 showsyml · 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 inlinepadding-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,#f5f5f7surface, 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 /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 inlinepadding-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.overlayslot and the send expansion hooksconversation.sendSession. A change to either in DSH's client UI needs a matching update here. - On Windows, Explorer does not reliably send
dragleavewhen a drag ends, so overlay visibility is driven by adragoverheartbeat (hidden after 320 ms of silence) rather than bydragenter/dragleavecounting.
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.dockwithorder: -1and 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 thattranslateY(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-t