# dsh-web-file-uploader > **🌐 Language** Β· [English](README.md) | [δΈ­ζ–‡](README_zh_CN.md) 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. - **Repository**: https://github.com/Mooling0602/dsh-web-file-uploader ## 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](#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 `ImageBlock`s; 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) ```text 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: ```bash # 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:/` (e.g. > `github:Mooling0602/dsh-web-file-uploader`). A bare `github.com//` > 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](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): ```bash 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](PUBLISHING.md#update). ## 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 | `/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](#deduplication) and [Cleanup & retention](#cleanup--retention-ttl-gc--manual-delete)). ## 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