Bundle
dsh-file-upload
Codex-style drag-drop for DSH web GUI: drag any file, it lands in ~/.dsh-dropbox, and the path lands in the composer as a whole blue chip (the mandatory .b64 suffix is display-hidden; the real path serializes on submit). Hot-pluggable: install with `dsh plugin --profile web add ./plugins/file-upload`, no dsh source changes.
- Source
- GLFzr
- stars
- 10 stars
- License
- MIT
- Updated
- Updated 5 days ago
Readme
# DSH 文件上传插件(File Upload)v2.1.0
把 DSH Web GUI 原版**只支持图片**的附件通道,扩展为**任意文件上传**:拖入任意文件(PDF、3MF、ZIP、Excel、代码、图片……)→ 文件上传/引用到本机中转目录 → 输入框出现一个**蓝色文件 chip** → agent 可以直接读取该文件。
> **定位说明(v2.0.0 起)**:本插件的本质是**文件上传功能**,不是"路径展示"。路径只是内部机制——agent 读取文件必须依赖磁盘路径,浏览器又拿不到(见下文"为什么必须上传"),所以插件负责把文件物化到本机、并把路径交给 agent。用户界面不再出现任何路径概念:chip 只显示文件名,用户只需知道"文件上传好了,agent 能读了"。
本仓库是**持久化 profile 插件**(`dsh.bundle` + `dsh.client` 双声明):按官方方式安装一次,**DSH 重启后自动生效**。
## 功能特性
- 🖱️ **页面任意位置拖入任意文件**(多文件、图片均可);拖拽时全屏提示"松开以接收文件"
- 📦 **任意文件类型**:原版"仅图片"附件通道被完全静默接管,任何文件都可拖入
- 📍 **本机文件零上传直引**:Chromium(Edge/Chrome)拖拽会经 entry API 暴露源文件路径——直接引用,**零上传、零复制**(你电脑里那份文件就是唯一的一份);仅当浏览器拿不到路径(如 Firefox)时才走上传中转
- 🔵 文件以**一个整体 chip** 插入输入框:蓝色文字(**只显示文件名+格式**)、不可被局部删改(Backspace/Delete 一次删除整个引用)
- 📐 **篮筐宽度随文件名自适应**:插入时用 composer 真实字体实测文件名宽度,pill 精确贴合(误差 <1 个字符)
- 🙈 **内部细节全部隐藏**:`.b64` 后缀、`_1/_2` 重名序号、中转目录路径——用户界面永远看不到;提交给 agent 的才是完整真实路径
- ♻️ **两级去重(先查后传,防误复用)**:`begin` 快路径命中条件 = 同名 + 同大小 + **manifest 登记的源文件修改时间与本次拖入一致**——重复拖同一文件仍**秒回**(零上传);但**编辑过且字节数没变的文件绝不误复用**(v2.1.0 起,新/旧文件首次拖入都先完整上传,由 `end` 的 sha256 逐字节比对裁决:内容相同复用旧路径,内容不同落 `_1` 副本)
- 📇 **中转目录清单(manifest)**:插件把每个自己写入的文件登记到 `~/.dsh-dropbox/.dsh-manifest.json`(原名、源修改时间、sha256、是否自动编号副本)——清理面板的"冗余副本"标记只认清单,**不会把 `notes_2024.txt` 这类自然命名误判为副本**(v2.1.0 起)
- 🧹 **失败即清理**:上传任一步失败,客户端立即调用 abort 释放宿主会话,不再滞留内存到超时(v2.1.0 起)
- 🛡️ **完整性校验**:每块精确长度校验、分块必须齐全、解码后字节数必须与声明一致——截断/缺块/伪造数据一律拒绝,绝不落盘损坏文件
- ⏳ **插入点待机圆环**:上传真正需要等待时,在文件将要出现的位置(光标右侧一个字符处)显示 DeepSeek 蓝色转圈环(渐隐拖尾、持续转动);**瞬间完成的路径(本机直引、秒回去重、极小文件)不出圈**;chip 插入的同一瞬间圆环立即消失
- 🚀 ~4MB 分块上传,上限 512MB;请求体上限 16MB/次;失败右下角红色提示 4 秒
- 🗑️ **侧边栏垃圾桶图标(设置图标上方)**:打开上传目录清理浮层——文件清单、一键清理冗余 `_N` 副本、按大小清理、清空全部,操作前确认,避免中转目录无限膨胀
## 为什么必须上传/中转?(浏览器限制)
这是 **WebUI 与 Hermes/Codex 之类本地 CLI 的本质区别**,不是本插件的缺陷:
- **浏览器是沙盒**:网页里的 JavaScript **拿不到本地文件的磁盘路径**(安全模型,防止网页偷读你的磁盘)。Hermes/Codex 直接跑在你的电脑上,可以随便读 `C:\...\xxx.pdf`;DSH 的 Web GUI 是一个浏览器网页,**它手里的文件只是一个内存 `File` 对象**。
- **agent 只能读磁盘路径**:agent 读取文件靠的是磁盘路径。浏览器无法把一个内存文件"变成"磁盘路径交给 agent,所以插件必须先把文件**物化到本机中转目录**(默认 `~/.dsh-dropbox`),agent 才能访问。
- **唯一的例外(零上传)**:Chromium 系浏览器(Edge/Chrome)拖拽**本机文件**时,`DataTransferItem.webkitGetAsEntry()` 能探测到源文件路径——此时插件**直接引用原路径,零上传、零复制**。Firefox、或从非本机来源(网页、压缩包内)拖拽时拿不到路径,才必须走上传兜底。
- **大文件的代价**:新文件第一次拖入必须完整上传(几百 MB 也要传),这是浏览器限制决定的。为了把重复成本降到零,插件做了两级去重(见工作原理)。
## 上传目录清理
中转目录会随着使用积累文件(尤其大文件)。**侧边栏底部、设置图标上方的垃圾桶图标**(悬停显示"清理上传目录")打开清理浮层:
- **文件清单**:名称、大小、修改时间;**冗余副本**自动标注——v2.1.0 起只认插件清单(`.dsh-manifest.json`)里登记的自动编号副本,**自然命名文件(如 `notes_2024.txt`)绝不会被误标**
- **清理冗余副本**:一键删除所有清单登记的 `_N` 副本(推荐——你的电脑里已有原文件,这些副本是纯浪费)
- **按大小清理**:输入阈值(MB),删除大于该值的文件
- **清空全部**:删除中转目录里所有文件(高危,二次确认)
- 任何操作前都会显示"将删除 N 个文件、释放 X",并提示**历史会话引用的文件会失效**——清理前请确认没有正在使用的会话
## `.b64` 是什么?(内部细节,用户无需关心)
浏览器无法保证任意二进制文件的原始字节能无损地穿越 JSON/文本通道,所以**回退上传**的二进制文件(PDF、3MF、ZIP、图片等)落盘时以 **base64 文本**保存,文件名追加 `.b64` 作为标记——agent 看到 `.b64` 就知道要先解码再使用:
```python
import base64
with open('xxx.pdf.b64') as f: raw = base64.b64decode(f.read())
with open('xxx.pdf', 'wb') as f: f.write(raw)
```
文本类文件(.md/.txt/.json 等)直接写原文,**不会**带 `.b64`。**本机文件走原路径引用时不经过中转目录,天然没有 `.b64`**。所有这些都是内部机制——输入框里的 chip 只显示文件名,提交给 agent 的才是完整真实路径。
## 安装(官方方式,一次永久)
```sh
# 在 deepseek-harness 仓库根目录(源码版):
pnpm dsh plugin --profile web add ./plugins/file-upload
# 或任意 dsh 安装(npx 版亦可):
dsh plugin --profile web add <本仓库路径>
```
`dsh plugin` 会 pnpm link 本包到 `$DSH_HOME/profiles/web` 并追加 `dsh.profile.bundles`。之后**无论用 npx 版还是源码版启动 DSH,插件都自动加载**。
验证:`dsh web --dump-config` 应出现 `# == dsh-file-upload` 层。
> 从旧名 `dsh-drop-file-to-path` 升级:插件 id 与路由已更名为 `file-upload`,重装后旧版本无需卸载(同一 bundle 行被覆盖)。
## 工作原理
| 部件 | 位置 | 职责 |
|---|---|---|
| Host 半(`lib/index.js`) | 宿主进程,`inject: ['webServer']` | HTTP 路由 `/api/file-upload/begin\|chunk\|end\|abort\|list\|clean`:分块校验、两级去重(lastModified+sha256)、manifest 清单、Origin 校验、node:fs 落盘、清理接口 |
| Client 半(`lib/client.js`) | 浏览器(`__ModuleLoader__` bundle,`dsh.client` 声明) | 全局拖拽监听(capture 接管)、fetch 分块上传、待机圆环、注册 trigger source、经 `conversation.input.insertReference` 插入文件 chip、侧边栏清理入口 |
### 文件 chip
文件引用走 composer 原生的参考引用机制(U+FFFC 占位符 + occurrence 表 + 提交时 codec 序列化):占位符在草稿里只占一个字符,因此整个引用是不可分割的整体;提交时经插件注册的 `file-upload` source 的 codec 把占位符展开为真实路径(含 `.b64`)。
**篮筐宽度自适应**:插件在插入前用 composer 的真实字体实测文件名宽度,算出 NBSP 空格数 `pad`,随 chip 一起插入(占位符 + pad 个 NBSP)。chip 单元格宽度 = 基础 1em + pad×空格宽 ≈ 文件名宽——**先有文件名,后有篮筐**。为此需要两处配套(都在本 DSH 发行版内):
- **加宽字体**:用 `tools/patch-chip-font.mjs` 把 `DshChipCell` 的 U+FFFC advance 从 composer 默认 4em 改为 **1em**(同名字体族、后声明者生效,textarea/镜像/背板三层共享同一 advance,对齐关系不变);
- **核心 composer 扩展**(`dsh-client-ui-conversation`):occurrence 支持 `pad` 扩展占位区间——插入、序列化、复制/剪切投影、Backspace/Delete 整删、backdrop 渲染全部按 `[offset, offset+1+pad)` 整段处理。
**无该 composer 扩展的公开版 DSH(降级模式)**:`pad` 缺失时插件自动退回 `[offset, offset+1)`——chip 为固定 1em 宽(文件名按 composer 默认样式显示),提交/复制/刷新草稿全部正确,**不会损坏草稿**。
### 上传、校验与去重
- **分块**:4194303 字节(4MiB−1,**3 的倍数**)——每满块的 base64 无中间填充符,拼接流可无损解码(4MiB 整块会在流中间嵌入 `==`,Node/Python 解码器都会在首个 `==` 处截断,>4MB 文件会损坏;v1.2.1 起修复)。
- **完整性**:`chunk` 阶段校验每块精确长度与 base64 合法性;`end` 阶段校验分块齐全、总量一致、解码后字节数 === 声明大小——截断/缺块/伪造数据一律拒绝,绝不落盘损坏文件。
- **两级去重(防误复用)**:`begin` 快路径命中需要「同名 + 同大小 + manifest 登记的源修改时间一致」——重复拖同一文件秒回、零上传;**编辑过但字节数未变的文件不会走快路径**(v2.1.0 修复的误复用问题),而是完整上传后由 `end` 的 sha256 逐字节比对裁决:内容相同复用旧路径、不产生新副本;内容不同落 `_1` 副本,绝不复用错误内容。v2.1.0 之前上传的旧文件首次重拖也会走完整上传(缺 manifest 记录),由 end 哈希兜底并补登记。
- **清单登记**:每次落盘(含去重复用命中)都把文件登记进 `.dsh-manifest.json`(原名、源修改时间、sha256、是否自动编号副本);清理时同步注销。清单缺失或损坏时所有行为保守化(不做快路径复用、不标冗余副本),不影响上传本身。
### 待机圆环(插入点加载动画)
- **触发**:只有真正需要等待的上传才显示——上传开始后延迟 150ms,若期间完成(极小文件、秒回去重)则**从不出现**;超过 150ms 才在插入点显示。
- **位置**:直接测量 composer 自己的 mirror 镜像层(与输入框同字体、同宽度、同滚动,天然对齐),定位到**光标/草稿末尾**,圆心再右移一个字符宽("第二个字"位置)——不遮住已输入的文字。
- **外观**:16px DeepSeek 蓝(`#4d6bfe`)弧线圆环,渐隐拖尾 + 光晕,0.85s/圈持续旋转。
- **消失**:chip 插入的**同一瞬间**圆环立即移除(命令式 DOM 直接挂在 `document.body`,不经过 React/slot,无中间环节可吞掉它)。
### 清理入口
侧边栏脚部的**垃圾桶图标**(`sidebar.footer.action` 槽位,渲染在设置图标上方;悬停显示"清理上传目录")点击后弹出右下角清理浮层,复用同一套清理逻辑(`/api/file-upload/list` + `/clean`)。
依赖官方服务:`webServer`(HTTP 路由)、`slots`(shell.overlay + conversation.composer.dock + sidebar.footer.action)、`inputTriggers`(source 注册与序列化)、`conversation`/`sessions`(按 session 解析输入门面)。除上述核心 occurrence 扩展外不修改 dsh 源码。
## 版本历史
- **v2.1.0**(当前):修复一批实测发现的问题——
- **P1 快路径误复用**:begin 快路径从"同名同大小即复用"改为"同名同大小 + 源修改时间一致才复用",编辑过但字节数没变的文件不再被静默引用旧内容(`File.lastModified` ↔ manifest 登记时间,1s 容差)
- **P2 清理误标**:新增 `.dsh-manifest.json` 清单,冗余副本标记只认清单登记的自动编号副本,`notes_2024.txt` 这类自然命名不再被误标、不会被误删
- **P3 失败泄漏**:客户端上传任一步失败立即 abort 宿主会话,不再滞留内存到 10 分钟超时
- **P4 静默丢弃**:移除 16 个文件上限截断,一次拖更多文件也不会丢引用
- **P5 插入乱序**:多文件 chip 严格按拖拽顺序插入(此前按完成顺序)
- **P6 busy 卡死**:composer 忙时插入失败改为定时重试,不再等可能不来的事件
- **P7 sanitize 兜底**:纯非法字符文件名(如 `::::`)落盘为 `file` 而非 `____`
- **P8 跨源防护**:HTTP 路由拒绝跨源请求(Origin ≠ Host 时 403);无 Origin 的本地调用不受影响
- **v2.0.0**:正式更名 **`dsh-file-upload`**——定位从"拖拽文件转路径"改为"**任意文件上传**"(把原版仅图片的附件通道扩展为任意文件);用户界面移除全部"路径"概念(ready 卡片不再显示/复制路径,提示文案统一为"接收文件");内部机制(上传、两级去重、完整性校验、待机圆环、清理入口)与 v1.4.8 完全一致
- **v1.4.8**:待机圆环最终形态——延迟 150ms 显示(瞬间完成不出圈)、镜像层测量定位(光标右侧一个字符)、chip 插入瞬间同步消失
- **v1.4.0**:清理入口改为**侧边栏垃圾桶图标**(设置图标上方,悬停提示);移除设置内导航页
- **v1.3.0**:新增**上传目录清理**功能(文件清单、冗余副本/按大小/清空三种清理模式、删除前确认)
- **v1.2.3**:恢复 **begin 先查后传**快速去重(同名同大小秒回,零上传);end 内容哈希兜底保留
- **v1.2.2**:chip 样式机制回退为全局规则(v1.2.0/1.2.1 的动态规则存在时序 bug)
- **v1.2.1**:修复 >4MB 文件解码截断(分块改为 3 的倍数)、end 完整性校验、草稿投影 pad 回退、Windows 保留名 sanitize
- **v1.1.0**:路径以蓝色 chip 整体插入、篮筐宽度自适应、begin 快速去重
## 开发
```sh
node --check lib/index.js lib/client.js # 语法检查
node --test tests/host.test.mjs # 宿主行为测试(27 例:上传/完整性/两级去重/防误复用/清单/清理/跨源/安全)
```
CI(`.github/workflows/ci.yml`)在 push/PR 时自动执行以上两步。
## 文件说明
```
├── lib/index.js # Host 半:HTTP 上传路由 + 校验/去重/清理 + node:fs 落盘
├── lib/client.js # Client 半:全局拖拽 + 待机圆环 + 文件 chip + 侧边栏清理入口
├── tools/patch-chip-font.mjs # 生成加宽 DshChipCell 字体的工具(改宽度后重跑)
├── tests/host.test.mjs # 宿主行为测试(node --test,免 DSH 服务器)
├── chip-cell-font.b64 # 加宽字体产物(client.js 内嵌同一份)
├── cordis.patch.yml # bundle 补丁层(insert 插件行)
├── .github/workflows/ci.yml # CI:语法检查 + 宿主测试
├── .gitignore
└── package.json # dsh.bundle.patch + dsh.client 声明
```
## 落盘与读取约定
- 中转目录:`C:\Users\<用户>\.dsh-dropbox\`(`os.homedir()` 解析;测试可用 `DSH_DROPBOX_DIR` 覆盖)
- 清单文件:`~/.dsh-dropbox/.dsh-manifest.json`(插件内部登记,勿手动编辑;删除中转文件时建议用清理面板,保证清单同步)
- `.b64` 解码(agent 侧):见上文 `.b64` 说明
## 已知限制(v2.1.0)
- **上传内存**:单文件上传期间,宿主进程在内存中持有该文件全部 base64 分块(512MB 文件 ≈ 683MB 字符串),随后在 `end` 落盘。单机单用户够用;并发多个大文件会明显吃内存(架构级优化待后续版本)。
- **本机直引路径**(零上传):依赖 Chromium `webkitGetAsEntry()` 返回的真实磁盘路径,无存在性/大小校验;OneDrive 占位文件、虚拟文件系统拖入时可能得到不可读路径(此时会看到 agent 报文件不存在,建议改用"复制文件再拖")。Firefox 等拿不到路径的浏览器自动走上传兜底,无此问题。
- **清理不可逆**:清理浮层删除的是中转目录副本,历史会话引用的路径会失效——操作前弹窗会明确提示。
## License
MIT
Install
dsh plugin --profile web add github:GLFzr/dsh-file-upload#dc23ac42271fd93814924cd5822992501f7fb654
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-file-upload from the hub