dsh-web-file-uploader
一个面向 DeepSeek Harness 网页端的文件上传插件。在输入框工具行加入一个 DeepSeek 网页版风格的回形针附件按钮,将所选文件上传到 DSH 宿主机——内置模型感知适配让文件真正能被当前模型使用,并带内容寻址防重传避免重复上传刷爆存储。
- 仓库:https://github.com/Mooling0602/dsh-web-file-uploader
功能特性
- 📎 输入框工具行左侧的回形针按钮(细线条、悬停圆形背景跟随主题:浅色 ≈
#F2F3F4,深色 ≈ 白 8%,通过--dsw-alias-interactive-bg-hover自动适配) - 支持多选;每个文件以预览式附件卡片显示在输入框上方(图片显示缩略图):读取中 → 上传中 → 已保存,带"复制路径"与移除按钮
- 文件保存在 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 原生设置面板)中,单个输入框即可设置,经
settings服务命名空间dsh-web-file-uploader({ ttl })持久化;环境变量DSH_UPLOAD_TTL(如DSH_UPLOAD_TTL=30m)作为兜底在默认值之前生效。动态(会话)模式下该设置存于内存;静态 bundle 中通过设置文档持久化。
两种卡片操作(回顾)
| 操作 | Host 调用 | 磁盘上的文件 |
|---|---|---|
× 关闭 | remove | 保留 —— 模型可重新读取 |
| 🗑 删除 | delete | 永久移除 |
delete Host 调用先撤销本会话引用,检查是否还有其它活跃会话引用同一份去重副本:仅当无其它引用时才删除文件并清理索引;若仍被引用则保留文件(removed: false),但当前会话的卡片仍会被清除。
附件大小限制
- 单文件约 48 MiB(动态模式:RPC 载荷上限 64 MiB base64,约 48 MiB 解码;静态模式:流式接收 HTTP body 时同此上限)。超限文件会在卡片上显示明确错误。
- 多模态原生注入图片时,额外遵循部署的
attachments限制(单消息字节/像素上限)。
安装方式
A. 动态插件(当前会话,免安装)
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 支持的任意来源均可:
# 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:<owner>/<repo>(如 > github:Mooling0602/dsh-web-file-uploader)。裸写 github.com/<owner>/<repo> > 会被 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 操作会直接覆盖):
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', …) [动态]
└ 回形针 ── FileReader ──┐ webServer 路由 POST /upload [静态]
▼ ┌ sandboxPolicy.resolve() → 工作区根
上传载荷 ├ 会话 cwd / DSH_HOME + /uploads/
│ ├ SHA-256 → .dfu-index.json 防重传
▼ └ base64 -d(stdin)/ 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
└ 路径文本块(仅用户消息)为什么用 base64 -d 而不是 Host 的 atob? Host 的 atob 是文本语义(Buffer.from(s, "base64").toString("utf-8")),fs 服务也只支持 UTF-8 文本写入。把 base64 经 shell 服务的 stdin 管道给 base64 -d,可无损写入二进制且无需临时文件。
保存位置
| 运行模式 | 目的地 |
|---|---|
| 动态插件 | <会话工作区>/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