dshbase

插件目录 / Developer / dsh-web-file-uploader

dsh-web-file-uploader

已验证 · 实测可装 Mooling0602

✓ 持续维护

查看 GitHub ↗ ← 返回插件目录

11Stars
1Forks
2未关闭 issue
JavaScript语言
2026-09-10最近推送
跨平台平台

功能简介

A file-upload plugin for the DeepSeek Harness web UI.

✅
我们的评价
可用 — 实测通过,早期项目

A file-upload plugin for the DeepSeek Harness web UI. 实测能干净安装、正常启动。早期项目,但功能可用。

「已验证」表示我们的自动化 CI 在干净 profile 里实际执行了 dsh plugin add 并启动成功——仅此而已。功能描述与版本兼容性均为作者声明。这不是安全审计,也不代表对第三方代码的背书。

README

dsh-web-file-uploader

🌐 Language · English | 中文

A file-upload plugin for the DeepSeek Harness web UI. It adds a
DeepSeek-web-style paperclip attach button to the composer input row and
uploads the selected files to the DSH host machine — with model-aware
adaptation
so the files are actually usable by the running model, and
content-addressed deduplication so storage never gets flooded by
re-uploads.

Features

  • 📎 Paperclip attach button in the composer tool row, wearing the official
    attach button's chrome (28px circle, --dsw-specific-selector fill, 14px
    filled glyph, --dsw-alias-interactive-bg-hover-solid hover) with the
    paperclip rotated 45° to keep this plugin's long-standing diagonal, so the
    two buttons stay tellable apart at a glance. It sits directly to the right of
    the official attach button, ahead of the permission chips — and Settings can
    optionally hide the official button and let this one take its place
  • Multi-file selection with preview-style attachment cards (image
    thumbnails) in the dock above the composer: reading → uploading → saved,
    with copy-path and remove buttons
  • Files are stored on the DSH host, never only in the browser
  • Name sanitization (path separators, .., control characters rejected) and
    automatic -1/-2 collision suffixes for distinct files with the same name
  • Content-addressed deduplication — see Deduplication
  • UI strings localized through the app's locale service (zh / en; follows
    the dsh web language setting or the system default)

Model-aware adaptation & the attachment-card design

Uploaded files are shown as attachment cards in the dock above the
composer. The cards are the source of truth for injection:

State Behavior
Card present The file's absolute path is injected into every user message sent while the card is visible
Card closed (×) The plugin calls the host remove RPC — the file is dropped from the pending registry, no longer injected, and the file is kept on disk so the model can still re-read it from the uploads folder
Card deleted (🗑) The plugin calls the host delete RPC — the file is permanently removed from the DSH host and the dedup index is scrubbed
Ctrl+V paste Pasted images ride the native draft-image pipeline — no card is created and the plugin never touches them

The distinction between × and 🗑 is intentional: × just stops injecting
(you might want the model to find the file later), while 🗑 is a statement that
you are done with the file forever. Deleting the last reference to a file
removes it from disk; if another live session still references the same
deduplicated copy, the file is kept and only this session's reference is
dropped.

This is a deliberate design decision: the cards persist after sending (unlike
paste previews that clear on send), so you decide how long a file stays
"attached" to the conversation. Injection is one-shot per message, and the
injected block lists exactly the files whose cards are currently open.

Injection is model-aware:

Model type Image files (png/jpeg/webp/gif) Other files
Multimodal (reported inputModalities includes image) Native ImageBlock via the attachments service — the image is part of the request, like a normal attached image, with no extra prompt text Path text block
Text-only (e.g. DeepSeek V4 series) Path text block — the model can call read/vision tools to inspect them Path text block

The capability check uses llm.resolveModelInfo().inputModalities (cached for
10 minutes, safe text-only fallback).

The injected prompt uses a readable, English-only format (the model reads it):

-----
[Attached files] Some files have uploaded with this message:
- /path/to/file1.txt
- /path/to/image1.png
Read the files or use tools to analyse (like vision tools), then answer the user.

Compatibility with vision-tools and other plugins

  • Zero coupling: the plugin only injects absolute file paths into the
    prompt. It never calls, wraps, or assumes any auxiliary tool — vision-tools
    or any other reader simply receives the path and works independently.
  • Native multimodal path: for image-capable models, images are injected as
    native ImageBlocks; the model uses its own multimodal ability and is not
    prompted to reach for external tools.
  • Non-invasive: injection targets only real user messages
    (source.kind === 'user'); steering/system messages are never touched, and
    a message that already carries the [Attached files] marker is never
    injected twice (guard against concurrent host instances). Pasting images
    via Ctrl+V is handled entirely by the product's native pipeline.

Deduplication

Re-uploads cannot flood storage:

  1. On upload, the plugin computes the SHA-256 of the decoded bytes
    (dynamic mode pipes base64 -d | sha256sum through the shell service;
    the static bundle uses node:crypto).
  2. A persisted index at uploads/.dfu-index.json maps hash → stored path.
  3. If identical content is uploaded again (same name or different name), the
    existing stored copy is reused — no new file is written, no -1
    suffix copy is created, and the response carries dedup: true.
  4. Uploads are serialized through a promise queue so concurrent identical
    uploads cannot race; a stale index entry (file deleted) falls back to a
    fresh store.
Scenario Result
Same file uploaded N times One copy on disk; all uploads resolve to the same path
Same content, different filename Reuses the first stored copy
Same name, different content Normal -1 collision handling (correct)
Process restart The index file persists, dedup keeps working

The index entry is hash → { path, at } where at is the upload timestamp
(epoch ms) used by the TTL cleanup below. Legacy hash → path entries are read
transparently and upgraded to the object form on the next write; legacy entries
carrying no timestamp are never reclaimed by TTL (only scrubbed when the file
is gone).

Cleanup & retention (TTL GC + manual delete)

Uploaded files are reclaimed in two complementary ways:

Automatic TTL garbage collection

Every upload, every permanent delete, and plugin start trigger a lazy sweep
(after the success). Files older than the TTL that are not referenced by
any live session's pending set are removed from disk and their index entries
are dropped. Sweeps run on the same serialized queue as uploads, so they never
race in-flight writes; no background task is required.

  • Default TTL: 7d (7 days).
  • Retention units: 1s (seconds), 1m (minutes), 1h (hours), 1d
    (days); a bare number with no unit is treated as days (legacy behavior).
    0 (or an empty/invalid value) disables automatic GC — files are only
    removed when you explicitly click 🗑.
  • Configure in Settings → File uploads (native DSH settings panel; the
    tab carries its own paperclip mark, so it never reads as the gear rows other
    plugins get): the retention field and the replace the official attach button
    switch share one Save button. The static bundle persists both in
    ~/.dsh/dsh-web-file-uploader.json ({ ttl, replaceOfficial }) and merges
    updates field by field, so saving one never clears the other. The environment
    variable DSH_UPLOAD_TTL (e.g. DSH_UPLOAD_TTL=30m) is honored as a
    fallback before the default. In dynamic (session) mode the setting lives in
    memory, overlaid over the optional dsh-web-file-uploader settings namespace.

Files whose timestamp is unknown (legacy index entries) are never auto-removed,
protecting pre-existing uploads from being deleted on migration.

Attach-button placement & replacing the official button

The composer tool row reads [+ menu] [official attach] [permission chips].
This plugin's button always stays visible and is placed directly to the right
of the official attach button
, ahead of the permission chips. No node React
owns is moved: the shell's slot wrapper is display: contents, so flex
order re-sequences the row — ours at 1, everything the shell draws after its
own attach button at 2.

Replace the official attach button Result
Off (default) Two buttons side by side: the official upright paperclip, then this plugin's diagonal one
On The official button is hidden and this plugin's button stands in its place — a single upload control in the row
  • Saving applies at once, with no refresh, and to every composer mounted later.
  • Switching it off and saving — or disabling/unloading the plugin — puts the
    official button back exactly as the shell drew it: hiding writes one inline
    style and never detaches a node, so React's tree stays intact.
  • While the official button is hidden, its own pending-attachment rail can no
    longer be triggered; uploads, progress and cleanup all belong to this plugin.
  • This plugin's button is never disabled by a running turn — a file picked while
    the machine is busy is injected at the next step.
  • If a future shell stops matching, the row is left exactly as drawn, the
    official button stays visible, and one console warning names the reason.

Two card actions (recap)

Action Host call File on disk
× close remove Kept — model may re-read it
🗑 delete delete Removed permanently

Closing a card whose upload is still in flight asks first, because the bytes
are still moving and both answers are real: Cancel upload aborts the transfer
and drops the staged bytes (nothing lands on disk), while Keep uploading
dismisses the question and leaves the card — and, once it finishes, its
reference — intact. Neither answer leaves an orphan copy behind, and the
question only appears while the transfer runs: a finished card still closes in
one click.

The delete host call drops this session's reference, checks that no other
live session references the same deduplicated copy, and only then unlinks the
file and scrubs the index. If another session still references it, the file is
kept (removed: false) but this session's card is still cleared.

Attachment size limits

  • No per-file cap. The static bundle writes the request body through as it
    arrives while hashing the same pass; the dynamic plugin has the browser slice
    the File and append one chunk per RPC call. Neither path holds the file in
    memory (a 120 MiB upload grew the process by < 1 MiB), so the only bound left
    is disk space.
  • The card shows a percentage while uploading: XHR upload.onprogress in the
    static bundle, sent-chunk accounting in the dynamic plugin.
  • The fallback path still has a ceiling. A caller that hands over base64
    instead of a File (the old call shape) stays under the single-shot 64 MiB
    base64 / ≈48 MiB payload cap; that path is kept for compatibility only.
  • Images additionally follow the deployment's attachments limits (per-message
    byte/pixel caps) when injected natively for multimodal models.

Installation

A. Dynamic plugin (current session, no install)

cordis_define + cordis_run   # host = src/host.js, client = src/client.js

Approve the run card and the paperclip button appears immediately. The plugin
is process-local: after a restart, define and run it again.

B. Static bundle (persistent, dsh plugin)

The package declares dsh.bundle.patch (see cordis.patch.yml), so
dsh plugin --profile web add recognizes it as a profile layer. Any
pnpm-supported source works:

# Git repository (recommended distribution channel)
dsh plugin --profile web add github:Mooling0602/dsh-web-file-uploader

# Local directory (development)
dsh plugin --profile web add ../dsh-web-file-uploader

# Tarball
dsh plugin --profile web add ./dsh-web-file-uploader-0.2.0.tgz

# npm registry (after publishing)
dsh plugin --profile web add dsh-web-file-uploader

Git spec note: pnpm's git shorthand is github:<owner>/<repo> (e.g.
github:Mooling0602/dsh-web-file-uploader). A bare github.com/<owner>/<repo>
is treated by pnpm as a local directory and will fail with a
"non-existent directory" warning. Other valid forms:
git+https://github.com/Mooling0602/dsh-web-file-uploader.git or
https://github.com/Mooling0602/dsh-web-file-uploader.git.

Restart the dsh web process and refresh the page. See
PUBLISHING.md for distribution details and the optional npm
publish flow (requires your npm credentials).

Update

dsh plugin forwards to pnpm in the profile directory, so update through it
(never edit ~/.dsh/profiles/web/node_modules by hand — the next pnpm
operation rewrites it):

dsh plugin --profile web update dsh-web-file-uploader
# or, if the lockfile-pinned resolution refuses to move:
dsh plugin --profile web remove dsh-web-file-uploader
dsh plugin --profile web add github:Mooling0602/dsh-web-file-uploader

Restart the dsh web process afterwards; the served bundle URL carries a
content-hash revision (?rev=…) so the browser picks up the new build on
refresh. For local-directory installs, run pnpm build in the checkout
before re-adding it. Details: PUBLISHING.md.

Architecture

Browser (Client)                          DSH host (Host)
─────────────                             ─────────────────
conversation.input.left                   harness.handle('upload-begin'/'upload-chunk'/
  └ paperclip ── File (sliced) ┐              'upload-finish'/'upload-abort')  [dynamic]
                              │           webServer route POST /upload        [static]
                              ▼           ┌ sandboxPolicy.resolve() → workspace root
              File slices / body          ├ session cwd / DSH_HOME + /uploads/
                              │           ├ stage .dfu-tmp-*, incremental SHA-256
                              ▼           ├ commit: dedup → name → move → .dfu-index.json
                                          └ base64 -d >> tmp (dynamic) / node:fs stream (static)
conversation.input.dock
  └ attachment cards (persist)            harness.handle('remove', …) / remove route
       └ × closes card → stop injecting     └ pending entry deleted (file kept)
       └ 🗑 deletes card                  harness.handle('delete', …) / delete route
                                              └ pending dropped → unlink + index scrub
                                           lazy TTL sweep (upload / delete / start)
                                              └ remove files past TTL, not in pending
                                          agent/pre-step waterfall
                                             ├ resolveModelInfo → multimodal?
                                             ├ attachments.saveImage → ImageBlock
                                             └ path text block (user messages only)

Why does the dynamic mode go through the shell's base64 -d? The dynamic
sandbox disables require, and the fs service only writes whole UTF-8 text —
so binary has no other way to disk than the shell service's stdin. Appending
per chunk (>>) keeps each command bounded by the chunk size instead of the
file size. The static bundle has no such restriction and streams with
node:fs instead.

Storage location

Mode Destination
Dynamic plugin <session workspace>/uploads/ (sandboxed shell/fs cannot leave the workspace)
Static bundle $DSH_HOME/uploads (default ~/.dsh/uploads) via node:fs — the dsh data directory

The dedup index (uploads/.dfu-index.json) lives next to the stored files and
maps hash → { path, at } (see Deduplication and
Cleanup & retention).

Repository layout

The project follows a single-source-of-truth core + thin seam architecture:
all business logic lives in src/core/*; the dynamic plugin and the static
bundle are thin adapters over it, so changes are made once and both sides pick
them up.

dsh-web-file-uploader/
├── src/core/
│   ├── host-core.js        # canonical host logic (transport-agnostic, DI)
│   └── client-core.js      # canonical client logic (transport-agnostic, DI)
├── src/seams/
│   ├── host-dynamic.template.js     # dynamic host seam (harness + shell/fs)
│   ├── client-dynamic.template.js   # dynamic client seam (host.call + React)
│   └── client-static.template.js    # static client seam (fetch + module react)
├── src/host.js             # GENERATED dynamic host (core inlined) — do not edit
├── src/client.js           # GENERATED dynamic client (core inlined) — do not edit
├── lib/index.js            # static host seam (imports core; node:fs/crypto/webServer)
├── client/src/client.js    # GENERATED static client source — do not edit
├── scripts/
│   ├── build-dynamic.mjs   # inlines cores into seams -> src/*.js + client/src/client.js
│   └── build-client.mjs    # wraps client/src/client.js -> lib/client.js
├── cordis.patch.yml        # dsh.bundle patch (profile layer row)
├── package.json            # publishable manifest (dsh.bundle + dsh.client)
├── PUBLISHING.md           # install & npm publish guide
├── README.md / README_zh_CN.md
└── LICENSE                 # MIT

How to change code: edit src/core/* (or a seam), then run
pnpm build — it regenerates the dynamic sources (src/host.js,
src/client.js) and the static client bundle (lib/client.js). For the
running dynamic plugin, redeploy the regenerated src/host.js /
src/client.js via cordis_define + cordis_run.

Development status

  • ✅ Dynamic plugin: implemented and verified in live sessions
  • ✅ Card-driven injection, model-aware adaptation, dedup, i18n UI
  • ⚠️ Static client module: built by scripts/build-client.mjs, but the
    __ModuleLoader__ wrapper must be verified against the real web toolchain
    before distribution

License

MIT

安装

🧩 让 Agent 自动装(推荐)

装一次目录插件,之后本站所有插件都能让 DeepSeek Harness 自动找、自动装:

dsh plugin add dshbase-catalog

然后对 agent 说「帮我装 dsh-web-file-uploader」,它会在目录里找到并自动安装。文档:dshbase-catalog · 已验证场景包。

该插件是 GitHub 源码(未发 npm)——直接从仓库装:

Web profile:

dsh plugin --profile web add github:Mooling0602/dsh-web-file-uploader

Headless(CLI)profile:

dsh plugin --profile headless add github:Mooling0602/dsh-web-file-uploader

实测报告

验证通过:从 GitHub 源码完成 L1 安装 + L2 加载 + L3 运行(dsh 0.1.0-rc.6)。

安全:尚未扫描——我们的每日静态扫描将很快覆盖它。

分享徽章

Developer 里更多

浏览全部 7797 个插件 →