dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
把文件或图片拖进 DSH Web 输入框,以一排小卡片展示,发送时以 @path 引用传给模型。
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
| 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.
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.
DSH gives plugins no general-purpose file attachment channel — only images. So the plugin splits the work:
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.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.
text/uri-list, which is the user's original
file location. Nothing is copied.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.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.
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:
同名文件有 2 个,请先选择 where a
resolved card shows yml · 502 B, so the state is legible without clicking anything.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.
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.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.正在定位… · 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.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.
[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.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.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.Things that were tried and abandoned, recorded so they are not tried again:
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.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.--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.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".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).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.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.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.@ 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.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.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.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.
| File | Responsibility |
|---|---|
lib/index.js | host half: /file-attach/locate bounded name search, /file-attach/stage copy staging, /file-attach/report diagnostics |
lib/client.js | browser half: attachment state, the card strip, the preview panel, drag/paste capture, send-time expansion |
cordis.patch.yml | bundle patch layer that mounts the host half into a profile |
check-client.mjs | admission and routing, the draft staying untouched, send-time expansion, staging fallback, retry, overlay liveness, layout contract |
check-host.mjs | host routes and staging boundaries, including traversal, name conflicts and the size cap |
check-spec.mjs | the visual spec assertions listed above |
probe-rail-render.mjs | renders the strip without a browser and asserts no throw and image cards present |
probe-locate.mjs | locator hits and timing against a real directory |
read-report.mjs | prints the layout and lifecycle checkpoints written back by the browser |
MIT — see LICENSE.
把任意东西——文件或截图——拖进/粘进 DSH Web 输入框,它都会变成输入框内部的一枚标签,就是豆包那种形式:一行紧凑的标签条(图标 + 文件名 + 状态),哪一侧还有藏着的标签哪一侧才浮出一个翻页箭头,每枚标签右上角是删除按钮。 文本框本身永远不收路径:发送时文件标签展开成 @路径 引用交给模型用自己的工具打开,图片则照旧作为图片送给模型。
English · 行为 · 安装 · 文件怎么到模型手里 · 标签条 · 限制 · 设计取舍 · 开发