@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.
- package/lib/client.js +302 -86
- package/lib/index.js +23 -18
- 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(
|
|
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 插入
|
|
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() // 变化订阅(
|
|
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
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
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
|
-
// 发送进行中不动草稿(此时
|
|
219
|
+
// 发送进行中不动草稿(此时 @引用 会随发送完成清空)
|
|
223
220
|
if (snap.phase === 'adjudicating' || snap.phase === 'submitting') break
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
let
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (
|
|
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 (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
// 有输入桥:接管全部(图片与文档统一落盘 →
|
|
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
|
-
// 图片:落盘 →
|
|
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
|
-
// 要求 @
|
|
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
|
-
// 关键:
|
|
598
|
-
// draft.length
|
|
599
|
-
//
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
//
|
|
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
|
-
// 要求 @
|
|
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
|
-
// 与图片分支同理:
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
//
|
|
816
|
-
|
|
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: '
|
|
843
|
-
width: '100%',
|
|
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: '
|
|
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: '
|
|
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:
|
|
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
|
-
//
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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 识别 →
|
|
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
|
-
|
|
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
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "文件附件:拖拽/粘贴/上传文件(支持多文件);图片与文档统一落盘到 .dsh-file-attachment 并以 @绝对路径 引用发送(文本模型可正常使用);输入框内联显示图片缩略图预览与文件条目,聊天区图片同样渲染为可点击放大的缩略图、文件保持芯片样式;非多模态模型下图片自动调用可配置 VLM 识别生成中文描述回填草稿;文档/代码/配置文件可上传类型可在设置页配置;支持 PC 与移动端浏览器",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
7
7
|
"plugin",
|