@wszhoho/dsh-file-attachment 0.4.1 → 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 +498 -166
- package/lib/index.js +186 -13
- package/package.json +5 -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 服务:登记草稿图片
|
|
@@ -49,9 +49,10 @@ window.__ModuleLoader__.load({
|
|
|
49
49
|
config: ['json', 'yml', 'yaml', 'toml', 'ini', 'conf', 'cfg', 'env', 'properties', 'xml', 'html', 'css'],
|
|
50
50
|
}
|
|
51
51
|
const allowedTypes = { doc: DEFAULT_TYPES.doc.slice(), code: DEFAULT_TYPES.code.slice(), config: DEFAULT_TYPES.config.slice() }
|
|
52
|
+
// 多模态 VLM 识别参数(仅当前模型非多模态时调用):baseURL/apiKey/model/thinkingType
|
|
53
|
+
let vlmCfg = { baseURL: 'https://api.xiaomimimo.com/v1', apiKey: '', model: 'mimo-v2.5', thinkingType: 'disabled' }
|
|
52
54
|
let allowedExts = null // Set<ext>,rebuildAllowed 填充;上传校验用
|
|
53
|
-
let acceptString = 'image/*' //
|
|
54
|
-
const acceptListeners = new Set() // accept 变化订阅(设置页保存 → 上传按钮重渲染)
|
|
55
|
+
let acceptString = 'image/*' // 文件选择器 <input accept>,buildAccept 生成;劫持📎打开前赋值
|
|
55
56
|
// 规范化扩展名列表(小写/去点/去重/仅 [a-z0-9]),与 host 侧同逻辑
|
|
56
57
|
function normalizeList(arr) {
|
|
57
58
|
const out = []
|
|
@@ -71,20 +72,90 @@ window.__ModuleLoader__.load({
|
|
|
71
72
|
allowedExts = set
|
|
72
73
|
}
|
|
73
74
|
function buildAccept() {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
// 默认所有文件类型可见(浏览器文件选择器不按扩展名过滤);
|
|
76
|
+
// 扩展名白名单校验在 runBatch 阶段执行(不在允许列表则 announce 拒绝)。
|
|
77
|
+
return '*/*'
|
|
78
|
+
}
|
|
79
|
+
// ---- 劫持 dsh 原生📎按钮(hero / composer 两模式同一 InputBar,按钮都在加号右侧、modes 左侧)----
|
|
80
|
+
// 插件不再渲染自己的上传按钮;而是 capture 阶段拦截原生📎 click,改走本插件文件选择器,
|
|
81
|
+
// ---- 接管 dsh 原生📎:上传入口位于与 dsh 本体完全一致的位置(hero/composer 两模式同一按钮)----
|
|
82
|
+
// 方案:不劫持📎 click(原生 onClick 会打开 dsh 自己的 fileInput,files 可靠),
|
|
83
|
+
// 改为劫持 dsh 原生 fileInput 的 change 事件(元素级监听先于 React 冒泡,stopImmediatePropagation 阻断 onPickFiles),
|
|
84
|
+
// 读 files 后改走本插件 runBatch 管线。修复:自建隐藏 input 的 files 在某些浏览器返回空(count:0)导致上传失效。
|
|
85
|
+
const hijackedInputs = new WeakSet() // 已挂拦截的原生 fileInput
|
|
86
|
+
let uploadBusy = false // 文件选择中防重入
|
|
87
|
+
const nativeFileInputSelector = '[data-composer-card] input[type="file"], [data-composer-card] input[type=file]'
|
|
88
|
+
function onNativeFileChange(e) {
|
|
89
|
+
const el = e.currentTarget
|
|
90
|
+
if (el === null || el === void 0) return
|
|
91
|
+
const list = el.files
|
|
92
|
+
if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] native fileInput change', { count: list === null ? 0 : list.length })
|
|
93
|
+
if (list === null || list.length === 0) return
|
|
94
|
+
e.preventDefault()
|
|
95
|
+
e.stopImmediatePropagation()
|
|
96
|
+
// FileList 是活对象:el.value='' 会把它清空(length 变 0),必须先快照成数组
|
|
97
|
+
const files = Array.prototype.slice.call(list)
|
|
98
|
+
try { el.value = '' } catch (err) { /* 忽略 */ }
|
|
99
|
+
const images = []
|
|
100
|
+
const docs = []
|
|
101
|
+
for (let i = 0; i < files.length; i++) {
|
|
102
|
+
const f = files[i]
|
|
103
|
+
if (f.type && f.type.indexOf('image/') === 0) { images.push(f); continue }
|
|
104
|
+
docs.push({ path: null, name: f.name, isDir: false, file: f })
|
|
105
|
+
}
|
|
106
|
+
if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] native change classified', { images: images.length, docs: docs.length })
|
|
107
|
+
if (images.length === 0 && docs.length === 0) return
|
|
108
|
+
uploadBusy = true
|
|
109
|
+
try {
|
|
110
|
+
runBatch(images, docs).catch((err) => {
|
|
111
|
+
console.error('[dsh-file-attachment] runBatch rejected:', err)
|
|
112
|
+
announce('文件处理失败: ' + (err !== null && err !== void 0 && err.message ? err.message : String(err)))
|
|
113
|
+
})
|
|
114
|
+
} finally { uploadBusy = false }
|
|
115
|
+
}
|
|
116
|
+
function hijackFileInput(el) {
|
|
117
|
+
if (hijackedInputs.has(el)) return
|
|
118
|
+
hijackedInputs.add(el)
|
|
119
|
+
el.addEventListener('change', onNativeFileChange)
|
|
120
|
+
}
|
|
121
|
+
function installNativeAttachHijack() {
|
|
122
|
+
if (typeof document === 'undefined' || document.body === null || document.body === undefined) return
|
|
123
|
+
let body
|
|
124
|
+
try { body = document.body } catch (err) { return }
|
|
125
|
+
if (!(body instanceof Node)) return
|
|
126
|
+
const scan = () => {
|
|
127
|
+
let all = []
|
|
128
|
+
try { all = Array.from(body.querySelectorAll(nativeFileInputSelector)) } catch (err) { all = [] }
|
|
129
|
+
for (let i = 0; i < all.length; i++) hijackFileInput(all[i])
|
|
130
|
+
}
|
|
131
|
+
scan()
|
|
132
|
+
const mo = new MutationObserver(() => scan())
|
|
133
|
+
try { mo.observe(body, { childList: true, subtree: true }) } catch (err) { /* 忽略 */ }
|
|
134
|
+
// 兜底定时重扫:MutationObserver 偶发漏报(如同一帧内大段替换)时保底
|
|
135
|
+
const timer = setInterval(scan, 1500)
|
|
136
|
+
return { mo, timer }
|
|
137
|
+
}
|
|
138
|
+
// 客户端侧多模态参数规范化(空值回退默认,与 host normalizeMimo 一致)
|
|
139
|
+
function normalizeVlmClient(raw) {
|
|
140
|
+
const d = { baseURL: 'https://api.xiaomimimo.com/v1', apiKey: '', model: 'mimo-v2.5', thinkingType: 'disabled' }
|
|
141
|
+
if (raw === null || typeof raw !== 'object') return d
|
|
142
|
+
const pick = (k) => (typeof raw[k] === 'string' && raw[k] !== '') ? raw[k] : d[k]
|
|
143
|
+
return { baseURL: pick('baseURL'), apiKey: pick('apiKey'), model: pick('model'), thinkingType: raw.thinkingType === 'enabled' ? 'enabled' : 'disabled' }
|
|
79
144
|
}
|
|
80
|
-
// 应用配置:更新 allowedTypes → 重建校验 Set + accept
|
|
145
|
+
// 应用配置:更新 allowedTypes → 重建校验 Set + accept(劫持📎在打开选择器时读取 acceptString)
|
|
81
146
|
function applyConfig(cfg) {
|
|
82
|
-
|
|
83
|
-
allowedTypes.
|
|
84
|
-
allowedTypes.
|
|
147
|
+
// 空数组/未配置 → 回退默认类型(否则 config 空数组会清空 allowedTypes,文档全被拒)
|
|
148
|
+
allowedTypes.doc = pickTypes(cfg && cfg.doc, DEFAULT_TYPES.doc)
|
|
149
|
+
allowedTypes.code = pickTypes(cfg && cfg.code, DEFAULT_TYPES.code)
|
|
150
|
+
allowedTypes.config = pickTypes(cfg && cfg.config, DEFAULT_TYPES.config)
|
|
151
|
+
vlmCfg = normalizeVlmClient(cfg && (cfg.vlm || cfg.mimo))
|
|
85
152
|
rebuildAllowed()
|
|
86
153
|
acceptString = buildAccept()
|
|
87
|
-
|
|
154
|
+
}
|
|
155
|
+
// 配置优先:list 规范化后非空则采用,否则回退默认(allow all 由设置页传 '*' 表达)
|
|
156
|
+
function pickTypes(list, fallback) {
|
|
157
|
+
const n = normalizeList(list)
|
|
158
|
+
return n.length > 0 ? n : fallback.slice()
|
|
88
159
|
}
|
|
89
160
|
// 拉取持久化配置;成功覆盖默认,失败保留默认。幂等(复用同一 Promise)
|
|
90
161
|
let configPromise = null
|
|
@@ -118,10 +189,10 @@ window.__ModuleLoader__.load({
|
|
|
118
189
|
noticeTimer = setTimeout(() => { noticeTimer = null; if (bridge.noticeText === text) publishNotice(null) }, NOTICE_MS)
|
|
119
190
|
}
|
|
120
191
|
|
|
121
|
-
// ---- 已附加文件(待发送):runBatch 插入
|
|
192
|
+
// ---- 已附加文件(待发送):runBatch 插入 @路径 成功后登记,FaAttachments(输入框内附件条)渲染,发送后清空 ----
|
|
122
193
|
let faSeq = 0
|
|
123
194
|
const attached = new Map() // id -> { id, name, path, isImage, url, sessionId }
|
|
124
|
-
const attachedListeners = new Set() // 变化订阅(
|
|
195
|
+
const attachedListeners = new Set() // 变化订阅(FaAttachments 重渲染)
|
|
125
196
|
function emitAttached() { for (const fn of attachedListeners) { try { fn() } catch (err) { /* 忽略 */ } } }
|
|
126
197
|
function attachFile(entry) { attached.set(entry.id, entry); emitAttached() }
|
|
127
198
|
function detachFile(id) { if (attached.delete(id)) emitAttached() }
|
|
@@ -131,38 +202,40 @@ window.__ModuleLoader__.load({
|
|
|
131
202
|
if (changed) emitAttached()
|
|
132
203
|
}
|
|
133
204
|
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
//(连带 chip 后机器自动补的尾随空格 gap),draftRev 递增后重读 snapshot,循环至无目标。
|
|
205
|
+
// 附件条移除时清除草稿中指向给定路径的引用。
|
|
206
|
+
// chip 模式下 snap.draft(clipboardText 投影)包含 chip 的 @完整路径;
|
|
207
|
+
// setDraft 会清空所有 Lexical 节点(含 chip)重建为纯文本——调用后 chip 变为
|
|
208
|
+
// @路径 纯文本(TextRefNode 颜色装饰,可发送)。子串须位于行首或前面是空白,
|
|
209
|
+
// 防误伤用户文本;同一文件拖多次会留多份,循环至无目标。
|
|
140
210
|
function removeDraftRefs(path) {
|
|
141
211
|
const shell = bridge.shell
|
|
142
212
|
if (shell === null || typeof path !== 'string' || path === '') return
|
|
143
213
|
if (typeof shell.setDraft !== 'function') return
|
|
144
214
|
const plain = '@' + path
|
|
145
215
|
const quoted = '@"' + path + '"'
|
|
146
|
-
const isTarget = (oc) => oc.ref === plain || oc.ref === quoted
|
|
147
|
-
|| (oc.clipboardText !== void 0 && (oc.clipboardText === plain || oc.clipboardText === quoted))
|
|
148
216
|
for (let n = 0; n < 64; n++) {
|
|
149
217
|
const snap = readShellSnapshot(shell)
|
|
150
218
|
if (snap === null || snap === void 0 || typeof snap.draft !== 'string') break
|
|
151
|
-
// 发送进行中不动草稿(此时
|
|
219
|
+
// 发送进行中不动草稿(此时 @引用 会随发送完成清空)
|
|
152
220
|
if (snap.phase === 'adjudicating' || snap.phase === 'submitting') break
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
let
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
if (o.offset < 0 || o.offset + o.length > snap.draft.length) continue
|
|
160
|
-
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 }
|
|
161
227
|
}
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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))
|
|
166
239
|
}
|
|
167
240
|
}
|
|
168
241
|
|
|
@@ -385,6 +458,7 @@ window.__ModuleLoader__.load({
|
|
|
385
458
|
return out
|
|
386
459
|
}
|
|
387
460
|
|
|
461
|
+
// File → dataURL(base64 data: 前缀),供 host /describe 的 VLM 识别
|
|
388
462
|
// 触发应用自身 drop 浮层的关闭:应用在 window 上监听 dragend 执行 reset()。
|
|
389
463
|
// 从 OS 拖入时浏览器不派发 dragend,且我们拦截 drop 后应用的 drop 监听收不到事件,浮层会残留。
|
|
390
464
|
function dismissDropOverlay() {
|
|
@@ -407,7 +481,15 @@ window.__ModuleLoader__.load({
|
|
|
407
481
|
const docs = [] // { path, name, isDir, file }
|
|
408
482
|
for (let i = 0; i < files.length; i++) {
|
|
409
483
|
const file = files[i]
|
|
410
|
-
|
|
484
|
+
// File.type 在剪贴板截图场景下可能为空(浏览器未填充 MIME);
|
|
485
|
+
// 回退到 DataTransferItem.type 判定,并创建带正确 MIME 的 File 副本,
|
|
486
|
+
// 确保 createDrafts 的 imageMediaType 校验通过。
|
|
487
|
+
const effType = file.type || ((items && items[i]) ? items[i].type : '') || ''
|
|
488
|
+
if (effType.indexOf('image/') === 0) {
|
|
489
|
+
// file.type 为空时用 items[i].type 补全,生成新 File 对象
|
|
490
|
+
images.push(file.type ? file : new File([file], file.name || 'image.png', { type: effType }))
|
|
491
|
+
continue
|
|
492
|
+
}
|
|
411
493
|
let entry = null
|
|
412
494
|
const item = items ? items[i] : null
|
|
413
495
|
if (item && typeof item.webkitGetAsEntry === 'function') {
|
|
@@ -430,7 +512,7 @@ window.__ModuleLoader__.load({
|
|
|
430
512
|
announce('输入框不可用')
|
|
431
513
|
return true
|
|
432
514
|
}
|
|
433
|
-
//
|
|
515
|
+
// 有输入桥:接管全部(图片与文档统一落盘 → @路径 纯文本,方案 B 绕开 dsh image 能力检查)
|
|
434
516
|
e.preventDefault()
|
|
435
517
|
e.stopImmediatePropagation()
|
|
436
518
|
dismissDropOverlay()
|
|
@@ -438,10 +520,12 @@ window.__ModuleLoader__.load({
|
|
|
438
520
|
return true
|
|
439
521
|
}
|
|
440
522
|
|
|
441
|
-
// 异步批处理:文档 + 图片统一落盘 → shell.insertReference
|
|
442
|
-
//
|
|
523
|
+
// 异步批处理:文档 + 图片统一落盘 → shell.insertReference chip(label=短文件名显示,clipboardText=@完整路径发送)。
|
|
524
|
+
// 图片不走 dsh 草稿附件链路(方案 B:文本模型被 dsh 拒绝 image 附件),改与文档同链路:落盘 → chip 引用 → 附件条登记。
|
|
525
|
+
// 目录拒绝;扩展名按配置校验(图片恒允许)。
|
|
443
526
|
async function runBatch(images, docs) {
|
|
444
527
|
const shell = bridge.shell
|
|
528
|
+
if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] runBatch start', { images: images.length, docs: docs.length, shell: shell === null ? 'null' : 'ok', mounted: bridge.mounted })
|
|
445
529
|
if (shell === null || typeof shell.insertReference !== 'function') {
|
|
446
530
|
announce(`输入框不可用 [mounted:${bridge.mounted} shell:${shell === null ? 'null' : 'ok'} addImg:${typeof bridge.addImages}]`)
|
|
447
531
|
return
|
|
@@ -481,15 +565,63 @@ window.__ModuleLoader__.load({
|
|
|
481
565
|
const it = pending[i]
|
|
482
566
|
try {
|
|
483
567
|
if (it.isImage) {
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (
|
|
489
|
-
|
|
568
|
+
// 图片:落盘 → @路径 纯文本 + 附件条登记(方案 B:走文件引用链路,
|
|
569
|
+
// 不触发 dsh 的 image 能力检查,文本模型可正常发送到历史)
|
|
570
|
+
const data = it.file
|
|
571
|
+
const name = it.name
|
|
572
|
+
if (data === null || typeof data.size !== 'number' || data.size === 0) throw new Error('size')
|
|
573
|
+
if (data.size > MAX_BYTES) throw new Error('size')
|
|
574
|
+
const buf = await data.arrayBuffer()
|
|
575
|
+
const b64 = bytesToBase64(new Uint8Array(buf))
|
|
576
|
+
if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] before saveFileToHost', { name, b64len: b64.length })
|
|
577
|
+
const res = await saveFileToHost(name, b64, bridge.sessionId)
|
|
578
|
+
if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] saved', res.path)
|
|
579
|
+
const m = formatMention(res.path, false)
|
|
580
|
+
const clipboardText = m !== undefined ? m : '@' + res.path
|
|
581
|
+
// 文件条缩略图:data URL(图片已读入内存(buf),直接用 b64 组装,不重复读文件/不用 FileReader)
|
|
582
|
+
const mime = data.type && data.type !== '' ? data.type : 'application/octet-stream'
|
|
583
|
+
const thumbUrl = 'data:' + mime + ';base64,' + b64
|
|
584
|
+
// 每次插入前重取 snapshot(draftRev 随插入递增,span 取当前末尾)
|
|
585
|
+
const snap = readShellSnapshot(shell)
|
|
586
|
+
// 前导分隔空格:聊天记录对 @引用 的 file-chip 渲染(projectUserText)
|
|
587
|
+
// 要求 @ 前是行首或空白;纯文本 @路径 也遵循同样边界。
|
|
588
|
+
// 草稿末尾紧贴文字时先补一格,否则发送后聊天记录显示裸 @路径。
|
|
589
|
+
if (snap !== void 0 && typeof snap.draft === 'string' && snap.draft !== ''
|
|
590
|
+
&& !isSpace(snap.draft.charCodeAt(snap.draft.length - 1))) {
|
|
591
|
+
const leadSpan = { draftRev: snap.draftRev, start: snap.draft.length, end: snap.draft.length }
|
|
592
|
+
if (typeof shell.insertText === 'function') {
|
|
593
|
+
try { shell.insertText(' ', leadSpan) } catch (err) { /* 忽略:补空格失败不阻塞插入 */ }
|
|
594
|
+
} else if (typeof shell.setDraft === 'function') {
|
|
595
|
+
try { shell.setDraft(snap.draft + ' ') } catch (err) { /* 忽略 */ }
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// 补空格会递增 draftRev,重新取快照后再定插入 span。
|
|
599
|
+
// 关键:insertText 的 span 是 detect 坐标(编辑器实际节点坐标),
|
|
600
|
+
// draft.length 是投影坐标,纯文本机下两者一致;仍优先 shell.caretSpan()
|
|
601
|
+
// 拿 detect 坐标(有光标返回光标处,否则返回文档末尾),与历史行为对齐。
|
|
602
|
+
const snap2 = readShellSnapshot(shell)
|
|
603
|
+
let span
|
|
604
|
+
if (typeof shell.caretSpan === 'function') {
|
|
605
|
+
try {
|
|
606
|
+
const cs = shell.caretSpan()
|
|
607
|
+
span = { draftRev: snap2.draftRev, start: cs.start, end: cs.end }
|
|
608
|
+
} catch (err) {
|
|
609
|
+
span = { draftRev: snap2.draftRev, start: snap2.draft.length, end: snap2.draft.length }
|
|
610
|
+
}
|
|
611
|
+
} else {
|
|
612
|
+
span = { draftRev: snap2.draftRev, start: snap2.draft.length, end: snap2.draft.length }
|
|
613
|
+
}
|
|
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 })
|
|
617
|
+
if (okInsert) {
|
|
618
|
+
inserted += 1
|
|
619
|
+
// 登记到附件条(图片:data URL 缩略图 + 点击打开;发送后随 @引用 清空)
|
|
620
|
+
attachFile({ id: 'fa' + (++faSeq), name, path: res.path, isImage: true, url: thumbUrl, sessionId: bridge.sessionId })
|
|
621
|
+
} else failed += 1
|
|
490
622
|
continue
|
|
491
623
|
}
|
|
492
|
-
// 文档:原样落盘 →
|
|
624
|
+
// 文档:原样落盘 → @路径 纯文本 + 附件条登记
|
|
493
625
|
const data = it.file
|
|
494
626
|
const name = it.name
|
|
495
627
|
if (data === null || typeof data.size !== 'number' || data.size === 0) throw new Error('size')
|
|
@@ -502,7 +634,7 @@ window.__ModuleLoader__.load({
|
|
|
502
634
|
// 每次插入前重取 snapshot(draftRev 随插入递增,span 取当前末尾)
|
|
503
635
|
const snap = readShellSnapshot(shell)
|
|
504
636
|
// 前导分隔空格:聊天记录对 @引用 的 file-chip 渲染(projectUserText)
|
|
505
|
-
// 要求 @
|
|
637
|
+
// 要求 @ 前是行首或空白;纯文本 @路径 也遵循同样边界。
|
|
506
638
|
// 草稿末尾紧贴文字时先补一格,否则发送后聊天记录显示裸 @路径
|
|
507
639
|
//(旧版 insertTextAtCaret 的 padStart 同款保底)。
|
|
508
640
|
if (snap !== void 0 && typeof snap.draft === 'string' && snap.draft !== ''
|
|
@@ -511,30 +643,33 @@ window.__ModuleLoader__.load({
|
|
|
511
643
|
if (typeof shell.insertText === 'function') {
|
|
512
644
|
try { shell.insertText(' ', leadSpan) } catch (err) { /* 忽略:补空格失败不阻塞插入 */ }
|
|
513
645
|
} else if (typeof shell.setDraft === 'function') {
|
|
514
|
-
// 含 chip 的草稿末尾必为空白(chip 自动尾随空格),故此处必为纯文本,拼接安全
|
|
515
646
|
try { shell.setDraft(snap.draft + ' ') } catch (err) { /* 忽略 */ }
|
|
516
647
|
}
|
|
517
648
|
}
|
|
518
|
-
// 补空格会递增 draftRev,重新取快照后再定插入 span
|
|
649
|
+
// 补空格会递增 draftRev,重新取快照后再定插入 span。
|
|
650
|
+
// 与图片分支同理:insertText 的 span 须为 detect 坐标,优先 shell.caretSpan()。
|
|
519
651
|
const snap2 = readShellSnapshot(shell)
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
{
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
span,
|
|
530
|
-
|
|
652
|
+
let span
|
|
653
|
+
if (typeof shell.caretSpan === 'function') {
|
|
654
|
+
try {
|
|
655
|
+
const cs = shell.caretSpan()
|
|
656
|
+
span = { draftRev: snap2.draftRev, start: cs.start, end: cs.end }
|
|
657
|
+
} catch (err) {
|
|
658
|
+
span = { draftRev: snap2.draftRev, start: snap2.draft.length, end: snap2.draft.length }
|
|
659
|
+
}
|
|
660
|
+
} else {
|
|
661
|
+
span = { draftRev: snap2.draftRev, start: snap2.draft.length, end: snap2.draft.length }
|
|
662
|
+
}
|
|
663
|
+
// chip 插入:label=短文件名(显示用),clipboardText=@完整路径(发送用)
|
|
664
|
+
const okInsert = shell.insertReference({ source: 'reference', ref: clipboardText, label: name, appearance: 'file', clipboardText: clipboardText }, span)
|
|
531
665
|
if (okInsert) {
|
|
532
666
|
inserted += 1
|
|
533
|
-
//
|
|
667
|
+
// 登记到附件条(图片:data URL 缩略图;文档:类型图标;发送后随 @引用 清空)
|
|
534
668
|
attachFile({ id: 'fa' + (++faSeq), name, path: res.path, isImage: false, url: null, sessionId: bridge.sessionId })
|
|
535
669
|
} else failed += 1
|
|
536
670
|
} catch (err) {
|
|
537
671
|
failed += 1
|
|
672
|
+
if (typeof console !== 'undefined' && console.error) console.error('[dsh-file-attachment] item failed', { name: it && it.name, isImage: it && it.isImage, error: err instanceof Error ? (err.message + '\n' + (err.stack || '')) : String(err) })
|
|
538
673
|
}
|
|
539
674
|
}
|
|
540
675
|
if (inserted === 0) {
|
|
@@ -580,6 +715,7 @@ window.__ModuleLoader__.load({
|
|
|
580
715
|
react.useEffect(() => {
|
|
581
716
|
if (bridge.noticeText !== null) setText(bridge.noticeText)
|
|
582
717
|
bridge.noticeSub = setText
|
|
718
|
+
if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] FaToast mounted')
|
|
583
719
|
return () => { if (bridge.noticeSub === setText) bridge.noticeSub = null }
|
|
584
720
|
})
|
|
585
721
|
if (text === null || text === '') return null
|
|
@@ -618,15 +754,9 @@ window.__ModuleLoader__.load({
|
|
|
618
754
|
if (conversation !== void 0 && actx !== void 0) {
|
|
619
755
|
shell = conversation.input.for(actx)
|
|
620
756
|
if (shell !== null) {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
if (!shell.addImages(images.map((image) => image.id))) conversation.releaseDraftImages(images)
|
|
625
|
-
return null
|
|
626
|
-
} catch (error) {
|
|
627
|
-
return error instanceof Error ? error.message : String(error)
|
|
628
|
-
}
|
|
629
|
-
}
|
|
757
|
+
// 不再注册 dsh 原生附件(createDrafts + addAttachments 会导致原生附件条重复显示)
|
|
758
|
+
// 插件已接管图片落盘 + chip 引用 + 自定义附件条(FaAttachments)
|
|
759
|
+
addImages = (files) => { return null }
|
|
630
760
|
}
|
|
631
761
|
}
|
|
632
762
|
}
|
|
@@ -638,6 +768,7 @@ window.__ModuleLoader__.load({
|
|
|
638
768
|
bridge.input = input
|
|
639
769
|
bridge.sessionId = sessionId
|
|
640
770
|
bridge.mounted = true
|
|
771
|
+
if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] FaBridge mounted', { sessionId, shell: shell === null ? 'null' : 'ok', addImages: typeof addImages })
|
|
641
772
|
return () => {
|
|
642
773
|
bridge.shell = null
|
|
643
774
|
bridge.addImages = null
|
|
@@ -659,8 +790,10 @@ window.__ModuleLoader__.load({
|
|
|
659
790
|
)
|
|
660
791
|
}
|
|
661
792
|
|
|
662
|
-
//
|
|
663
|
-
|
|
793
|
+
// 输入框内附件条(conversation.input.attachments 槽,shadow 原生 ComposerAttachments):
|
|
794
|
+
// 图片显示真缩略图预览 / 文件仅类型图标;文件名横排显示在缩略图/图标右侧(草稿 @引用 chip
|
|
795
|
+
// 已改为只留图标不显示文字,避免双份名称);可移除(先清草稿 @引用 再登记移除);发送后自动清空。
|
|
796
|
+
function FaAttachments() {
|
|
664
797
|
const [, force] = react.useState(0)
|
|
665
798
|
react.useEffect(() => {
|
|
666
799
|
const fn = () => force(n => n + 1)
|
|
@@ -686,26 +819,25 @@ window.__ModuleLoader__.load({
|
|
|
686
819
|
if (entries.length === 0) return null
|
|
687
820
|
return react.createElement('div', {
|
|
688
821
|
style: {
|
|
689
|
-
display: 'flex', flexWrap: 'wrap', gap: '
|
|
690
|
-
width: '100%',
|
|
691
|
-
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',
|
|
692
824
|
},
|
|
693
825
|
},
|
|
694
826
|
entries.map(e =>
|
|
695
827
|
react.createElement('div', { key: e.id, style: {
|
|
696
|
-
display: 'inline-flex', alignItems: 'center', gap: '
|
|
828
|
+
display: 'inline-flex', alignItems: 'center', gap: '8px',
|
|
697
829
|
background: 'rgba(128,128,128,0.12)', border: '1px solid rgba(128,128,128,0.2)',
|
|
698
|
-
borderRadius: '
|
|
830
|
+
borderRadius: '10px', padding: '4px 8px', fontSize: '12px', lineHeight: '1.4', maxWidth: '260px',
|
|
699
831
|
} },
|
|
700
832
|
e.isImage && e.url
|
|
701
|
-
? 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' } })
|
|
702
834
|
: react.createElement('span', { style: { display: 'inline-flex', width: '20px', height: '20px', flex: 'none', color: 'inherit' } }, fileGlyph(e.name)),
|
|
703
835
|
react.createElement('span', {
|
|
704
836
|
style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 },
|
|
705
837
|
}, e.name),
|
|
706
838
|
react.createElement('span', {
|
|
707
839
|
onClick: () => {
|
|
708
|
-
// 先清草稿中指向该文件的全部 @引用 chip
|
|
840
|
+
// 先清草稿中指向该文件的全部 @引用 chip,再移除附件条条目
|
|
709
841
|
try { removeDraftRefs(e.path) } catch (err) { /* 忽略:引用清理失败不阻塞移除 */ }
|
|
710
842
|
detachFile(e.id)
|
|
711
843
|
},
|
|
@@ -717,93 +849,231 @@ window.__ModuleLoader__.load({
|
|
|
717
849
|
)
|
|
718
850
|
}
|
|
719
851
|
|
|
720
|
-
// conversation.
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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 + 内联样式复刻。
|
|
859
|
+
|
|
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
|
|
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)
|
|
731
948
|
react.useEffect(() => {
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
return () => { acceptListeners.delete(fn) }
|
|
949
|
+
lightboxListener = () => force(n => n + 1)
|
|
950
|
+
return () => { if (lightboxListener !== null) lightboxListener = null }
|
|
735
951
|
}, [])
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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))
|
|
758
1049
|
}
|
|
759
1050
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
multiple: true,
|
|
765
|
-
accept: acceptString,
|
|
766
|
-
style: { display: 'none' },
|
|
767
|
-
onChange: onChange,
|
|
768
|
-
}),
|
|
769
|
-
react.createElement('button', {
|
|
770
|
-
type: 'button',
|
|
771
|
-
title: '上传文件',
|
|
772
|
-
'aria-label': '上传文件',
|
|
773
|
-
disabled: busy,
|
|
774
|
-
onClick: onClick,
|
|
1051
|
+
// 气泡(text + rest)
|
|
1052
|
+
if (showBubble) {
|
|
1053
|
+
rows.push(react.createElement('div', {
|
|
1054
|
+
key: 'bubble',
|
|
775
1055
|
style: {
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
color: 'var(--dsw-alias-label-primary, currentColor)',
|
|
781
|
-
cursor: busy ? 'default' : 'pointer', borderRadius: '999px',
|
|
782
|
-
opacity: busy ? 0.45 : 1, transition: 'background 120ms ease, opacity 120ms ease',
|
|
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',
|
|
783
1060
|
},
|
|
784
|
-
onMouseEnter: (ev) => { if (!busy) ev.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover-solid, rgba(128,128,128,0.16))' },
|
|
785
|
-
onMouseLeave: (ev) => { ev.currentTarget.style.background = 'var(--dsw-specific-selector, rgba(128,128,128,0.10))' },
|
|
786
1061
|
},
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
react.createElement('
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
)
|
|
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),
|
|
799
1074
|
)
|
|
800
1075
|
}
|
|
801
1076
|
|
|
802
|
-
// 扩展名规范化:小写/去前导点/仅 [a-z0-9];非法返回 null
|
|
803
|
-
function normExt(v) {
|
|
804
|
-
const s = String(v == null ? '' : v).trim().toLowerCase().replace(/^\.+/u, '')
|
|
805
|
-
return (s !== '' && /^[a-z0-9]+$/u.test(s)) ? s : null
|
|
806
|
-
}
|
|
807
1077
|
function addExt(setList, value, current) {
|
|
808
1078
|
const s = normExt(value)
|
|
809
1079
|
if (s === null || current.indexOf(s) >= 0) return
|
|
@@ -855,11 +1125,20 @@ window.__ModuleLoader__.load({
|
|
|
855
1125
|
)
|
|
856
1126
|
}
|
|
857
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
|
+
}
|
|
858
1133
|
// 设置页:配置可上传的文档/代码/配置文件扩展名,保存写 ~/.dsh/file-attachment.json
|
|
859
1134
|
function FaSettingsPage() {
|
|
860
1135
|
const [doc, setDoc] = react.useState(DEFAULT_TYPES.doc.slice())
|
|
861
1136
|
const [code, setCode] = react.useState(DEFAULT_TYPES.code.slice())
|
|
862
1137
|
const [config, setConfig] = react.useState(DEFAULT_TYPES.config.slice())
|
|
1138
|
+
const [vlmBaseURL, setVlmBaseURL] = react.useState('')
|
|
1139
|
+
const [vlmApiKey, setVlmApiKey] = react.useState('')
|
|
1140
|
+
const [vlmModel, setVlmModel] = react.useState('')
|
|
1141
|
+
const [vlmThinking, setVlmThinking] = react.useState(false)
|
|
863
1142
|
const [docIn, setDocIn] = react.useState('')
|
|
864
1143
|
const [codeIn, setCodeIn] = react.useState('')
|
|
865
1144
|
const [cfgIn, setCfgIn] = react.useState('')
|
|
@@ -873,6 +1152,10 @@ window.__ModuleLoader__.load({
|
|
|
873
1152
|
setDoc(allowedTypes.doc.slice())
|
|
874
1153
|
setCode(allowedTypes.code.slice())
|
|
875
1154
|
setConfig(allowedTypes.config.slice())
|
|
1155
|
+
setVlmBaseURL(vlmCfg.baseURL)
|
|
1156
|
+
setVlmApiKey(vlmCfg.apiKey)
|
|
1157
|
+
setVlmModel(vlmCfg.model)
|
|
1158
|
+
setVlmThinking(vlmCfg.thinkingType === 'enabled')
|
|
876
1159
|
})
|
|
877
1160
|
return () => { live = false }
|
|
878
1161
|
}, [])
|
|
@@ -885,7 +1168,7 @@ window.__ModuleLoader__.load({
|
|
|
885
1168
|
async function save() {
|
|
886
1169
|
setSaving(true); setSaved(false)
|
|
887
1170
|
try {
|
|
888
|
-
const body = { doc: doc, code: code, config: config }
|
|
1171
|
+
const body = { doc: doc, code: code, config: config, vlm: { baseURL: vlmBaseURL, apiKey: vlmApiKey, model: vlmModel, thinkingType: vlmThinking ? 'enabled' : 'disabled' } }
|
|
889
1172
|
const r = await fetch('/dsh-file-attachment/config', {
|
|
890
1173
|
method: 'POST',
|
|
891
1174
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -908,6 +1191,23 @@ window.__ModuleLoader__.load({
|
|
|
908
1191
|
react.createElement(FaExtGroup, { title: t('doc.title'), list: doc, setList: setDoc, input: docIn, setInput: setDocIn }),
|
|
909
1192
|
react.createElement(FaExtGroup, { title: t('code.title'), list: code, setList: setCode, input: codeIn, setInput: setCodeIn }),
|
|
910
1193
|
react.createElement(FaExtGroup, { title: t('config.title'), list: config, setList: setConfig, input: cfgIn, setInput: setCfgIn }),
|
|
1194
|
+
react.createElement('div', { style: { margin: '16px 0', padding: '12px', borderRadius: '8px', background: 'rgba(128,128,128,0.08)', border: '1px solid rgba(128,128,128,0.15)' } },
|
|
1195
|
+
react.createElement('div', { style: { marginBottom: '8px', fontSize: '13px', fontWeight: '600' } }, t('vlm.title')),
|
|
1196
|
+
react.createElement('div', { style: { marginBottom: '10px', fontSize: '12px', color: 'rgba(128,128,128,0.9)' } }, t('vlm.hint')),
|
|
1197
|
+
react.createElement('div', { style: { display: 'grid', gridTemplateColumns: '110px 1fr', gap: '8px 10px', alignItems: 'center' } },
|
|
1198
|
+
react.createElement('span', { style: { fontSize: '12px' } }, t('vlm.baseURL')),
|
|
1199
|
+
react.createElement('input', { value: vlmBaseURL, onChange: (e) => setVlmBaseURL(e.target.value), placeholder: 'https://api.xiaomimimo.com/v1', style: { padding: '6px 8px', borderRadius: '6px', border: '1px solid rgba(128,128,128,0.3)', background: 'transparent', color: 'inherit', fontSize: '12px' } }),
|
|
1200
|
+
react.createElement('span', { style: { fontSize: '12px' } }, t('vlm.apiKey')),
|
|
1201
|
+
react.createElement('input', { value: vlmApiKey, onChange: (e) => setVlmApiKey(e.target.value), type: 'password', placeholder: 'sk-...', style: { padding: '6px 8px', borderRadius: '6px', border: '1px solid rgba(128,128,128,0.3)', background: 'transparent', color: 'inherit', fontSize: '12px' } }),
|
|
1202
|
+
react.createElement('span', { style: { fontSize: '12px' } }, t('vlm.model')),
|
|
1203
|
+
react.createElement('input', { value: vlmModel, onChange: (e) => setVlmModel(e.target.value), placeholder: 'mimo-v2.5', style: { padding: '6px 8px', borderRadius: '6px', border: '1px solid rgba(128,128,128,0.3)', background: 'transparent', color: 'inherit', fontSize: '12px' } }),
|
|
1204
|
+
react.createElement('span', { style: { fontSize: '12px' } }),
|
|
1205
|
+
react.createElement('label', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '12px' } },
|
|
1206
|
+
react.createElement('input', { type: 'checkbox', checked: vlmThinking, onChange: (e) => setVlmThinking(e.target.checked) }),
|
|
1207
|
+
t('vlm.thinking'),
|
|
1208
|
+
),
|
|
1209
|
+
),
|
|
1210
|
+
),
|
|
911
1211
|
react.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '12px' } },
|
|
912
1212
|
react.createElement('button', {
|
|
913
1213
|
type: 'button', onClick: save, disabled: saving,
|
|
@@ -924,6 +1224,7 @@ window.__ModuleLoader__.load({
|
|
|
924
1224
|
|
|
925
1225
|
function apply(ctx) {
|
|
926
1226
|
ctxRef = ctx
|
|
1227
|
+
if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] apply', { hasSlots: ctx.get('slots') !== undefined, hasConversation: ctx.get('conversation') !== undefined })
|
|
927
1228
|
// 接入即拉持久化配置(幂等):让自定义类型每次会话生效,而非仅打开设置页才加载
|
|
928
1229
|
void loadConfig()
|
|
929
1230
|
// 注册设置页文案(zh/en)
|
|
@@ -934,6 +1235,12 @@ window.__ModuleLoader__.load({
|
|
|
934
1235
|
'doc.title': '文档',
|
|
935
1236
|
'code.title': '代码',
|
|
936
1237
|
'config.title': '配置文件',
|
|
1238
|
+
'vlm.title': '多模态识别参数',
|
|
1239
|
+
'vlm.hint': '当前会话模型不支持多模态时,粘贴图片将调用此 VLM 生成描述回填草稿;支持多模态则模型直接看图,不调用。',
|
|
1240
|
+
'vlm.baseURL': 'Base URL',
|
|
1241
|
+
'vlm.apiKey': 'API Key',
|
|
1242
|
+
'vlm.model': '模型',
|
|
1243
|
+
'vlm.thinking': '启用思考模式(默认禁用)',
|
|
937
1244
|
'save': '保存',
|
|
938
1245
|
'saving': '保存中…',
|
|
939
1246
|
'saved': '已保存',
|
|
@@ -944,6 +1251,12 @@ window.__ModuleLoader__.load({
|
|
|
944
1251
|
'doc.title': 'Documents',
|
|
945
1252
|
'code.title': 'Code',
|
|
946
1253
|
'config.title': 'Config Files',
|
|
1254
|
+
'vlm.title': 'Multimodal VLM',
|
|
1255
|
+
'vlm.hint': 'When the session model is not multimodal, pasted images are described by this VLM and appended to the draft; multimodal models read images directly (no call).',
|
|
1256
|
+
'vlm.baseURL': 'Base URL',
|
|
1257
|
+
'vlm.apiKey': 'API Key',
|
|
1258
|
+
'vlm.model': 'Model',
|
|
1259
|
+
'vlm.thinking': 'Enable thinking mode (disabled by default)',
|
|
947
1260
|
'save': 'Save',
|
|
948
1261
|
'saving': 'Saving…',
|
|
949
1262
|
'saved': 'Saved',
|
|
@@ -965,19 +1278,27 @@ window.__ModuleLoader__.load({
|
|
|
965
1278
|
},
|
|
966
1279
|
FaBridge
|
|
967
1280
|
))
|
|
968
|
-
//
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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
|
|
972
1293
|
))
|
|
973
1294
|
slots.inject('shell.overlay', () => slots.register(
|
|
974
1295
|
{ name: 'shell.overlay', id: 'dsh-file-attachment-toast', order: 100 },
|
|
975
1296
|
FaToast
|
|
976
1297
|
))
|
|
977
|
-
//
|
|
978
|
-
slots.inject('
|
|
979
|
-
{ name: '
|
|
980
|
-
|
|
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
|
|
981
1302
|
))
|
|
982
1303
|
// 设置页:文件附件类型配置(左侧菜单项 + 右侧配置页)
|
|
983
1304
|
slots.inject('settings.section', () => [
|
|
@@ -990,6 +1311,17 @@ window.__ModuleLoader__.load({
|
|
|
990
1311
|
])
|
|
991
1312
|
}
|
|
992
1313
|
bridge.conversation = ctx.get('conversation')
|
|
1314
|
+
// 劫持 dsh 原生📎:让上传入口位于与 dsh 本体一致的位置(hero/composer 两模式同一按钮),
|
|
1315
|
+
// 点击改走本插件文件选择器;MutationObserver + 定时重扫随 ctx.effect 生命周期清理
|
|
1316
|
+
ctx.effect(() => {
|
|
1317
|
+
const h = installNativeAttachHijack()
|
|
1318
|
+
return () => {
|
|
1319
|
+
if (h !== null && h !== void 0) {
|
|
1320
|
+
try { h.mo.disconnect() } catch (err) { /* 忽略 */ }
|
|
1321
|
+
try { clearInterval(h.timer) } catch (err) { /* 忽略 */ }
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}, 'dsh-file-attachment: native attach hijack')
|
|
993
1325
|
// document 级 capture 监听:dragenter/drop/paste 都先于既有 bubble 监听器;随 Fiber 停止
|
|
994
1326
|
ctx.effect(() => {
|
|
995
1327
|
document.addEventListener('dragenter', onDragEnterCap, true)
|
package/lib/index.js
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
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
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
10
11
|
|
|
11
12
|
/** 本包声明依赖的 Host 服务。 */
|
|
12
13
|
export const name = 'dsh-file-attachment'
|
|
13
|
-
export const inject = ['webServer']
|
|
14
|
+
export const inject = ['webServer', 'tools']
|
|
14
15
|
|
|
15
16
|
// 临时目录名:落在会话工作区根下;每个项目都能看到自己引用过的文档
|
|
16
17
|
const ATTACHMENT_DIR = '.dsh-file-attachment'
|
|
@@ -136,27 +137,50 @@ function normalizeTypeList(arr) {
|
|
|
136
137
|
return out
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
|
|
140
|
+
// 多模态识别(VLM)默认参数:用户在「设置→文件附件」可覆盖;空值回退默认,保证开箱可用。
|
|
141
|
+
const VLM_DEFAULTS = {
|
|
142
|
+
baseURL: 'https://api.xiaomimimo.com/v1',
|
|
143
|
+
apiKey: '',
|
|
144
|
+
model: 'mimo-v2.5',
|
|
145
|
+
thinkingType: 'disabled',
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 规范化多模态参数(缺省/空值回退默认,trim 去空白)。 */
|
|
149
|
+
function normalizeVlm(raw) {
|
|
150
|
+
const r = (raw && typeof raw === 'object') ? raw : {}
|
|
151
|
+
const pick = (key) => (typeof r[key] === 'string' && r[key].trim() !== '' ? r[key].trim() : VLM_DEFAULTS[key])
|
|
152
|
+
return {
|
|
153
|
+
baseURL: pick('baseURL'),
|
|
154
|
+
apiKey: pick('apiKey'),
|
|
155
|
+
model: pick('model'),
|
|
156
|
+
thinkingType: pick('thinkingType'),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 读取配置;文件缺失/损坏时回退默认值(保持三类 + 多模态参数齐全)。 */
|
|
140
161
|
async function readConfig() {
|
|
162
|
+
let stored = {}
|
|
141
163
|
try {
|
|
142
164
|
const text = await readFile(CONFIG_PATH, 'utf8')
|
|
143
|
-
|
|
144
|
-
return {
|
|
145
|
-
doc: normalizeTypeList(doc && doc.doc),
|
|
146
|
-
code: normalizeTypeList(doc && doc.code),
|
|
147
|
-
config: normalizeTypeList(doc && doc.config),
|
|
148
|
-
}
|
|
165
|
+
stored = JSON.parse(text) || {}
|
|
149
166
|
} catch (err) {
|
|
150
|
-
|
|
167
|
+
stored = {}
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
doc: normalizeTypeList(stored.doc),
|
|
171
|
+
code: normalizeTypeList(stored.code),
|
|
172
|
+
config: normalizeTypeList(stored.config),
|
|
173
|
+
vlm: normalizeVlm(stored.vlm || stored.mimo),
|
|
151
174
|
}
|
|
152
175
|
}
|
|
153
176
|
|
|
154
|
-
/**
|
|
177
|
+
/** 保存配置(全量覆盖三类 + 多模态参数);返回规范化后的配置。 */
|
|
155
178
|
async function writeConfig(body) {
|
|
156
179
|
const next = {
|
|
157
180
|
doc: normalizeTypeList(body && body.doc),
|
|
158
181
|
code: normalizeTypeList(body && body.code),
|
|
159
182
|
config: normalizeTypeList(body && body.config),
|
|
183
|
+
vlm: normalizeVlm(body && (body.vlm || body.mimo)),
|
|
160
184
|
}
|
|
161
185
|
await writeFile(CONFIG_PATH, JSON.stringify(next, null, 2) + '\n', 'utf8')
|
|
162
186
|
return next
|
|
@@ -195,6 +219,48 @@ async function handleSave(root, body) {
|
|
|
195
219
|
return { path: target, dir, name: safe, size: bytes.length }
|
|
196
220
|
}
|
|
197
221
|
|
|
222
|
+
/**
|
|
223
|
+
* 调 VLM(OpenAI 兼容 chat/completions)识别图片,返回中文描述。
|
|
224
|
+
* @param vlm - 多模态参数 { baseURL, apiKey, model, thinkingType }(来自配置)。
|
|
225
|
+
* @param dataUrl - base64 data URL(形如 data:image/png;base64,xxx)。
|
|
226
|
+
* @returns 图片的简洁中文描述。
|
|
227
|
+
* @throws VLM 请求失败或未返回有效内容时抛错。
|
|
228
|
+
*/
|
|
229
|
+
async function describeImage(vlm, dataUrl, prompt) {
|
|
230
|
+
const url = vlm.baseURL.replace(/\/+$/u, '') + '/chat/completions'
|
|
231
|
+
const userPrompt = (typeof prompt === 'string' && prompt.trim() !== '') ? prompt.trim() : '请描述这张图片。'
|
|
232
|
+
const payload = {
|
|
233
|
+
model: vlm.model,
|
|
234
|
+
thinkingType: vlm.thinkingType,
|
|
235
|
+
messages: [
|
|
236
|
+
{ role: 'system', content: '你是图片描述助手,用简洁中文回答。' },
|
|
237
|
+
{
|
|
238
|
+
role: 'user',
|
|
239
|
+
content: [
|
|
240
|
+
{ type: 'text', text: userPrompt },
|
|
241
|
+
{ type: 'image_url', image_url: { url: dataUrl } },
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
}
|
|
246
|
+
const resp = await fetch(url, {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: {
|
|
249
|
+
'content-type': 'application/json',
|
|
250
|
+
'authorization': 'Bearer ' + vlm.apiKey,
|
|
251
|
+
},
|
|
252
|
+
body: JSON.stringify(payload),
|
|
253
|
+
})
|
|
254
|
+
if (!resp.ok) {
|
|
255
|
+
const text = await resp.text().catch(() => '')
|
|
256
|
+
throw new Error('VLM 请求失败 ' + resp.status + (text !== '' ? ' ' + text.slice(0, 200) : ''))
|
|
257
|
+
}
|
|
258
|
+
const data = await resp.json()
|
|
259
|
+
const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content
|
|
260
|
+
if (typeof content !== 'string' || content === '') throw new Error('VLM 未返回有效内容')
|
|
261
|
+
return content
|
|
262
|
+
}
|
|
263
|
+
|
|
198
264
|
/** 注册 /dsh-file-attachment 前缀路由(save POST)。 */
|
|
199
265
|
function registerRoutes(ctx) {
|
|
200
266
|
const webserver = ctx.get('webServer')
|
|
@@ -242,15 +308,122 @@ function registerRoutes(ctx) {
|
|
|
242
308
|
json(res, { ok: true, value: { saved: true, config: value } })
|
|
243
309
|
return
|
|
244
310
|
}
|
|
245
|
-
|
|
311
|
+
if (req.method === 'POST' && pathname === '/dsh-file-attachment/describe') {
|
|
312
|
+
const body = await readJsonBody(req, MAX_BODY_BYTES)
|
|
313
|
+
if (body === null || typeof body !== 'object') {
|
|
314
|
+
json(res, { ok: false, error: '请求体必须是 JSON(68MB 内)' }, 400)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
const dataUrl = typeof body.dataUrl === 'string' ? body.dataUrl : ''
|
|
318
|
+
if (dataUrl === '') {
|
|
319
|
+
json(res, { ok: false, error: 'dataUrl 必须是非空字符串(base64 data URL)' }, 400)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
const cfg = await readConfig()
|
|
323
|
+
const vlm = cfg.vlm
|
|
324
|
+
if (vlm.apiKey === '') {
|
|
325
|
+
json(res, { ok: false, error: '未配置多模态 API Key(设置→文件附件填写)' }, 400)
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const description = await describeImage(vlm, dataUrl, body.prompt)
|
|
330
|
+
json(res, { ok: true, value: { description } })
|
|
331
|
+
return
|
|
332
|
+
} catch (err) {
|
|
333
|
+
const message = err && err.message ? err.message : '识别失败'
|
|
334
|
+
json(res, { ok: false, error: message }, 502)
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
json(res, { ok: false, error: 'only POST /dsh-file-attachment/save、GET/POST /dsh-file-attachment/config 与 POST /dsh-file-attachment/describe are allowed' }, 405)
|
|
246
339
|
},
|
|
247
340
|
})
|
|
248
341
|
}
|
|
249
342
|
|
|
343
|
+
// 图片扩展名判定(describe_image 工具用)
|
|
344
|
+
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp)$/iu
|
|
345
|
+
|
|
346
|
+
/** 按扩展名推断 MIME(缺省 image/png)。 */
|
|
347
|
+
function mimeFromPath(p) {
|
|
348
|
+
const ext = String(p ?? '').toLowerCase().split('.').pop() || ''
|
|
349
|
+
switch (ext) {
|
|
350
|
+
case 'jpg':
|
|
351
|
+
case 'jpeg': return 'image/jpeg'
|
|
352
|
+
case 'png': return 'image/png'
|
|
353
|
+
case 'webp': return 'image/webp'
|
|
354
|
+
case 'gif': return 'image/gif'
|
|
355
|
+
case 'bmp': return 'image/bmp'
|
|
356
|
+
default: return 'image/png'
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 注册 describe_image 工具:模型看到用户消息里的图片路径引用(@/绝对路径)时调用,
|
|
362
|
+
* 读图片 → 调 VLM 识别 → 识别结果直接作为工具输出返回:
|
|
363
|
+
* UI 展示描述文本,同时该输出即模型可见内容(tool/result 的 content 是 model-facing)。
|
|
364
|
+
*/
|
|
365
|
+
function registerTools(ctx) {
|
|
366
|
+
const tools = (ctx !== undefined && ctx !== null && typeof ctx.tools === 'object' && ctx.tools !== null)
|
|
367
|
+
? ctx.tools
|
|
368
|
+
: (typeof ctx.get === 'function' ? ctx.get('tools') : undefined)
|
|
369
|
+
if (tools === undefined || typeof tools.register !== 'function') {
|
|
370
|
+
console.warn('[dsh-file-attachment] tools 服务不可用,跳过 describe_image 注册')
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
tools.register(defineTool({
|
|
374
|
+
name: 'describe_image',
|
|
375
|
+
description: '当用户消息里出现图片路径引用(形如 @/绝对路径,扩展名 png/jpg/jpeg/gif/webp/bmp)且需要理解图片内容时调用此工具,获取该图片的中文内容描述。path 参数填去掉前导 @ 的绝对路径。prompt 可选,指定要关注的内容(如"识别图中文字"、"描述 UI 布局")。',
|
|
376
|
+
parameters: {
|
|
377
|
+
path: {
|
|
378
|
+
type: 'string',
|
|
379
|
+
required: true,
|
|
380
|
+
description: '图片文件的绝对路径(去掉前导 @)',
|
|
381
|
+
},
|
|
382
|
+
prompt: {
|
|
383
|
+
type: 'string',
|
|
384
|
+
description: '可选。告诉多模态模型要关注什么(如"识别图中的文字"、"描述 UI 布局")。不填则默认"请描述这张图片"。',
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
output: {
|
|
388
|
+
schema: {
|
|
389
|
+
type: 'object',
|
|
390
|
+
additionalProperties: false,
|
|
391
|
+
properties: {
|
|
392
|
+
ok: { type: 'boolean', required: true },
|
|
393
|
+
description: { type: 'string' },
|
|
394
|
+
},
|
|
395
|
+
},
|
|
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
|
+
},
|
|
402
|
+
},
|
|
403
|
+
async execute(args, exec) {
|
|
404
|
+
let path = typeof args.path === 'string' ? args.path.trim() : ''
|
|
405
|
+
if (path.startsWith('@')) path = path.slice(1)
|
|
406
|
+
if (path === '') throw new Error('path 不能为空')
|
|
407
|
+
if (!IMAGE_EXT.test(path)) throw new Error('path 不是受支持的图片(png/jpg/jpeg/gif/webp/bmp)')
|
|
408
|
+
const bytes = await readFile(path)
|
|
409
|
+
if (bytes.length === 0) throw new Error('图片文件为空')
|
|
410
|
+
const dataUrl = 'data:' + mimeFromPath(path) + ';base64,' + bytes.toString('base64')
|
|
411
|
+
const cfg = await readConfig()
|
|
412
|
+
if (cfg.vlm.apiKey === '') throw new Error('未配置多模态 API Key(设置→文件附件填写),无法识别图片')
|
|
413
|
+
const description = await describeImage(cfg.vlm, dataUrl, args.prompt)
|
|
414
|
+
// 识别结果直接作为工具输出返回:UI 直接展示描述文本,
|
|
415
|
+
// 同时该输出就是模型可见内容(tool/result 的 content),无需额外 deferContext 注入。
|
|
416
|
+
return { ok: true, description }
|
|
417
|
+
},
|
|
418
|
+
}))
|
|
419
|
+
console.log('[dsh-file-attachment] describe_image tool registered')
|
|
420
|
+
}
|
|
421
|
+
|
|
250
422
|
/**
|
|
251
|
-
* @param ctx - 宿主上下文(webServer 注入)。
|
|
423
|
+
* @param ctx - 宿主上下文(webServer + tools 注入)。
|
|
252
424
|
*/
|
|
253
425
|
export function apply(ctx, config = {}) {
|
|
426
|
+
registerTools(ctx)
|
|
254
427
|
registerRoutes(ctx)
|
|
255
|
-
console.log('[dsh-file-attachment] host loaded (webServer routes)')
|
|
428
|
+
console.log('[dsh-file-attachment] host loaded (webServer routes + describe_image tool)')
|
|
256
429
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wszhoho/dsh-file-attachment",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "文件附件:拖拽/粘贴/上传文件(支持多文件);图片与文档统一落盘到 .dsh-file-attachment 并以 @绝对路径 引用发送(文本模型可正常使用);输入框内联显示图片缩略图预览与文件条目,聊天区图片同样渲染为可点击放大的缩略图、文件保持芯片样式;非多模态模型下图片自动调用可配置 VLM 识别生成中文描述回填草稿;文档/代码/配置文件可上传类型可在设置页配置;支持 PC 与移动端浏览器",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
7
7
|
"plugin",
|
|
@@ -33,6 +33,9 @@
|
|
|
33
33
|
"engines": {
|
|
34
34
|
"node": ">=18"
|
|
35
35
|
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@deepseek-ai/dsh": ">=0.1.0-rc.1 <0.1.1-0 || >=0.1.1-rc.1 <0.1.2-0 || >=0.1.2-alpha.1 <0.2.0-0"
|
|
38
|
+
},
|
|
36
39
|
"publishConfig": {
|
|
37
40
|
"registry": "https://registry.npmjs.org/"
|
|
38
41
|
},
|