@wszhoho/dsh-file-attachment 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,20 +1,27 @@
1
1
  # dsh-file-attachment
2
2
 
3
- DeepSeek Harness (dsh) web GUI 插件:在会话输入框中**拖入或 Ctrl+V 粘贴文档(支持多文件,一次可拖入/粘贴多个文件)**时,浏览器端批量读取全文,保存到**当前会话工作区根目录下的 `.dsh-file-attachment/`**,并在输入框光标处插入 `@<保存副本绝对路径>` 引用。agent 读取 `@路径` 时直接读到落盘副本,不再依赖原始文件位置。
3
+ DeepSeek Harness (dsh) web GUI 插件:在会话输入框中**拖入或 Ctrl+V 粘贴文档/图片(支持多文件,一次可拖入/粘贴多个文件)**、或通过 **📎 附件按钮上传**时,浏览器端批量读取全文,保存到**当前会话工作区根目录下的 `.dsh-file-attachment/`**,并在输入框光标处插入 `@<保存副本绝对路径>` 引用。agent 读取 `@路径` 时直接读到落盘副本,不再依赖原始文件位置。
4
4
 
5
- 图片文件由插件接管后走 dsh web 既有草稿图片流程(`addImages`):不落盘、不进文件条、不缩放;仅当输入框未就绪且为纯图片时,才原样交回原生。
5
+ 图片与文档走**同一条落盘管线**:图片在输入框**内联附件条**中以缩略图预览(点击放大),在聊天区渲染为**可点击放大的缩略图**;文档在附件条中以类型图标 + 文件名条目显示,在聊天区保持芯片样式。当前会话模型不支持多模态时,图片自动调用**可配置 VLM** 识别生成中文描述回填草稿,文本模型也能「看懂」图片。
6
6
 
7
- 另外,输入框工具栏(加号之后)提供了一个**上传按钮(📎 回形针图标)**,点击后弹出系统文件选择框,可多选文档/图片,复用与拖入/粘贴完全一致的落盘 + `@路径` 插入管线。PC 与移动端浏览器均适用(移动端走原生文件选择器)。
7
+ 插件接管 dsh 原生 **📎 附件上传按钮**(hero / composer 两种输入模式共用同一位置:加号右侧、modes 左侧),点击后弹出系统文件选择框,可多选文档/图片,复用与拖入/粘贴完全一致的落盘 + `@路径` 插入管线。PC 与移动端浏览器均适用(移动端走原生文件选择器)。
8
8
 
9
9
  ## 界面
10
10
 
11
- **演示**:拖入/粘贴文档 → 落盘并在输入框插入 `@` 引用 → 输入框上方 dock 文件条待发送状态(完整流程见 gif)。
11
+ **演示**:拖入/粘贴文档或图片 → 落盘并在输入框插入 `@` 引用 → 输入框内联附件条待发送状态(完整流程见 gif)。
12
12
 
13
13
  ![插件演示](docs/demo.gif)
14
14
 
15
- **上传按钮**:输入框工具栏 `+` 之后的 📎 回形针图标,点击弹出系统文件选择框(可多选文档/图片)。
15
+ **内联附件条**:上传后在 composer 卡片内部、编辑区上方显示——图片为缩略图(点击放大),文档为类型图标 + 文件名条目;条目可移除(同步清除草稿中对应 `@引用`),发送后自动清空。
16
16
 
17
- **可上传类型设置**:「设置 文件附件」页可按 文档 / 代码 / 配置文件 三类增删扩展名(小写、不带点),图片恒可发送。
17
+ **聊天区图片缩略图**:历史对话中用户消息里的 `@图片路径` 渲染为缩略图(点击打开 Lightbox 放大层);`@文件路径` 保持芯片样式。
18
+
19
+ **上传按钮**:接管 dsh 原生 📎 附件按钮(加号右侧、modes 左侧),点击弹出系统文件选择框(可多选文档/图片)。
20
+
21
+ **设置页**:「设置 → 文件附件」页两部分:
22
+
23
+ - **可上传类型**:按 文档 / 代码 / 配置文件 三类增删扩展名(小写、不带点),图片恒可发送;
24
+ - **多模态识别参数(VLM)**:Base URL / API Key / 模型 / 思考模式开关(默认禁用),仅当前会话模型不支持多模态时调用,未填 API Key 时静默跳过。
18
25
 
19
26
  ![可上传类型设置](docs/settings-file-types.png)
20
27
 
@@ -22,14 +29,15 @@ DeepSeek Harness (dsh) web GUI 插件:在会话输入框中**拖入或 Ctrl+V
22
29
 
23
30
  | 操作 | 行为 |
24
31
  | --- | --- |
25
- | 点击**上传按钮(📎)**,在系统文件选择框中**多选**文档/图片 | 与拖入/粘贴同一管线:文档 落盘 + 光标插入全部 `@` 路径;图片 → 走既有草稿图片流程;`accept` 限定图片 + 常见文档扩展名,移动端自动弹出文件选择器 |
26
- | 拖入/粘贴 **文档/代码/配置文件**(非图片,默认 doc·code·config 三类扩展名,如 docx/xlsx/pdf/md/txt/js/json) | 浏览器读全文 → base64 → 宿主落盘 `<会话工作区>/.dsh-file-attachment/<日期>/<文件名>` → 光标插入 `@绝对路径`,成功不提示、失败简短 toast |
32
+ | 点击 **📎 附件按钮**(插件接管 dsh 原生按钮),在系统文件选择框中**多选**文档/图片 | 与拖入/粘贴同一管线:图片与文档统一落盘 + 光标插入全部 `@` 引用;移动端自动弹出文件选择器 |
33
+ | 拖入/粘贴 **文档/代码/配置文件**(默认 doc·code·config 三类扩展名,如 docx/xlsx/pdf/md/txt/js/json) | 浏览器读全文 → base64 → 宿主落盘 `<会话工作区>/.dsh-file-attachment/<日期>/<文件名>` → 光标插入 `@绝对路径` chip,成功不提示、失败简短 toast |
27
34
  | 一次拖入/粘贴 **多个文件**(支持多文件) | 批量处理:逐个读取全文 → base64 → 落盘 → 在光标处一次性插入全部 `@` 引用;全部成功不提示,部分失败仅简短提示「N 个已跳过」 |
35
+ | 拖入/粘贴 **图片** | 与文档同一落盘管线:插入 `@绝对路径` 引用 + 附件条登记缩略图;聊天区渲染为可点击放大的缩略图 |
36
+ | 图片需要被理解,而当前模型**不支持多模态** | 粘贴/上传图片时自动调用可配置 VLM(OpenAI 兼容 `chat/completions`)生成中文描述回填草稿(仅注入模型上下文,不进对话界面);agent 也可主动调用 `describe_image` 工具识别已落盘图片(含历史消息中的 `@图片` 引用) |
28
37
  | 拖入 **目录** | 拒绝,toast 提示「不支持拖入目录」,不插入引用 |
29
- | 拖入/粘贴 **图片** | 接管后走 dsh 既有草稿图片流程(`addImages`):不落盘、不进文件条、不缩放;输入框未就绪且纯图片时原样交原生 |
30
38
  | 粘贴 **纯文本**(含从地址栏复制的目录路径字符串) | 不改写,完全原生行为 |
31
39
  | 重复拖入同名文件(同一天内) | 追加 `-1`、`-2` 序号,绝不覆盖 |
32
- | 点文件条(dock)`×` 移除 | 移除 dock 条目,并清除输入框中指向该文件路径的**全部** `@` 引用 chip(同一文件拖入多次产生的多个 chip 一并清除);其他文件的引用不受影响 |
40
+ | 点附件条 `×` 移除 | 移除附件条目,并清除输入框中指向该文件路径的**全部** `@` 引用 chip(同一文件上传多次产生的多个 chip 一并清除);其他文件的引用不受影响 |
33
41
  | 单文件 > 50MB | 跳过并提示 |
34
42
 
35
43
  ### 文件命名
@@ -82,21 +90,30 @@ packages/dsh-file-attachment/
82
90
  ├── package.json # dsh.client 声明 + bundle patch 指向
83
91
  ├── cordis.patch.yml # bundle 补丁:插入本插件行
84
92
  └── lib/
85
- ├── index.js # Host 半:webServer 路由(POST /dsh-file-attachment/save),node:fs/promises 写盘
86
- └── client.js # Client 半:document 级 capture drop/paste/dragenter 监听 + 槽组件
93
+ ├── index.js # Host 半:webServer 路由(/save /config /describe)+ describe_image 工具
94
+ └── client.js # Client 半:document 级 capture drop/paste 监听 + 📎 按钮接管 + 槽组件
87
95
  ```
88
96
 
89
- - **Host 半**:`webServer.register` 前缀路由 `/dsh-file-attachment`,POST `/dsh-file-attachment/save` 接收 `{name, data(base64), sessionId}`,base64 解码后用 `node:fs/promises` 写盘到会话工作区 `.dsh-file-attachment/`,返回 `{ok, value:{path,dir,name,size}}`。
90
- - **Client 半**:`conversation.input.dock`(list 槽,同步桥状态,不渲染文本;经会话作用域取输入机,hero 首轮空白态也挂载)+ `shell.overlay`(list 槽,toast 通知展示)+ `conversation.input.left`(list 槽,**上传按钮**,渲染为左动作簇第 3 项),document 级 capture 监听先于应用 bubble 监听执行。
91
- - **上传按钮**:`FaUploadButton` 组件注入 `conversation.input.left`(`order:10`),内部是隐藏 `<input type="file" multiple accept>`(`accept` 限定 `image/*` + 常见文档扩展名),点击触发原生选择框;`onChange` 按 `file.type` 是否为 `image/` 分派到 `runBatch`(文档落盘插 `@`、图片走草稿图片流程),完成后清空 `input.value` 以便重复选择同一文件。图标为内联 SVG 回形针(📎,`stroke=currentColor`,随主题变色)。
97
+ - **Host 半**:`webServer.register` 前缀路由 `/dsh-file-attachment`:
98
+ - POST `/save`:接收 `{name, data(base64), sessionId}`,base64 解码后用 `node:fs/promises` 写盘到会话工作区 `.dsh-file-attachment/`,返回 `{ok, value:{path,dir,name,size}}`;
99
+ - GET/POST `/config`:读写插件配置(可上传类型 + VLM 参数);
100
+ - POST `/describe`:接收 `{dataUrl, prompt}`,调 VLM(OpenAI 兼容 `chat/completions`)识别图片,返回中文描述;
101
+ - `describe_image` 工具(Host 注册):agent 需要理解 `@图片` 引用时调用——读已落盘图片 → VLM 识别 → 描述直接作为工具输出返回(UI 展示 + 模型可见)。
102
+ - **Client 半**槽位:
103
+ - `conversation.input.attachments`(shadow 原生附件条,priority -100):**内联附件条**,渲染自维护的附件登记表——图片为 data URL 缩略图(点击放大)、文档为类型图标 + 文件名;条目可移除(同步清除草稿 `@引用`),发送后自动清空,不依赖 dsh 附件草稿链路(文本模型可正常发送);
104
+ - `conversation.chat.node` keyed `user`(shadow 原生用户消息渲染器,priority -100):聊天区 `@图片路径` 渲染为缩略图(经宿主 `/api/file?path=` 读取落盘图片,点击打开 Lightbox 放大层),`@文件路径` 保持芯片样式;原生 image/file 附件块(多模态模型场景)仍交 dsh 渲染;
105
+ - `conversation.input.dock`:仅保留 `FaBridge` 桥槽(经会话作用域同步 shell/actions/input 状态,hero 首轮空白态也挂载),原文件条渲染已移除;
106
+ - `shell.overlay`(list 槽,toast 通知展示)。
107
+ - **📎 按钮接管**:capture 阶段拦截原生 `fileInput` 的 change 事件,文件进入插件自定义管线(图片/文档统一落盘 + 插 `@引用`);纯文本粘贴完全原生,不做改写。document 级 capture 监听先于应用 bubble 监听执行。
92
108
  - **通知**:`shell.overlay` frame-wide 浮动 toast(不参与文档流,不破坏布局),4 秒自动消失。
93
109
  - **浮层**:拖入任何文件时 capture 阶段拦截应用自带「拖入图片…」DropOverlay(文案面向图片,对文档是误导)。
94
110
 
95
111
  ## 开发说明
96
112
 
97
113
  - `lib/*.js`、`package.json`、`cordis.patch.yml`、`README.md` 当前均为 **UTF-8 无 BOM**(以仓库实测为准);编辑时保持原编码,不引入 BOM。
98
- - Host 半用 `webServer.register({ kind: 'prefix', path: '/dsh-file-attachment', handler })` 收文件,纯 ESM 无构建,不依赖装饰器/远程反射。
114
+ - Host 半用 `webServer.register({ kind: 'prefix', path: '/dsh-file-attachment', handler })` 提供 `/save`、`/config`、`/describe` 三个路由,并经 `tools.register` 注册 `describe_image` 工具;纯 ESM 无构建,不依赖装饰器/远程反射。
99
115
  - Client 半保存用 `fetch('/dsh-file-attachment/save', { method: 'POST', body: JSON.stringify({ name, data, sessionId }) })`,信封为 `{ ok, value | error }`。
116
+ - VLM 走 OpenAI 兼容 `chat/completions`(Base URL / API Key / 模型 / 思考模式可在设置页配置,默认关闭思考模式);未填 API Key 时识别静默跳过,不影响落盘。
100
117
  - 项目根 = 会话工作区(`session.header.cwd` → `sandboxPolicy.workspaceRoot` → `process.cwd()` 兜底)。
101
118
  - 50MB 上限两侧一致(client 跳过 + host 校验)。
102
119
 
package/lib/client.js CHANGED
@@ -28,7 +28,7 @@ window.__ModuleLoader__.load({
28
28
  // ---- 模块级状态:document 监听器 / dock 槽(同步桥)/ overlay 槽(toast)共享 ----
29
29
  let ctxRef = null // apply 时记下 ctx,供远程调用与降级判断
30
30
  const bridge = {
31
- shell: null, // 输入机 shell(insertReference/snapshot),来自当前会话
31
+ shell: null, // 输入机 shell(insertText/snapshot),来自当前会话
32
32
  actions: null, // InputActions(setDraft/addImages...),来自当前会话
33
33
  input: undefined, // 最新 InputState(draft/phase...),随渲染刷新
34
34
  conversation: undefined, // conversation 服务:登记草稿图片
@@ -189,10 +189,10 @@ window.__ModuleLoader__.load({
189
189
  noticeTimer = setTimeout(() => { noticeTimer = null; if (bridge.noticeText === text) publishNotice(null) }, NOTICE_MS)
190
190
  }
191
191
 
192
- // ---- 已附加文件(待发送):runBatch 插入 chip 成功后登记,FaFileDock 渲染,发送后清空 ----
192
+ // ---- 已附加文件(待发送):runBatch 插入 @路径 成功后登记,FaAttachments(输入框内附件条)渲染,发送后清空 ----
193
193
  let faSeq = 0
194
194
  const attached = new Map() // id -> { id, name, path, isImage, url, sessionId }
195
- const attachedListeners = new Set() // 变化订阅(FaFileDock 重渲染)
195
+ const attachedListeners = new Set() // 变化订阅(FaAttachments 重渲染)
196
196
  function emitAttached() { for (const fn of attachedListeners) { try { fn() } catch (err) { /* 忽略 */ } } }
197
197
  function attachFile(entry) { attached.set(entry.id, entry); emitAttached() }
198
198
  function detachFile(id) { if (attached.delete(id)) emitAttached() }
@@ -202,38 +202,40 @@ window.__ModuleLoader__.load({
202
202
  if (changed) emitAttached()
203
203
  }
204
204
 
205
- // 文件条移除时清除草稿中指向给定路径的全部 @引用 chip(同一文件拖多次会留多个 chip,需全清)。
206
- // 兼容两代 dsh 输入机:旧版(Lexical)draft clipboard 投影(chip 展开为 @路径 文本),
207
- // 新版(纯文本机,chip 外观是文本上的扫描装饰)draft 是纯文本——两代里 occurrence
208
- // [offset, offset+length) 都是 snap.draft 内的文本区间,故统一 setDraft 整文拼接删除
209
- //(新版 consumeToken 的 span 分支内部本就是 setDraft 拼接)。每轮删一个目标区间
210
- //(连带 chip 后机器自动补的尾随空格 gap),draftRev 递增后重读 snapshot,循环至无目标。
205
+ // 附件条移除时清除草稿中指向给定路径的引用。
206
+ // chip 模式下 snap.draft(clipboardText 投影)包含 chip @完整路径;
207
+ // setDraft 会清空所有 Lexical 节点(含 chip)重建为纯文本——调用后 chip 变为
208
+ // @路径 纯文本(TextRefNode 颜色装饰,可发送)。子串须位于行首或前面是空白,
209
+ // 防误伤用户文本;同一文件拖多次会留多份,循环至无目标。
211
210
  function removeDraftRefs(path) {
212
211
  const shell = bridge.shell
213
212
  if (shell === null || typeof path !== 'string' || path === '') return
214
213
  if (typeof shell.setDraft !== 'function') return
215
214
  const plain = '@' + path
216
215
  const quoted = '@"' + path + '"'
217
- const isTarget = (oc) => oc.ref === plain || oc.ref === quoted
218
- || (oc.clipboardText !== void 0 && (oc.clipboardText === plain || oc.clipboardText === quoted))
219
216
  for (let n = 0; n < 64; n++) {
220
217
  const snap = readShellSnapshot(shell)
221
218
  if (snap === null || snap === void 0 || typeof snap.draft !== 'string') break
222
- // 发送进行中不动草稿(此时 occurrence 会随发送完成清空)
219
+ // 发送进行中不动草稿(此时 @引用 会随发送完成清空)
223
220
  if (snap.phase === 'adjudicating' || snap.phase === 'submitting') break
224
- // 取第一个有效(类型+边界健全)且指向目标路径的 occurrence,边界失配直接跳过防误伤用户文本
225
- const occs = snap.occurrences || []
226
- let oc = null
227
- for (let i = 0; i < occs.length; i++) {
228
- const o = occs[i]
229
- if (typeof o.offset !== 'number' || typeof o.length !== 'number' || o.length <= 0) continue
230
- if (o.offset < 0 || o.offset + o.length > snap.draft.length) continue
231
- if (isTarget(o)) { oc = o; break }
221
+ const draft = snap.draft
222
+ let idx = -1
223
+ for (let i = 0; i <= draft.length; i++) {
224
+ const p = draft.indexOf(plain, i)
225
+ if (p === -1) break
226
+ if (p === 0 || /\s/.test(draft[p - 1])) { idx = p; break }
232
227
  }
233
- if (oc === null) break
234
- let end = oc.offset + oc.length
235
- if (snap.draft.charCodeAt(end) === 32) end += 1 // 吃掉插入 chip 时机器自动补的尾随空格 gap
236
- shell.setDraft(snap.draft.slice(0, oc.offset) + snap.draft.slice(end))
228
+ if (idx === -1) {
229
+ for (let i = 0; i <= draft.length; i++) {
230
+ const p = draft.indexOf(quoted, i)
231
+ if (p === -1) break
232
+ if (p === 0 || /\s/.test(draft[p - 1])) { idx = p; break }
233
+ }
234
+ }
235
+ if (idx === -1) break
236
+ let end = idx + plain.length
237
+ if (draft.charCodeAt(end) === 32) end += 1 // 吃掉插入时补的尾随空格 gap
238
+ shell.setDraft(draft.slice(0, idx) + draft.slice(end))
237
239
  }
238
240
  }
239
241
 
@@ -510,7 +512,7 @@ window.__ModuleLoader__.load({
510
512
  announce('输入框不可用')
511
513
  return true
512
514
  }
513
- // 有输入桥:接管全部(图片与文档统一落盘 → @引用芯片,方案 B 绕开 dsh image 能力检查)
515
+ // 有输入桥:接管全部(图片与文档统一落盘 → @路径 纯文本,方案 B 绕开 dsh image 能力检查)
514
516
  e.preventDefault()
515
517
  e.stopImmediatePropagation()
516
518
  dismissDropOverlay()
@@ -518,8 +520,8 @@ window.__ModuleLoader__.load({
518
520
  return true
519
521
  }
520
522
 
521
- // 异步批处理:文档 + 图片统一落盘 → shell.insertReference 芯片(不显示长路径,发送还原 @ 路径)。
522
- // 图片不走 dsh 草稿附件链路(方案 B:文本模型被 dsh 拒绝 image 附件),改与文档同链路:落盘 → @引用文件条登记。
523
+ // 异步批处理:文档 + 图片统一落盘 → shell.insertReference chip(label=短文件名显示,clipboardText=@完整路径发送)。
524
+ // 图片不走 dsh 草稿附件链路(方案 B:文本模型被 dsh 拒绝 image 附件),改与文档同链路:落盘 → chip 引用 附件条登记。
523
525
  // 目录拒绝;扩展名按配置校验(图片恒允许)。
524
526
  async function runBatch(images, docs) {
525
527
  const shell = bridge.shell
@@ -563,7 +565,7 @@ window.__ModuleLoader__.load({
563
565
  const it = pending[i]
564
566
  try {
565
567
  if (it.isImage) {
566
- // 图片:落盘 → @引用芯片 + 文件条登记(方案 B:走文件引用链路,
568
+ // 图片:落盘 → @路径 纯文本 + 附件条登记(方案 B:走文件引用链路,
567
569
  // 不触发 dsh 的 image 能力检查,文本模型可正常发送到历史)
568
570
  const data = it.file
569
571
  const name = it.name
@@ -582,7 +584,7 @@ window.__ModuleLoader__.load({
582
584
  // 每次插入前重取 snapshot(draftRev 随插入递增,span 取当前末尾)
583
585
  const snap = readShellSnapshot(shell)
584
586
  // 前导分隔空格:聊天记录对 @引用 的 file-chip 渲染(projectUserText)
585
- // 要求 @ 前是行首或空白,而 insertReference 只在 chip 后补空格。
587
+ // 要求 @ 前是行首或空白;纯文本 @路径 也遵循同样边界。
586
588
  // 草稿末尾紧贴文字时先补一格,否则发送后聊天记录显示裸 @路径。
587
589
  if (snap !== void 0 && typeof snap.draft === 'string' && snap.draft !== ''
588
590
  && !isSpace(snap.draft.charCodeAt(snap.draft.length - 1))) {
@@ -594,10 +596,9 @@ window.__ModuleLoader__.load({
594
596
  }
595
597
  }
596
598
  // 补空格会递增 draftRev,重新取快照后再定插入 span。
597
- // 关键:insertReference 的 span 是 detect 坐标(编辑器实际节点坐标),
598
- // draft.length clipboard 投影坐标(chip 展开为 @路径 文本),草稿含 chip 时两者不一致
599
- // 会导致 selectSpan 越界失败(span.end > detectLength → null → ok:false)。
600
- // 用 shell.caretSpan() 拿 detect 坐标(有光标返回光标处,否则返回文档末尾)。
599
+ // 关键:insertText 的 span 是 detect 坐标(编辑器实际节点坐标),
600
+ // draft.length 是投影坐标,纯文本机下两者一致;仍优先 shell.caretSpan()
601
+ // detect 坐标(有光标返回光标处,否则返回文档末尾),与历史行为对齐。
601
602
  const snap2 = readShellSnapshot(shell)
602
603
  let span
603
604
  if (typeof shell.caretSpan === 'function') {
@@ -610,25 +611,17 @@ window.__ModuleLoader__.load({
610
611
  } else {
611
612
  span = { draftRev: snap2.draftRev, start: snap2.draft.length, end: snap2.draft.length }
612
613
  }
613
- const okInsert = shell.insertReference(
614
- {
615
- source: 'reference', // 复用内置已注册的 @引用 source(带 codec.serialize),否则报 no serializer
616
- ref: clipboardText, // codec.serialize 原样返回 ref 作为模型文本,故放完整 @路径
617
- label: name,
618
- appearance: 'file',
619
- clipboardText: clipboardText,
620
- },
621
- span,
622
- )
623
- if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] image insertReference result', { ok: okInsert, span, clipboardText, draftRev: snap2.draftRev, draftLen: snap2.draft.length })
614
+ // chip 插入:label=短文件名(显示用),clipboardText=@完整路径(发送用)
615
+ const okInsert = shell.insertReference({ source: 'reference', ref: clipboardText, label: name, appearance: 'file', clipboardText: clipboardText }, span)
616
+ if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] image insertReference result', { ok: okInsert, span, name, clipboardText, draftRev: snap2.draftRev })
624
617
  if (okInsert) {
625
618
  inserted += 1
626
- // 登记到文件条(图片:data URL 缩略图 + 点击打开;发送后随 chip 清空)
619
+ // 登记到附件条(图片:data URL 缩略图 + 点击打开;发送后随 @引用 清空)
627
620
  attachFile({ id: 'fa' + (++faSeq), name, path: res.path, isImage: true, url: thumbUrl, sessionId: bridge.sessionId })
628
621
  } else failed += 1
629
622
  continue
630
623
  }
631
- // 文档:原样落盘 → @引用芯片 + 文件条登记
624
+ // 文档:原样落盘 → @路径 纯文本 + 附件条登记
632
625
  const data = it.file
633
626
  const name = it.name
634
627
  if (data === null || typeof data.size !== 'number' || data.size === 0) throw new Error('size')
@@ -641,7 +634,7 @@ window.__ModuleLoader__.load({
641
634
  // 每次插入前重取 snapshot(draftRev 随插入递增,span 取当前末尾)
642
635
  const snap = readShellSnapshot(shell)
643
636
  // 前导分隔空格:聊天记录对 @引用 的 file-chip 渲染(projectUserText)
644
- // 要求 @ 前是行首或空白,而 insertReference 只在 chip 后补空格。
637
+ // 要求 @ 前是行首或空白;纯文本 @路径 也遵循同样边界。
645
638
  // 草稿末尾紧贴文字时先补一格,否则发送后聊天记录显示裸 @路径
646
639
  //(旧版 insertTextAtCaret 的 padStart 同款保底)。
647
640
  if (snap !== void 0 && typeof snap.draft === 'string' && snap.draft !== ''
@@ -650,12 +643,11 @@ window.__ModuleLoader__.load({
650
643
  if (typeof shell.insertText === 'function') {
651
644
  try { shell.insertText(' ', leadSpan) } catch (err) { /* 忽略:补空格失败不阻塞插入 */ }
652
645
  } else if (typeof shell.setDraft === 'function') {
653
- // 含 chip 的草稿末尾必为空白(chip 自动尾随空格),故此处必为纯文本,拼接安全
654
646
  try { shell.setDraft(snap.draft + ' ') } catch (err) { /* 忽略 */ }
655
647
  }
656
648
  }
657
649
  // 补空格会递增 draftRev,重新取快照后再定插入 span。
658
- // 与图片分支同理:insertReference 的 span 须为 detect 坐标,优先 shell.caretSpan()。
650
+ // 与图片分支同理:insertText 的 span 须为 detect 坐标,优先 shell.caretSpan()。
659
651
  const snap2 = readShellSnapshot(shell)
660
652
  let span
661
653
  if (typeof shell.caretSpan === 'function') {
@@ -668,19 +660,11 @@ window.__ModuleLoader__.load({
668
660
  } else {
669
661
  span = { draftRev: snap2.draftRev, start: snap2.draft.length, end: snap2.draft.length }
670
662
  }
671
- const okInsert = shell.insertReference(
672
- {
673
- source: 'reference', // 复用内置已注册的 @引用 source(带 codec.serialize),否则报 no serializer
674
- ref: clipboardText, // codec.serialize 原样返回 ref 作为模型文本,故放完整 @路径
675
- label: name,
676
- appearance: 'file',
677
- clipboardText: clipboardText,
678
- },
679
- span,
680
- )
663
+ // chip 插入:label=短文件名(显示用),clipboardText=@完整路径(发送用)
664
+ const okInsert = shell.insertReference({ source: 'reference', ref: clipboardText, label: name, appearance: 'file', clipboardText: clipboardText }, span)
681
665
  if (okInsert) {
682
666
  inserted += 1
683
- // 登记到文件条(图片:data URL 缩略图;文档:类型图标;发送后随 chip 清空)
667
+ // 登记到附件条(图片:data URL 缩略图;文档:类型图标;发送后随 @引用 清空)
684
668
  attachFile({ id: 'fa' + (++faSeq), name, path: res.path, isImage: false, url: null, sessionId: bridge.sessionId })
685
669
  } else failed += 1
686
670
  } catch (err) {
@@ -770,15 +754,9 @@ window.__ModuleLoader__.load({
770
754
  if (conversation !== void 0 && actx !== void 0) {
771
755
  shell = conversation.input.for(actx)
772
756
  if (shell !== null) {
773
- addImages = (files) => {
774
- try {
775
- const drafts = conversation.createDrafts(sessionId, files)
776
- if (!shell.addAttachments(drafts.map((draft) => draft.id))) conversation.releaseDraftAttachments(drafts)
777
- return null
778
- } catch (error) {
779
- return error instanceof Error ? error.message : String(error)
780
- }
781
- }
757
+ // 不再注册 dsh 原生附件(createDrafts + addAttachments 会导致原生附件条重复显示)
758
+ // 插件已接管图片落盘 + chip 引用 + 自定义附件条(FaAttachments)
759
+ addImages = (files) => { return null }
782
760
  }
783
761
  }
784
762
  }
@@ -812,8 +790,10 @@ window.__ModuleLoader__.load({
812
790
  )
813
791
  }
814
792
 
815
- // 文件条(输入框上方 dock):图片真缩略图 / 文件类型图标,点击用默认程序打开,可移除,发送后自动清空。
816
- function FaFileDock() {
793
+ // 输入框内附件条(conversation.input.attachments 槽,shadow 原生 ComposerAttachments):
794
+ // 图片显示真缩略图预览 / 文件仅类型图标;文件名横排显示在缩略图/图标右侧(草稿 @引用 chip
795
+ // 已改为只留图标不显示文字,避免双份名称);可移除(先清草稿 @引用 再登记移除);发送后自动清空。
796
+ function FaAttachments() {
817
797
  const [, force] = react.useState(0)
818
798
  react.useEffect(() => {
819
799
  const fn = () => force(n => n + 1)
@@ -839,26 +819,25 @@ window.__ModuleLoader__.load({
839
819
  if (entries.length === 0) return null
840
820
  return react.createElement('div', {
841
821
  style: {
842
- display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center',
843
- width: '100%', maxWidth: 'calc(var(--dsh-composer-card-max-width, 720px) - 24px)',
844
- margin: '0 auto 6px', padding: '2px 4px', boxSizing: 'border-box',
822
+ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center',
823
+ width: '100%', boxSizing: 'border-box',
845
824
  },
846
825
  },
847
826
  entries.map(e =>
848
827
  react.createElement('div', { key: e.id, style: {
849
- display: 'inline-flex', alignItems: 'center', gap: '6px',
828
+ display: 'inline-flex', alignItems: 'center', gap: '8px',
850
829
  background: 'rgba(128,128,128,0.12)', border: '1px solid rgba(128,128,128,0.2)',
851
- borderRadius: '8px', padding: '3px 8px', fontSize: '12px', lineHeight: '1.4', maxWidth: '240px',
830
+ borderRadius: '10px', padding: '4px 8px', fontSize: '12px', lineHeight: '1.4', maxWidth: '260px',
852
831
  } },
853
832
  e.isImage && e.url
854
- ? react.createElement('img', { src: e.url, alt: '', style: { width: '20px', height: '20px', objectFit: 'cover', borderRadius: '4px', flex: 'none' } })
833
+ ? react.createElement('img', { src: e.url, alt: e.name, title: e.name, style: { width: '56px', height: '56px', objectFit: 'cover', borderRadius: '8px', flex: 'none', display: 'block' } })
855
834
  : react.createElement('span', { style: { display: 'inline-flex', width: '20px', height: '20px', flex: 'none', color: 'inherit' } }, fileGlyph(e.name)),
856
835
  react.createElement('span', {
857
836
  style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 },
858
837
  }, e.name),
859
838
  react.createElement('span', {
860
839
  onClick: () => {
861
- // 先清草稿中指向该文件的全部 @引用 chip,再移除文件条条目
840
+ // 先清草稿中指向该文件的全部 @引用 chip,再移除附件条条目
862
841
  try { removeDraftRefs(e.path) } catch (err) { /* 忽略:引用清理失败不阻塞移除 */ }
863
842
  detachFile(e.id)
864
843
  },
@@ -870,12 +849,231 @@ window.__ModuleLoader__.load({
870
849
  )
871
850
  }
872
851
 
852
+ // ---- 聊天区用户消息渲染器(shadow conversation.chat.node keyed 'user')----
853
+ // 需求:图片在聊天区也要预览(缩略图 + 点击放大);文件自始至终不需要预览(保持 chip)。
854
+ // 方案 B 下用户消息以 @绝对路径 文本形式落库(无原生附件块),故核心是扫描 text 块内的
855
+ // @路径:图片路径 → /api/file?path= 缩略图(宿主既有文件服务路由,同 AssistantMarkdown
856
+ // localPathMediaUrl 机制);文件路径 → 复刻 dsh projectUserText 的 refChip 样式(无预览)。
857
+ // 原生 image/file 附件块(多模态模型场景)→ 尽力渲染(renderMessageImages / 文件卡片)。
858
+ // 本插件自包含(无法 import dsh 包),全部 react.createElement + 内联样式复刻。
873
859
 
874
- // 扩展名规范化:小写/去前导点/仅 [a-z0-9];非法返回 null
875
- function normExt(v) {
876
- const s = String(v == null ? '' : v).trim().toLowerCase().replace(/^\.+/u, '')
877
- return (s !== '' && /^[a-z0-9]+$/u.test(s)) ? s : null
860
+ // 图片扩展名集合(与 host IMAGE_EXT 一致:png/jpg/jpeg/gif/webp/bmp)
861
+ const IMAGE_EXT_SET = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'])
862
+ function isImagePath(p) {
863
+ if (typeof p !== 'string' || p === '') return false
864
+ const dot = p.lastIndexOf('.')
865
+ if (dot <= 0) return false
866
+ return IMAGE_EXT_SET.has(p.slice(dot + 1).toLowerCase())
867
+ }
868
+ // 复刻 dsh fileSizeText:字节数 → 人类可读
869
+ function fileSizeText(bytes) {
870
+ if (typeof bytes !== 'number' || !isFinite(bytes) || bytes < 0) return ''
871
+ if (bytes < 1024) return bytes + ' B'
872
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1).replace(/\.0$/u, '') + ' KB'
873
+ if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/u, '') + ' MB'
874
+ return (bytes / (1024 * 1024 * 1024)).toFixed(1).replace(/\.0$/u, '') + ' GB'
875
+ }
876
+ // 复刻 dsh contentParts:用户消息 content(块数组)拆 text/attachments/rest
877
+ function contentParts(content) {
878
+ const texts = []
879
+ const attachments = []
880
+ const rest = []
881
+ if (!Array.isArray(content)) {
882
+ return { text: (typeof content === 'string' ? content : ''), attachments, rest }
883
+ }
884
+ for (const block of content) {
885
+ const b = (block !== null && typeof block === 'object') ? block : null
886
+ if (b !== null && b.type === 'text' && typeof b.text === 'string') texts.push(b.text)
887
+ else if (b !== null && b.type === 'image' && b.attachment !== void 0) attachments.push({ type: 'image', image: b.attachment })
888
+ else if (b !== null && b.type === 'file' && b.attachment !== void 0) attachments.push({ type: 'file', file: b.attachment })
889
+ else if (b !== null) rest.push(block)
890
+ }
891
+ return { text: texts.join(''), attachments, rest }
892
+ }
893
+ // 复刻 dsh projectUserText 的 @路径 正则(含引号形式),扫描 text 中的引用 token。
894
+ // 返回 [{ start, end, raw, path }]:raw=完整 token(含@),path=去 @ 与前导引号后的路径。
895
+ function scanRefTokens(text) {
896
+ const out = []
897
+ if (typeof text !== 'string' || text === '') return out
898
+ const re = /(^|\s)(\/[\w-]+(?=\s|$)|@"[^"\n]+"|@[^\s]+)/gu
899
+ let m = null
900
+ while ((m = re.exec(text)) !== null) {
901
+ const tokenStart = m.index + (m[1] || '').length
902
+ const raw = m[2]
903
+ if (typeof raw !== 'string' || raw.charAt(0) !== '@') continue // /name 是 skill/command,保持文本
904
+ let path = raw.slice(1)
905
+ if (path.charAt(0) === '"' && path.length > 1 && path.charAt(path.length - 1) === '"') path = path.slice(1, -1)
906
+ out.push({ start: tokenStart, end: tokenStart + raw.length, raw, path })
907
+ }
908
+ return out
878
909
  }
910
+ // 复刻 dsh IconBrowseOutline16 的 SVG path(16×16 viewBox,文件浏览图标,currentColor)
911
+ function fileBrowseGlyph(size) {
912
+ return react.createElement('svg', { width: size, height: size, viewBox: '0 0 16 16', fill: 'none', xmlns: 'http://www.w3.org/2000/svg', 'aria-hidden': true, style: { display: 'block' } },
913
+ react.createElement('path', { d: 'M11.2426 4.80473V6.10551H4.75819V4.80473H11.2426Z', fill: 'currentColor' }),
914
+ react.createElement('path', { d: 'M9.40858 7.84478V9.14557H4.75819V7.84478H9.40858Z', fill: 'currentColor' }),
915
+ react.createElement('path', {
916
+ d: 'M9.23438 0.546389C10.1941 0.546389 10.9683 0.544914 11.5859 0.611819C12.2161 0.680096 12.7634 0.825745 13.2393 1.17139C13.5172 1.3733 13.7619 1.61812 13.9639 1.896C14.3096 2.37183 14.4551 2.91922 14.5234 3.54932C14.5903 4.16686 14.5889 4.94133 14.5889 5.90088V10.0981C14.5889 11.0576 14.5903 11.8321 14.5234 12.4497C14.4552 13.0798 14.3094 13.6272 13.9639 14.103C13.7619 14.381 13.5172 14.6257 13.2393 14.8276C12.7633 15.1734 12.2163 15.3189 11.5859 15.3872C10.9683 15.4541 10.1942 15.4536 9.23438 15.4536H6.76563C5.80591 15.4536 5.03168 15.4541 4.41407 15.3872C3.78385 15.3189 3.23665 15.1734 2.76074 14.8276C2.48291 14.6257 2.23802 14.3809 2.03614 14.103C1.69066 13.6272 1.54483 13.0798 1.47657 12.4497C1.40973 11.8321 1.41114 11.0576 1.41114 10.0981V5.90088C1.41113 4.94132 1.40966 4.16686 1.47657 3.54932C1.54488 2.91921 1.69042 2.37184 2.03614 1.896C2.2381 1.61807 2.4828 1.37333 2.76074 1.17139C3.23665 0.825682 3.78386 0.680109 4.41407 0.611819C5.03168 0.544905 5.80591 0.546389 6.76563 0.546389H9.23438ZM6.76563 1.896C5.77586 1.896 5.0876 1.89738 4.55957 1.95459C4.0443 2.01043 3.76214 2.11349 3.55469 2.26416C3.39135 2.38284 3.24761 2.52662 3.12891 2.68994C2.97821 2.89736 2.8752 3.17967 2.81934 3.69483C2.76214 4.22279 2.76075 4.91131 2.76074 5.90088V10.0981C2.76074 11.0876 2.76221 11.7762 2.81934 12.3042C2.87516 12.8194 2.97829 13.1026 3.12891 13.3101C3.24754 13.4733 3.39147 13.6172 3.55469 13.7358C3.76213 13.8865 4.04438 13.9896 4.55957 14.0454C5.0876 14.1026 5.77586 14.103 6.76563 14.103H9.23438C10.2242 14.103 10.9124 14.1026 11.4404 14.0454C11.9556 13.9896 12.2379 13.8865 12.4453 13.7358C12.6086 13.6172 12.7525 13.4733 12.8711 13.3101C13.0217 13.1026 13.1248 12.8195 13.1807 12.3042C13.2378 11.7762 13.2393 11.0876 13.2393 10.0981V5.90088C13.2393 4.91131 13.2379 4.22279 13.1807 3.69483C13.1248 3.17969 13.0218 2.89736 12.8711 2.68994C12.7524 2.52667 12.6086 2.38281 12.4453 2.26416C12.2379 2.11355 11.9556 2.01041 11.4404 1.95459C10.9124 1.8974 10.2241 1.896 9.23438 1.896H6.76563Z', fill: 'currentColor' }),
917
+ )
918
+ }
919
+ // 聊天区 @文件路径 → refChip(复刻 dsh css.refChip:inline + 主题色 + 图标 + 末段名;无预览)
920
+ function renderRefChip(raw, path) {
921
+ let name = path
922
+ const sl = path.lastIndexOf('/')
923
+ if (sl >= 0 && sl < path.length - 1) name = path.slice(sl + 1)
924
+ const isFolder = raw.charAt(raw.length - 1) === '/' || path.charAt(path.length - 1) === '/'
925
+ return react.createElement('span', {
926
+ title: raw,
927
+ style: {
928
+ display: 'inline', margin: '0 2px', whiteSpace: 'nowrap',
929
+ color: 'var(--dsw-alias-state-business-primary)', fontWeight: 500,
930
+ },
931
+ },
932
+ react.createElement('span', { style: { display: 'inline-block', width: '1em', height: '1em', marginRight: '4px', verticalAlign: '-0.125em', color: 'inherit' } },
933
+ fileBrowseGlyph(16)),
934
+ name,
935
+ )
936
+ }
937
+ // 图片放大预览(Lightbox):模块级状态,FaUserMessage 缩略图点击打开,FaLightbox 渲染
938
+ let lightboxSrc = null
939
+ let lightboxName = ''
940
+ let lightboxListener = null
941
+ function openLightbox(src, name) {
942
+ lightboxSrc = src
943
+ lightboxName = name || ''
944
+ if (lightboxListener !== null) lightboxListener()
945
+ }
946
+ function FaLightbox() {
947
+ const [, force] = react.useState(0)
948
+ react.useEffect(() => {
949
+ lightboxListener = () => force(n => n + 1)
950
+ return () => { if (lightboxListener !== null) lightboxListener = null }
951
+ }, [])
952
+ const src = lightboxSrc
953
+ if (src === null || src === '') return null
954
+ const close = () => { openLightbox(null, '') }
955
+ return react.createElement('div', {
956
+ onClick: close,
957
+ style: {
958
+ position: 'fixed', inset: '0', zIndex: 3000,
959
+ background: 'rgba(0,0,0,0.74)', display: 'flex', alignItems: 'center', justifyContent: 'center',
960
+ cursor: 'zoom-out',
961
+ },
962
+ },
963
+ react.createElement('img', { src, alt: lightboxName, style: { maxWidth: '90vw', maxHeight: '90vh', borderRadius: '8px', boxShadow: '0 8px 40px rgba(0,0,0,0.5)' } }),
964
+ react.createElement('span', {
965
+ onClick: (e) => { e.stopPropagation(); close() },
966
+ title: '关闭',
967
+ style: { position: 'absolute', top: '18px', right: '24px', color: '#fff', fontSize: '30px', cursor: 'pointer', lineHeight: '1', userSelect: 'none' },
968
+ }, '×'),
969
+ )
970
+ }
971
+ // 宿主文件服务 URL:与 dsh AssistantMarkdown.localPathMediaUrl 同机制
972
+ function imageUrlFor(path) {
973
+ return '/api/file?path=' + encodeURIComponent(path)
974
+ }
975
+ // 聊天区 @图片路径 → 缩略图(<img /api/file?path=> + 点击放大 Lightbox)
976
+ function renderImageThumb(path, raw) {
977
+ const src = imageUrlFor(path)
978
+ return react.createElement('img', {
979
+ src,
980
+ alt: '',
981
+ title: raw,
982
+ onClick: () => { openLightbox(src, path) },
983
+ style: {
984
+ maxWidth: '240px', maxHeight: '240px', width: 'auto', height: 'auto',
985
+ objectFit: 'contain', borderRadius: '12px', display: 'inline-block',
986
+ verticalAlign: 'middle', cursor: 'zoom-in', margin: '2px 2px',
987
+ },
988
+ })
989
+ }
990
+ // text 块 → 节点序列:普通文本 run + @路径(图片→缩略图 / 文件→chip)
991
+ function projectTextWithImages(text) {
992
+ const tokens = scanRefTokens(text)
993
+ if (tokens.length === 0) return react.createElement('span', null, text)
994
+ const parts = []
995
+ let cursor = 0
996
+ for (const tk of tokens) {
997
+ if (tk.start > cursor) parts.push(react.createElement('span', { key: 't' + cursor }, text.slice(cursor, tk.start)))
998
+ if (isImagePath(tk.path)) parts.push(react.createElement('span', { key: 'i' + tk.start }, renderImageThumb(tk.path, tk.raw)))
999
+ else parts.push(react.createElement('span', { key: 'r' + tk.start }, renderRefChip(tk.raw, tk.path)))
1000
+ cursor = tk.end
1001
+ }
1002
+ if (cursor < text.length) parts.push(react.createElement('span', { key: 't' + cursor }, text.slice(cursor)))
1003
+ return react.createElement(react.Fragment, null, parts)
1004
+ }
1005
+ // 聊天区文件附件块 → 文件卡片(名 + 扩展名/大小 meta;无预览)
1006
+ function renderFileCard(file, key) {
1007
+ const ext = (file && typeof file.name === 'string' ? file.name.lastIndexOf('.') : -1) > 0
1008
+ ? String(file.name.slice(file.name.lastIndexOf('.') + 1)).toUpperCase().slice(0, 8)
1009
+ : ''
1010
+ const meta = [ext, fileSizeText(file && file.bytes)].filter(Boolean).join(' ')
1011
+ return react.createElement('span', {
1012
+ key, title: file && file.name,
1013
+ style: {
1014
+ display: 'inline-flex', flex: '0 0 240px', alignItems: 'center', gap: '10px',
1015
+ width: '240px', minHeight: '64px', padding: '8px 12px',
1016
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
1017
+ borderRadius: '16px', background: 'var(--dsw-specific-input-major, transparent)',
1018
+ boxSizing: 'border-box',
1019
+ },
1020
+ },
1021
+ react.createElement('span', { style: { display: 'inline-flex', width: '20px', height: '20px', flex: 'none' } },
1022
+ fileGlyph(file && file.name ? file.name : 'file')),
1023
+ react.createElement('span', { style: { display: 'flex', flexDirection: 'column', minWidth: 0 } },
1024
+ react.createElement('span', { style: { fontSize: '13px', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, file && file.name),
1025
+ react.createElement('span', { style: { fontSize: '11px', opacity: 0.7 } }, meta),
1026
+ ),
1027
+ )
1028
+ }
1029
+ // 聊天区用户消息渲染器:气泡 + @图片缩略图预览 / @文件 chip;附件块尽力渲染
1030
+ function FaUserMessage(props) {
1031
+ const node = props && props.node
1032
+ const data = node ? node.data : null
1033
+ const content = data ? data.content : []
1034
+ const renderMessageImages = props && props.renderMessageImages
1035
+ const { text, attachments, rest } = contentParts(content)
1036
+ const showBubble = text !== '' || rest.length > 0
1037
+ const rows = []
1038
+ // 附件块(image → 原生缩略图画廊 / file → 文件卡片)
1039
+ for (let i = 0; i < attachments.length; i++) {
1040
+ const a = attachments[i]
1041
+ if (a.type === 'image') {
1042
+ // 原生 image 附件块:交 dsh 缩略图画廊渲染(ChatNodeOwnerProps.renderMessageImages,
1043
+ // 由 CHAT_NODE_INJECT 注入,宿主必有)
1044
+ if (typeof renderMessageImages === 'function') {
1045
+ rows.push(react.createElement(react.Fragment, { key: 'image:' + i }, renderMessageImages({ images: [{ attachment: a.image }], align: 'end', compact: attachments.length > 1 })))
1046
+ }
1047
+ } else {
1048
+ rows.push(renderFileCard(a.file, 'file:' + i))
1049
+ }
1050
+ }
1051
+ // 气泡(text + rest)
1052
+ if (showBubble) {
1053
+ rows.push(react.createElement('div', {
1054
+ key: 'bubble',
1055
+ style: {
1056
+ maxWidth: '100%', background: 'var(--dsw-specific-bubble)', borderRadius: '22px',
1057
+ padding: '10px 16px', fontSize: 'var(--dsh-content-font-size, 14px)',
1058
+ lineHeight: 'calc(22px + var(--dsh-content-font-delta, 0px))',
1059
+ whiteSpace: 'pre-wrap', wordBreak: 'break-word',
1060
+ },
1061
+ },
1062
+ projectTextWithImages(text),
1063
+ rest.map((block, i) => {
1064
+ let label = ''
1065
+ try { label = JSON.stringify(block) } catch (err) { label = String(block) }
1066
+ if (label.length > 200) label = label.slice(0, 200) + '…'
1067
+ return react.createElement('div', { key: 'rest' + i, style: { fontSize: '12px', opacity: 0.75, marginTop: '4px' } }, label)
1068
+ }),
1069
+ ))
1070
+ }
1071
+ if (rows.length === 0) return null
1072
+ return react.createElement('div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '6px' } },
1073
+ react.createElement('div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '8px', minWidth: 0, maxWidth: 'min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%)' } }, rows),
1074
+ )
1075
+ }
1076
+
879
1077
  function addExt(setList, value, current) {
880
1078
  const s = normExt(value)
881
1079
  if (s === null || current.indexOf(s) >= 0) return
@@ -927,6 +1125,11 @@ window.__ModuleLoader__.load({
927
1125
  )
928
1126
  }
929
1127
 
1128
+ // 扩展名规范化:小写/去前导点/仅 [a-z0-9];非法返回 null
1129
+ function normExt(v) {
1130
+ const s = String(v == null ? '' : v).trim().toLowerCase().replace(/^\.+/u, '')
1131
+ return (s !== '' && /^[a-z0-9]+$/u.test(s)) ? s : null
1132
+ }
930
1133
  // 设置页:配置可上传的文档/代码/配置文件扩展名,保存写 ~/.dsh/file-attachment.json
931
1134
  function FaSettingsPage() {
932
1135
  const [doc, setDoc] = react.useState(DEFAULT_TYPES.doc.slice())
@@ -1075,15 +1278,28 @@ window.__ModuleLoader__.load({
1075
1278
  },
1076
1279
  FaBridge
1077
1280
  ))
1078
- // 文件条:渲染已附加文件(缩略图/类型图标 + 点击打开 + 可移除),发送后清空
1079
- slots.inject('conversation.input.dock', () => slots.register(
1080
- { name: 'conversation.input.dock', id: 'dsh-file-attachment-dock', order: 400 },
1081
- FaFileDock
1281
+ // 输入框内附件条:shadow 原生 ComposerAttachments(priority -100 < ui-attachment 的 0,
1282
+ // lowest wins),图片缩略图预览 / 文件条目(无预览),发送后清空
1283
+ slots.inject('conversation.input.attachments', () => slots.register(
1284
+ { name: 'conversation.input.attachments', id: 'dsh-file-attachment-attachments', priority: -100 },
1285
+ FaAttachments
1286
+ ))
1287
+ // 聊天区用户消息:shadow 原生 UserMessageNodeView(keyed 'user',priority -100 < 0,
1288
+ // lowest wins;Reusing a key replaces that node renderer),@图片路径→缩略图预览,
1289
+ // @文件路径→chip(无预览)
1290
+ slots.inject('conversation.chat.node', () => slots.register(
1291
+ { name: 'conversation.chat.node', key: 'user', priority: -100 },
1292
+ FaUserMessage
1082
1293
  ))
1083
1294
  slots.inject('shell.overlay', () => slots.register(
1084
1295
  { name: 'shell.overlay', id: 'dsh-file-attachment-toast', order: 100 },
1085
1296
  FaToast
1086
1297
  ))
1298
+ // 图片放大预览(Lightbox):list 槽,与 toast 共存(order 200 渲染在 toast 之上)
1299
+ slots.inject('shell.overlay', () => slots.register(
1300
+ { name: 'shell.overlay', id: 'dsh-file-attachment-lightbox', order: 200 },
1301
+ FaLightbox
1302
+ ))
1087
1303
  // 设置页:文件附件类型配置(左侧菜单项 + 右侧配置页)
1088
1304
  slots.inject('settings.section', () => [
1089
1305
  slots.register({
package/lib/index.js CHANGED
@@ -8,7 +8,6 @@ import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
8
8
  import { homedir } from 'node:os'
9
9
  import { join, resolve } from 'node:path'
10
10
  import { defineTool } from '@deepseek-ai/dsh-tools'
11
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
12
11
 
13
12
  /** 本包声明依赖的 Host 服务。 */
14
13
  export const name = 'dsh-file-attachment'
@@ -227,17 +226,18 @@ async function handleSave(root, body) {
227
226
  * @returns 图片的简洁中文描述。
228
227
  * @throws VLM 请求失败或未返回有效内容时抛错。
229
228
  */
230
- async function describeImage(vlm, dataUrl) {
229
+ async function describeImage(vlm, dataUrl, prompt) {
231
230
  const url = vlm.baseURL.replace(/\/+$/u, '') + '/chat/completions'
231
+ const userPrompt = (typeof prompt === 'string' && prompt.trim() !== '') ? prompt.trim() : '请描述这张图片。'
232
232
  const payload = {
233
233
  model: vlm.model,
234
234
  thinkingType: vlm.thinkingType,
235
235
  messages: [
236
- { role: 'system', content: '你是图片描述助手,用简洁中文描述图片主要内容。' },
236
+ { role: 'system', content: '你是图片描述助手,用简洁中文回答。' },
237
237
  {
238
238
  role: 'user',
239
239
  content: [
240
- { type: 'text', text: '请描述这张图片。' },
240
+ { type: 'text', text: userPrompt },
241
241
  { type: 'image_url', image_url: { url: dataUrl } },
242
242
  ],
243
243
  },
@@ -326,7 +326,7 @@ function registerRoutes(ctx) {
326
326
  return
327
327
  }
328
328
  try {
329
- const description = await describeImage(vlm, dataUrl)
329
+ const description = await describeImage(vlm, dataUrl, body.prompt)
330
330
  json(res, { ok: true, value: { description } })
331
331
  return
332
332
  } catch (err) {
@@ -359,8 +359,8 @@ function mimeFromPath(p) {
359
359
 
360
360
  /**
361
361
  * 注册 describe_image 工具:模型看到用户消息里的图片路径引用(@/绝对路径)时调用,
362
- * 读图片 → 调 VLM 识别 → 描述仅注入模型上下文(deferContext,不进 UI/session 历史),
363
- * UI 只渲染简短确认。实现「图片进历史后自动识别、结果不发给用户」。
362
+ * 读图片 → 调 VLM 识别 → 识别结果直接作为工具输出返回:
363
+ * UI 展示描述文本,同时该输出即模型可见内容(tool/result 的 content 是 model-facing)。
364
364
  */
365
365
  function registerTools(ctx) {
366
366
  const tools = (ctx !== undefined && ctx !== null && typeof ctx.tools === 'object' && ctx.tools !== null)
@@ -372,13 +372,17 @@ function registerTools(ctx) {
372
372
  }
373
373
  tools.register(defineTool({
374
374
  name: 'describe_image',
375
- description: '当用户消息里出现图片路径引用(形如 @/绝对路径,扩展名 png/jpg/jpeg/gif/webp/bmp)且需要理解图片内容时调用此工具,获取该图片的中文内容描述。path 参数填去掉前导 @ 的绝对路径。',
375
+ description: '当用户消息里出现图片路径引用(形如 @/绝对路径,扩展名 png/jpg/jpeg/gif/webp/bmp)且需要理解图片内容时调用此工具,获取该图片的中文内容描述。path 参数填去掉前导 @ 的绝对路径。prompt 可选,指定要关注的内容(如"识别图中文字"、"描述 UI 布局")。',
376
376
  parameters: {
377
377
  path: {
378
378
  type: 'string',
379
379
  required: true,
380
380
  description: '图片文件的绝对路径(去掉前导 @)',
381
381
  },
382
+ prompt: {
383
+ type: 'string',
384
+ description: '可选。告诉多模态模型要关注什么(如"识别图中的文字"、"描述 UI 布局")。不填则默认"请描述这张图片"。',
385
+ },
382
386
  },
383
387
  output: {
384
388
  schema: {
@@ -386,11 +390,15 @@ function registerTools(ctx) {
386
390
  additionalProperties: false,
387
391
  properties: {
388
392
  ok: { type: 'boolean', required: true },
393
+ description: { type: 'string' },
389
394
  },
390
395
  },
391
- render: (_args, value) => [
392
- { type: 'text', text: (value && value.ok === true) ? '(图片已识别,描述已注入上下文)' : '(图片识别失败)' },
393
- ],
396
+ render: (_args, value) => {
397
+ if (value && value.ok === true && typeof value.description === 'string' && value.description !== '') {
398
+ return [{ type: 'text', text: '图片识别结果:' + value.description }]
399
+ }
400
+ return [{ type: 'text', text: (value && value.ok === true) ? '(图片已识别)' : '(图片识别失败)' }]
401
+ },
394
402
  },
395
403
  async execute(args, exec) {
396
404
  let path = typeof args.path === 'string' ? args.path.trim() : ''
@@ -402,13 +410,10 @@ function registerTools(ctx) {
402
410
  const dataUrl = 'data:' + mimeFromPath(path) + ';base64,' + bytes.toString('base64')
403
411
  const cfg = await readConfig()
404
412
  if (cfg.vlm.apiKey === '') throw new Error('未配置多模态 API Key(设置→文件附件填写),无法识别图片')
405
- const description = await describeImage(cfg.vlm, dataUrl)
406
- // 描述仅注入模型上下文(不进 session 历史 / UI),实现「结果不发给用户」
407
- exec.deferContext(createUserMessage({
408
- content: [{ type: 'text', text: '图片 ' + path + ' 的内容描述:' + description }],
409
- source: { kind: 'plugin', plugin: 'dsh-file-attachment' },
410
- }))
411
- return { ok: true }
413
+ const description = await describeImage(cfg.vlm, dataUrl, args.prompt)
414
+ // 识别结果直接作为工具输出返回:UI 直接展示描述文本,
415
+ // 同时该输出就是模型可见内容(tool/result 的 content),无需额外 deferContext 注入。
416
+ return { ok: true, description }
412
417
  },
413
418
  }))
414
419
  console.log('[dsh-file-attachment] describe_image tool registered')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wszhoho/dsh-file-attachment",
3
- "version": "0.5.0",
4
- "description": "文件附件:输入框上传按钮 + 拖拽/粘贴文件(支持多文件);图片走 dsh 原生图片草稿机制(addImages,不缩放/不落盘),文档落盘到 .dsh-file-attachment 并以芯片 @短名 引用(发送时还原 @绝对路径);非多模态模型下图片自动调用可配置 VLM 识别生成中文描述回填草稿;文档/代码/配置文件可上传类型可在设置页配置;支持 PC 与移动端浏览器",
3
+ "version": "0.5.2",
4
+ "description": "文件附件:拖拽/粘贴/上传文件(支持多文件);图片与文档统一落盘到 .dsh-file-attachment 并以 @绝对路径 引用发送(文本模型可正常使用);输入框内联显示图片缩略图预览与文件条目,聊天区图片同样渲染为可点击放大的缩略图、文件保持芯片样式;非多模态模型下图片自动调用可配置 VLM 识别生成中文描述回填草稿;文档/代码/配置文件可上传类型可在设置页配置;支持 PC 与移动端浏览器",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "plugin",