@wszhoho/dsh-file-attachment 0.5.0 → 0.5.1

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.
Files changed (3) hide show
  1. package/lib/client.js +302 -86
  2. package/lib/index.js +23 -18
  3. package/package.json +2 -2
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.1",
4
+ "description": "文件附件:拖拽/粘贴/上传文件(支持多文件);图片与文档统一落盘到 .dsh-file-attachment 并以 @绝对路径 引用发送(文本模型可正常使用);输入框内联显示图片缩略图预览与文件条目,聊天区图片同样渲染为可点击放大的缩略图、文件保持芯片样式;非多模态模型下图片自动调用可配置 VLM 识别生成中文描述回填草稿;文档/代码/配置文件可上传类型可在设置页配置;支持 PC 与移动端浏览器",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "plugin",