# dsh-web-file-uploader > **🌐 语言切换** · [English](README.md) | [中文](README_zh_CN.md) 一个面向 **DeepSeek Harness** 网页端的文件上传插件。在输入框工具行加入一个 **DeepSeek 网页版风格的回形针附件按钮**,将所选文件上传到 **DSH 宿主机**——内置**模型感知适配**让文件真正能被当前模型使用,并带**内容寻址防重传**避免重复上传刷爆存储。 - **仓库**:https://github.com/Mooling0602/dsh-web-file-uploader ## 功能特性 - 📎 输入框工具行里的回形针按钮:外观与官方附件按钮完全一致(28px 圆形、`--dsw-specific-selector` 底色、14px 实心图标、悬停 `--dsw-alias-interactive-bg-hover-solid`),只有回形针旋转 45° 保持本插件一贯的斜向造型,两个按钮并排时一眼可分;按钮固定在**官方附件按钮右侧、权限设置之前**,可在设置中**隐藏官方按钮并顶替它的位置** - 支持多选;每个文件以**预览式附件卡片**显示在输入框上方(图片显示缩略图):读取中 → 上传中 → 已保存,带"复制路径"与移除按钮 - 文件保存在 DSH 宿主机,不会只留在浏览器里 - 文件名清洗(拒绝路径分隔符、`..`、控制字符)+ 同名但内容不同的文件自动追加 `-1`/`-2` 后缀 - **内容寻址防重传** —— 见[防重传机制](#防重传机制) - UI 文案通过应用 `locale` 服务本地化(zh/en,跟随 dsh web 语言设置或系统默认) ## 模型感知适配与附件卡片设计 上传的文件以**附件卡片**形式显示在输入框上方的 dock 行。卡片是注入行为的唯一依据: | 状态 | 行为 | |---|---| | 卡片存在 | 该文件的绝对路径会注入到**此后发送的每一条用户消息**中 | | 点击卡片 `×` 关闭 | 插件调用 Host 的 `remove` RPC —— 文件从 pending 注册表移除,**不再注入**,且**保留在磁盘**(模型仍可从 uploads 目录重新读取) | | 点击卡片 🗑 删除 | 插件调用 Host 的 `delete` RPC —— 文件**从 DSH Host 永久删除**,并同步清理去重索引 | | Ctrl+V 粘贴 | 粘贴的图片走产品原生草稿管道 —— 不产生卡片,插件完全不干预 | `×` 与 🗑 的区分是有意为之:`×` 只是停止注入(你可能还希望模型稍后能找到该文件),而 🗑 表示你**永远不再需要这个文件**。删除最后一个引用时文件会从磁盘移除;若另一个活跃会话仍引用同一份去重副本,则保留文件、仅撤销当前会话的引用。 这是一个**有意的设计决策**:卡片在发送后**不会自动消失**(与粘贴预览发送即清空不同),由你决定文件"附着"在对话中的时长。每次注入为一次性(每条消息一份),注入块精确列出当前所有**未关闭**卡片的文件。 注入按模型能力区分: | 模型类型 | 图片文件(png/jpeg/webp/gif) | 其他文件 | |---|---|---| | **多模态**(`inputModalities` 报告含 `image`)| 通过 attachments 服务注入原生 `ImageBlock`,图片成为请求的一部分,等同正常附件,**不加任何文字提示** | 路径文本块 | | **纯文本**(如 DeepSeek V4 系列)| 路径文本块——模型可调用读取/识图工具自行查看 | 路径文本块 | 能力检测使用 `llm.resolveModelInfo().inputModalities`(缓存 10 分钟,失败安全回退文本模式)。 注入的提示词使用可读的、**仅供模型阅读的英文格式**: ``` ----- [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. ``` ### 与识图工具等插件的兼容性 - **零耦合**:插件只把绝对路径注入提示词,从不调用、包装或假设任何辅助工具——vision-tools 或其他读取工具拿到路径后独立工作即可。 - **原生多模态路径**:对支持图片的模型,图片以原生 `ImageBlock` 注入,模型使用自身的多模态能力,不会被提示词诱导去调用外部工具。 - **非侵入**:注入只针对真实用户消息(`source.kind === 'user'`);steering/系统消息绝不会被注入;已含 `[Attached files]` 标记的消息不会重复注入(并发/残留实例防线)。Ctrl+V 粘贴图片完全由产品原生管道处理。 ## 防重传机制 重复上传不会刷爆存储: 1. 上传时插件计算解码字节的 **SHA-256**(动态模式经 shell 管道 `base64 -d | sha256sum`;静态 bundle 用 `node:crypto`) 2. 持久化索引 `uploads/.dfu-index.json`:`hash → { path, at }`(`at` 为上传时间戳,用于清理) 3. 再次上传**相同内容**(同名或不同名)→ **复用已有存储副本**,不写新文件、不产生 `-1` 副本,响应带 `dedup: true` 4. 上传经 Promise 队列串行化,并发相同上传不会竞态;索引条目失效(文件被删)时自动回退重新存储 | 场景 | 结果 | |---|---| | 同一文件上传 N 次 | 磁盘只存 1 份,所有上传解析到同一路径 | | 同内容、不同文件名 | 复用首个存储副本 | | 同名字、不同内容 | 正常的 `-1` 冲突处理(正确) | | 进程重启 | 索引文件持久化,防重传继续生效 | 索引条目为 `hash → { path, at }`(`at` = 上传时间戳,毫秒,供下面的 TTL 清理使用)。旧版 `hash → path` 条目会被透明读取,并在下次写入时升级为对象格式;不带时间戳的旧条目**永不被 TTL 回收**(仅当文件已不存在时被清理),保护迁移前的历史文件不被误删。 ## 清理与保留(TTL 自动回收 + 手动删除) 上传文件通过两种互补方式回收: ### 自动 TTL 回收 每次上传成功、每次永久删除、以及插件启动时,都会触发一次**懒回收**。超过 TTL 且**不在任何活跃会话 pending 引用**中的文件会被从磁盘删除,并同步清理其索引条目。回收与上传走同一条串行队列,不会与在途写入竞争;无需后台任务。 - **默认 TTL**:`7d`(7 天)。 - **保留期单位**:`1s`(秒)、`1m`(分钟)、`1h`(小时)、`1d`(天);不带单位的纯数字按**天**(兼容旧行为)。`0`(或空/非法值)**禁用自动 GC** —— 仅在点击 🗑 时显式删除。 - **配置**:在 **设置 → 文件上传**(DSH 原生设置面板;该选项卡带专属回形针图标,不会和其他插件的齿轮图标混淆)中,保留期输入框与「替代官方附件按钮」开关共用同一个保存按钮。静态 bundle 将两项写入 `~/.dsh/dsh-web-file-uploader.json`(`{ ttl, replaceOfficial }`),且按字段合并更新——保存一项不会清掉另一项。环境变量 `DSH_UPLOAD_TTL`(如 `DSH_UPLOAD_TTL=30m`)作为兜底在默认值之前生效。动态(会话)模式下该设置存于内存,并优先读取 `dsh-web-file-uploader` 设置命名空间。 ### 附件按钮的位置与「替代官方按钮」 输入框工具行的顺序是「+ 菜单 → 官方附件 → 权限设置」。本插件按钮**始终显示**,并被放在**官方附件按钮右侧、权限设置之前**。整个过程不搬动任何 React 拥有的节点:shell 的插槽容器是 `display: contents`,因此用 flex `order` 重排行即可——插件按钮为 1,shell 在官方按钮之后绘制的内容为 2。 | 「替代官方附件按钮」 | 效果 | |---|---| | 关闭(默认) | 两个按钮并排:官方直立回形针在前,本插件斜向回形针紧随其后 | | 开启 | 官方按钮被隐藏,本插件按钮占据它的位置——工具行只留一个上传入口 | - 保存后立即生效,无需刷新;此后新挂载的输入框同样生效。 - 关闭开关并保存、停用或卸载插件,官方按钮都会恢复成 shell 原本的样子:隐藏只写一条内联样式,不摘除节点,React 树保持完整。 - 官方按钮被隐藏后,它自带的「待发送附件栏」也无法再触发;上传、进度与自动清理全部由本插件负责。 - 本插件按钮不会因运行中而禁用——此时选择的文件会在下一步注入。 - 若未来的 shell 结构不再匹配,工具行会保持原样:官方按钮继续显示、插件按钮留在原槽位,并在控制台输出一条警告说明原因。 ### 两种卡片操作(回顾) | 操作 | Host 调用 | 磁盘上的文件 | |---|---|---| | `×` 关闭 | `remove` | **保留** —— 模型可重新读取 | | 🗑 删除 | `delete` | **永久移除** | 关闭一张**仍在上传中**的卡片会先弹确认:字节还在路上,两个答案都是真实的选择——「确认取消上传」立刻中断传输并丢弃已暂存的字节(不会落盘),「返回继续上传」则关闭询问、保留卡片(以及它传完后的引用)。两种选择都不会留下孤儿文件;确认只在上传进行中出现,已完成的卡片仍然一键关闭(= 取消引用,不删文件)。 `delete` Host 调用先撤销本会话引用,检查是否还有其它活跃会话引用同一份去重副本:仅当无其它引用时才删除文件并清理索引;若仍被引用则保留文件(`removed: false`),但当前会话的卡片仍会被清除。 ## 附件大小限制 - **没有单文件上限**:静态 bundle 把浏览器发来的请求体**边收边写**、同一遍算 SHA-256;动态插件由客户端把 File 切片,每片一次 RPC 追加进临时文件。两条路径都不会把整个文件读进内存(实测 120 MiB 上传期间进程内存增长 < 1 MiB),最终只受磁盘空间限制。 - 上传期间卡片显示百分比:静态模式来自 XHR 的 `upload.onprogress`,动态模式按已发送分片累加。 - **回退路径仍有上限**:调用方若不传 `File` 而直接给 base64(旧调用形式),仍受单次 64 MiB base64 / 约 48 MiB 载荷限制——该路径只为兼容保留。 - 多模态原生注入图片时,额外遵循部署的 `attachments` 限制(单消息字节/像素上限)。 ## 安装方式 ### A. 动态插件(当前会话,免安装) ```text cordis_define + cordis_run # host = src/host.js,client = src/client.js ``` 批准 Run 卡片后回形针按钮立即出现。插件为进程内临时扩展,重启后需重新定义并运行。 ### B. 静态 bundle(持久化,`dsh plugin` 安装) 包声明了 `dsh.bundle.patch`(见 `cordis.patch.yml`),因此 `dsh plugin --profile web add` 会将其识别为 profile 层。pnpm 支持的任意来源均可: ```bash # Git 仓库(推荐分发渠道) dsh plugin --profile web add github:Mooling0602/dsh-web-file-uploader # 本地目录(开发用) 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(发布后) dsh plugin --profile web add dsh-web-file-uploader ``` > **Git 规格说明**:pnpm 的 git 简写是 `github:/`(如 > `github:Mooling0602/dsh-web-file-uploader`)。裸写 `github.com//` > 会被 pnpm 当作*本地目录*,报 "non-existent directory" 警告。其他合法形式: > `git+https://github.com/Mooling0602/dsh-web-file-uploader.git` 或 > `https://github.com/Mooling0602/dsh-web-file-uploader.git`。 重启 dsh web 进程并刷新页面。分发细节与可选的 npm 发布流程(需要你的 npm 凭据)见 [PUBLISHING.md](PUBLISHING.md)。 ### 更新 `dsh plugin` 只是把参数转发给 profile 目录下的 pnpm,因此更新也走它(不要手改 `~/.dsh/profiles/web/node_modules`——下次 pnpm 操作会直接覆盖): ```bash dsh plugin --profile web update dsh-web-file-uploader # 若 lockfile 钉住的解析不肯移动,可以退回重装: dsh plugin --profile web remove dsh-web-file-uploader dsh plugin --profile web add github:Mooling0602/dsh-web-file-uploader ``` 更新后重启 dsh web 进程;浏览器端 bundle URL 自带内容哈希版本号(`?rev=…`),刷新页面即可拿到新构建,无需手动清缓存。本地目录安装则在重新 add 前先在源码目录跑一次 `pnpm build`。详见 [PUBLISHING.md](PUBLISHING.md#update)。 ## 架构 ``` 浏览器 (Client) DSH 宿主机 (Host) ───────────── ───────────────── conversation.input.left harness.handle('upload-begin'/'upload-chunk'/ └ 回形针 ── File(切片)─┐ 'upload-finish'/'upload-abort') [动态] │ webServer 路由 POST /upload [静态] ▼ ┌ sandboxPolicy.resolve() → 工作区根 File 切片 / 请求体 ├ 会话 cwd / DSH_HOME + /uploads/ │ ├ 先写 .dfu-tmp-*,同一遍增量 SHA-256 ▼ ├ 提交:去重 → 定名 → 移入 → .dfu-index.json └ base64 -d >> tmp(动态)/ node:fs 流式写入(静态) conversation.input.dock └ 附件卡片(持久) harness.handle('remove', …) / remove 路由 └ × 关闭卡片 → 停止注入 └ pending 条目删除(文件保留) └ 🗑 删除卡片 harness.handle('delete', …) / delete 路由 └ 撤销 pending → 删除文件 + 清理索引 懒 TTL 回收(上传 / 删除 / 启动) └ 删除超 TTL 且不在 pending 中的文件 agent/pre-step 瀑布 ├ resolveModelInfo → 是否多模态? ├ attachments.saveImage → ImageBlock └ 路径文本块(仅用户消息) ``` **为什么动态模式要走 shell 的 `base64 -d`?** 动态插件所在的沙箱禁用了 `require`,`fs` 服务只支持 UTF-8 文本的整文件写入——二进制只能经 shell 服务的 `stdin` 管道给 `base64 -d`。分片追加(`>>`)让每条命令只承载一个分片,内存占用与文件大小无关。静态 bundle 没有这个限制,直接 `node:fs` 流式写入。 ## 保存位置 | 运行模式 | 目的地 | |---|---| | 动态插件 | `<会话工作区>/uploads/`(沙箱化 shell/fs 无法写出工作区)| | 静态 bundle | `$DSH_HOME/uploads`(默认 `~/.dsh/uploads`),经 `node:fs` 直写——即 dsh 数据目录 | 防重传索引(`uploads/.dfu-index.json`)与存储文件同目录。 ## 仓库结构 项目采用**核心单一来源 + 薄缝隙层**架构:所有业务逻辑都在 `src/core/*` 中;动态插件与静态 bundle 只是其上的薄适配层,改一处即可两边生效。 ``` dsh-web-file-uploader/ ├── src/core/ │ ├── host-core.js # 规范 Host 逻辑(传输无关,依赖注入) │ └── client-core.js # 规范 Client 逻辑(传输无关,依赖注入) ├── src/seams/ │ ├── host-dynamic.template.js # 动态 Host 缝隙(harness + shell/fs) │ ├── client-dynamic.template.js # 动态 Client 缝隙(host.call + React) │ └── client-static.template.js # 静态 Client 缝隙(fetch + 模块 react) ├── src/host.js # 生成产物:动态 Host(核心已内联)—— 勿手改 ├── src/client.js # 生成产物:动态 Client(核心已内联)—— 勿手改 ├── lib/index.js # 静态 Host 缝隙(import 核心;node:fs/crypto/webServer) ├── client/src/client.js # 生成产物:静态 Client 源码 —— 勿手改 ├── scripts/ │ ├── build-dynamic.mjs # 核心内联进缝隙 → src/*.js + client/src/client.js │ └── build-client.mjs # 包装 client/src/client.js → lib/client.js ├── cordis.patch.yml # dsh.bundle 补丁(profile 层行) ├── package.json # 可发布清单(dsh.bundle + dsh.client) ├── PUBLISHING.md # 安装与 npm 发布指南 ├── README.md / README_zh_CN.md └── LICENSE # MIT ``` **如何修改代码**:编辑 `src/core/*`(或缝隙模板),然后运行 `pnpm build`——它会重新生成动态源码(`src/host.js`、`src/client.js`)与静态客户端包(`lib/client.js`)。运行中的动态插件需用重新生成的 `src/host.js` / `src/client.js` 经 `cordis_define` + `cordis_run` 重新部署。 ## 开发状态 - ✅ 动态插件:已在真实会话中实现并验证 - ✅ 卡片驱动注入、模型感知适配、防重传、i18n UI - ⚠️ 静态 Client 模块:可由 `scripts/build-client.mjs` 构建,但 `__ModuleLoader__` 包装器需在正式分发前对照真实 web 工具链验证 ## 许可证 MIT