@wszhoho/dsh-file-attachment 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/lib/client.js +274 -126
- package/lib/index.js +181 -13
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ DeepSeek Harness (dsh) web GUI 插件:在会话输入框中**拖入或 Ctrl+V
|
|
|
4
4
|
|
|
5
5
|
图片文件由插件接管后走 dsh web 既有草稿图片流程(`addImages`):不落盘、不进文件条、不缩放;仅当输入框未就绪且为纯图片时,才原样交回原生。
|
|
6
6
|
|
|
7
|
-
另外,输入框工具栏(加号之后)提供了一个**上传按钮(📎 回形针图标)**,点击后弹出系统文件选择框,可多选文档/图片,复用与拖入/粘贴完全一致的落盘 + `@路径` 插入管线。PC
|
|
7
|
+
另外,输入框工具栏(加号之后)提供了一个**上传按钮(📎 回形针图标)**,点击后弹出系统文件选择框,可多选文档/图片,复用与拖入/粘贴完全一致的落盘 + `@路径` 插入管线。PC 与移动端浏览器均适用(移动端走原生文件选择器)。
|
|
8
8
|
|
|
9
9
|
## 界面
|
|
10
10
|
|
|
@@ -14,11 +14,15 @@ DeepSeek Harness (dsh) web GUI 插件:在会话输入框中**拖入或 Ctrl+V
|
|
|
14
14
|
|
|
15
15
|
**上传按钮**:输入框工具栏 `+` 之后的 📎 回形针图标,点击弹出系统文件选择框(可多选文档/图片)。
|
|
16
16
|
|
|
17
|
+
**可上传类型设置**:「设置 → 文件附件」页可按 文档 / 代码 / 配置文件 三类增删扩展名(小写、不带点),图片恒可发送。
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
17
21
|
## 行为一览
|
|
18
22
|
|
|
19
23
|
| 操作 | 行为 |
|
|
20
24
|
| --- | --- |
|
|
21
|
-
| 点击**上传按钮(📎)**,在系统文件选择框中**多选**文档/图片 | 与拖入/粘贴同一管线:文档 → 落盘 + 光标插入全部 `@` 路径;图片 → 走既有草稿图片流程;`accept` 限定图片 +
|
|
25
|
+
| 点击**上传按钮(📎)**,在系统文件选择框中**多选**文档/图片 | 与拖入/粘贴同一管线:文档 → 落盘 + 光标插入全部 `@` 路径;图片 → 走既有草稿图片流程;`accept` 限定图片 + 常见文档扩展名,移动端自动弹出文件选择器 |
|
|
22
26
|
| 拖入/粘贴 **文档/代码/配置文件**(非图片,默认 doc·code·config 三类扩展名,如 docx/xlsx/pdf/md/txt/js/json) | 浏览器读全文 → base64 → 宿主落盘 `<会话工作区>/.dsh-file-attachment/<日期>/<文件名>` → 光标插入 `@绝对路径`,成功不提示、失败简短 toast |
|
|
23
27
|
| 一次拖入/粘贴 **多个文件**(支持多文件) | 批量处理:逐个读取全文 → base64 → 落盘 → 在光标处一次性插入全部 `@` 引用;全部成功不提示,部分失败仅简短提示「N 个已跳过」 |
|
|
24
28
|
| 拖入 **目录** | 拒绝,toast 提示「不支持拖入目录」,不插入引用 |
|
package/lib/client.js
CHANGED
|
@@ -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 }
|
|
79
137
|
}
|
|
80
|
-
//
|
|
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' }
|
|
144
|
+
}
|
|
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
|
|
@@ -146,7 +217,7 @@ window.__ModuleLoader__.load({
|
|
|
146
217
|
const isTarget = (oc) => oc.ref === plain || oc.ref === quoted
|
|
147
218
|
|| (oc.clipboardText !== void 0 && (oc.clipboardText === plain || oc.clipboardText === quoted))
|
|
148
219
|
for (let n = 0; n < 64; n++) {
|
|
149
|
-
const snap = shell
|
|
220
|
+
const snap = readShellSnapshot(shell)
|
|
150
221
|
if (snap === null || snap === void 0 || typeof snap.draft !== 'string') break
|
|
151
222
|
// 发送进行中不动草稿(此时 occurrence 会随发送完成清空)
|
|
152
223
|
if (snap.phase === 'adjudicating' || snap.phase === 'submitting') break
|
|
@@ -300,10 +371,25 @@ window.__ModuleLoader__.load({
|
|
|
300
371
|
return seg
|
|
301
372
|
}
|
|
302
373
|
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
374
|
+
// 输入机快照读取:兼容两代 dsh。
|
|
375
|
+
// 旧版(纯文本机,alpha.2 之前):shell.snapshot 直接是 InputState;
|
|
376
|
+
// 新版(Lexical composer,alpha.2 起):shell.state 是 SnapshotStore,经
|
|
377
|
+
// getSnapshot() 取值(字段不变:draft/draftRev/phase/occurrences)。
|
|
378
|
+
function readShellSnapshot(shell) {
|
|
379
|
+
if (shell === null || shell === void 0) return undefined
|
|
380
|
+
const st = shell.state
|
|
381
|
+
if (st !== null && st !== void 0 && typeof st.getSnapshot === 'function') {
|
|
382
|
+
return st.getSnapshot()
|
|
383
|
+
}
|
|
384
|
+
return shell.snapshot
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 找可写的 composer 输入表面:可见、未禁用、未只读、phase 为 plain;
|
|
388
|
+
// 多个候选时优先文本与桥内草稿一致的那一个(排除审批弹窗里的同名元素)。
|
|
389
|
+
// 双代兼容:旧版 textarea([data-composer-card] textarea);
|
|
390
|
+
// 新版 Lexical contenteditable([data-composer-card] [data-composer-input])。
|
|
391
|
+
function findComposerInput() {
|
|
392
|
+
const list = Array.from(document.querySelectorAll('[data-composer-card] textarea, [data-composer-card] [data-composer-input]'))
|
|
307
393
|
const live = list.filter((ta) => {
|
|
308
394
|
if (ta.disabled || ta.readOnly) return false
|
|
309
395
|
const phase = ta.getAttribute('data-phase')
|
|
@@ -312,9 +398,10 @@ window.__ModuleLoader__.load({
|
|
|
312
398
|
})
|
|
313
399
|
if (live.length === 0) return null
|
|
314
400
|
const draft = bridge.input !== void 0 ? bridge.input.draft : undefined
|
|
401
|
+
const textOf = (el) => (el.tagName === 'TEXTAREA' ? el.value : (el.textContent || ''))
|
|
315
402
|
if (typeof draft === 'string') {
|
|
316
403
|
for (let i = 0; i < live.length; i++) {
|
|
317
|
-
if (live[i]
|
|
404
|
+
if (textOf(live[i]) === draft) return live[i]
|
|
318
405
|
}
|
|
319
406
|
}
|
|
320
407
|
return live[0]
|
|
@@ -369,6 +456,7 @@ window.__ModuleLoader__.load({
|
|
|
369
456
|
return out
|
|
370
457
|
}
|
|
371
458
|
|
|
459
|
+
// File → dataURL(base64 data: 前缀),供 host /describe 的 VLM 识别
|
|
372
460
|
// 触发应用自身 drop 浮层的关闭:应用在 window 上监听 dragend 执行 reset()。
|
|
373
461
|
// 从 OS 拖入时浏览器不派发 dragend,且我们拦截 drop 后应用的 drop 监听收不到事件,浮层会残留。
|
|
374
462
|
function dismissDropOverlay() {
|
|
@@ -391,7 +479,15 @@ window.__ModuleLoader__.load({
|
|
|
391
479
|
const docs = [] // { path, name, isDir, file }
|
|
392
480
|
for (let i = 0; i < files.length; i++) {
|
|
393
481
|
const file = files[i]
|
|
394
|
-
|
|
482
|
+
// File.type 在剪贴板截图场景下可能为空(浏览器未填充 MIME);
|
|
483
|
+
// 回退到 DataTransferItem.type 判定,并创建带正确 MIME 的 File 副本,
|
|
484
|
+
// 确保 createDrafts 的 imageMediaType 校验通过。
|
|
485
|
+
const effType = file.type || ((items && items[i]) ? items[i].type : '') || ''
|
|
486
|
+
if (effType.indexOf('image/') === 0) {
|
|
487
|
+
// file.type 为空时用 items[i].type 补全,生成新 File 对象
|
|
488
|
+
images.push(file.type ? file : new File([file], file.name || 'image.png', { type: effType }))
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
395
491
|
let entry = null
|
|
396
492
|
const item = items ? items[i] : null
|
|
397
493
|
if (item && typeof item.webkitGetAsEntry === 'function') {
|
|
@@ -414,7 +510,7 @@ window.__ModuleLoader__.load({
|
|
|
414
510
|
announce('输入框不可用')
|
|
415
511
|
return true
|
|
416
512
|
}
|
|
417
|
-
//
|
|
513
|
+
// 有输入桥:接管全部(图片与文档统一落盘 → @引用芯片,方案 B 绕开 dsh image 能力检查)
|
|
418
514
|
e.preventDefault()
|
|
419
515
|
e.stopImmediatePropagation()
|
|
420
516
|
dismissDropOverlay()
|
|
@@ -423,14 +519,16 @@ window.__ModuleLoader__.load({
|
|
|
423
519
|
}
|
|
424
520
|
|
|
425
521
|
// 异步批处理:文档 + 图片统一落盘 → shell.insertReference 芯片(不显示长路径,发送还原 @ 路径)。
|
|
426
|
-
//
|
|
522
|
+
// 图片不走 dsh 草稿附件链路(方案 B:文本模型被 dsh 拒绝 image 附件),改与文档同链路:落盘 → @引用 → 文件条登记。
|
|
523
|
+
// 目录拒绝;扩展名按配置校验(图片恒允许)。
|
|
427
524
|
async function runBatch(images, docs) {
|
|
428
525
|
const shell = bridge.shell
|
|
526
|
+
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 })
|
|
429
527
|
if (shell === null || typeof shell.insertReference !== 'function') {
|
|
430
528
|
announce(`输入框不可用 [mounted:${bridge.mounted} shell:${shell === null ? 'null' : 'ok'} addImg:${typeof bridge.addImages}]`)
|
|
431
529
|
return
|
|
432
530
|
}
|
|
433
|
-
const snap0 = shell
|
|
531
|
+
const snap0 = readShellSnapshot(shell)
|
|
434
532
|
const phase = snap0 ? snap0.phase : 'plain'
|
|
435
533
|
if (phase !== 'plain' && phase !== 'command' && phase !== 'claimed') {
|
|
436
534
|
announce('输入框正忙')
|
|
@@ -465,12 +563,69 @@ window.__ModuleLoader__.load({
|
|
|
465
563
|
const it = pending[i]
|
|
466
564
|
try {
|
|
467
565
|
if (it.isImage) {
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
if (
|
|
473
|
-
|
|
566
|
+
// 图片:落盘 → @引用芯片 + 文件条登记(方案 B:走文件引用链路,
|
|
567
|
+
// 不触发 dsh 的 image 能力检查,文本模型可正常发送到历史)
|
|
568
|
+
const data = it.file
|
|
569
|
+
const name = it.name
|
|
570
|
+
if (data === null || typeof data.size !== 'number' || data.size === 0) throw new Error('size')
|
|
571
|
+
if (data.size > MAX_BYTES) throw new Error('size')
|
|
572
|
+
const buf = await data.arrayBuffer()
|
|
573
|
+
const b64 = bytesToBase64(new Uint8Array(buf))
|
|
574
|
+
if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] before saveFileToHost', { name, b64len: b64.length })
|
|
575
|
+
const res = await saveFileToHost(name, b64, bridge.sessionId)
|
|
576
|
+
if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] saved', res.path)
|
|
577
|
+
const m = formatMention(res.path, false)
|
|
578
|
+
const clipboardText = m !== undefined ? m : '@' + res.path
|
|
579
|
+
// 文件条缩略图:data URL(图片已读入内存(buf),直接用 b64 组装,不重复读文件/不用 FileReader)
|
|
580
|
+
const mime = data.type && data.type !== '' ? data.type : 'application/octet-stream'
|
|
581
|
+
const thumbUrl = 'data:' + mime + ';base64,' + b64
|
|
582
|
+
// 每次插入前重取 snapshot(draftRev 随插入递增,span 取当前末尾)
|
|
583
|
+
const snap = readShellSnapshot(shell)
|
|
584
|
+
// 前导分隔空格:聊天记录对 @引用 的 file-chip 渲染(projectUserText)
|
|
585
|
+
// 要求 @ 前是行首或空白,而 insertReference 只在 chip 后补空格。
|
|
586
|
+
// 草稿末尾紧贴文字时先补一格,否则发送后聊天记录显示裸 @路径。
|
|
587
|
+
if (snap !== void 0 && typeof snap.draft === 'string' && snap.draft !== ''
|
|
588
|
+
&& !isSpace(snap.draft.charCodeAt(snap.draft.length - 1))) {
|
|
589
|
+
const leadSpan = { draftRev: snap.draftRev, start: snap.draft.length, end: snap.draft.length }
|
|
590
|
+
if (typeof shell.insertText === 'function') {
|
|
591
|
+
try { shell.insertText(' ', leadSpan) } catch (err) { /* 忽略:补空格失败不阻塞插入 */ }
|
|
592
|
+
} else if (typeof shell.setDraft === 'function') {
|
|
593
|
+
try { shell.setDraft(snap.draft + ' ') } catch (err) { /* 忽略 */ }
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// 补空格会递增 draftRev,重新取快照后再定插入 span。
|
|
597
|
+
// 关键:insertReference 的 span 是 detect 坐标(编辑器实际节点坐标),
|
|
598
|
+
// draft.length 是 clipboard 投影坐标(chip 展开为 @路径 文本),草稿含 chip 时两者不一致
|
|
599
|
+
// 会导致 selectSpan 越界失败(span.end > detectLength → null → ok:false)。
|
|
600
|
+
// 用 shell.caretSpan() 拿 detect 坐标(有光标返回光标处,否则返回文档末尾)。
|
|
601
|
+
const snap2 = readShellSnapshot(shell)
|
|
602
|
+
let span
|
|
603
|
+
if (typeof shell.caretSpan === 'function') {
|
|
604
|
+
try {
|
|
605
|
+
const cs = shell.caretSpan()
|
|
606
|
+
span = { draftRev: snap2.draftRev, start: cs.start, end: cs.end }
|
|
607
|
+
} catch (err) {
|
|
608
|
+
span = { draftRev: snap2.draftRev, start: snap2.draft.length, end: snap2.draft.length }
|
|
609
|
+
}
|
|
610
|
+
} else {
|
|
611
|
+
span = { draftRev: snap2.draftRev, start: snap2.draft.length, end: snap2.draft.length }
|
|
612
|
+
}
|
|
613
|
+
const okInsert = shell.insertReference(
|
|
614
|
+
{
|
|
615
|
+
source: 'reference', // 复用内置已注册的 @引用 source(带 codec.serialize),否则报 no serializer
|
|
616
|
+
ref: clipboardText, // codec.serialize 原样返回 ref 作为模型文本,故放完整 @路径
|
|
617
|
+
label: name,
|
|
618
|
+
appearance: 'file',
|
|
619
|
+
clipboardText: clipboardText,
|
|
620
|
+
},
|
|
621
|
+
span,
|
|
622
|
+
)
|
|
623
|
+
if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] image insertReference result', { ok: okInsert, span, clipboardText, draftRev: snap2.draftRev, draftLen: snap2.draft.length })
|
|
624
|
+
if (okInsert) {
|
|
625
|
+
inserted += 1
|
|
626
|
+
// 登记到文件条(图片:data URL 缩略图 + 点击打开;发送后随 chip 清空)
|
|
627
|
+
attachFile({ id: 'fa' + (++faSeq), name, path: res.path, isImage: true, url: thumbUrl, sessionId: bridge.sessionId })
|
|
628
|
+
} else failed += 1
|
|
474
629
|
continue
|
|
475
630
|
}
|
|
476
631
|
// 文档:原样落盘 → @引用芯片 + 文件条登记
|
|
@@ -484,8 +639,35 @@ window.__ModuleLoader__.load({
|
|
|
484
639
|
const m = formatMention(res.path, false)
|
|
485
640
|
const clipboardText = m !== undefined ? m : '@' + res.path
|
|
486
641
|
// 每次插入前重取 snapshot(draftRev 随插入递增,span 取当前末尾)
|
|
487
|
-
const snap = shell
|
|
488
|
-
|
|
642
|
+
const snap = readShellSnapshot(shell)
|
|
643
|
+
// 前导分隔空格:聊天记录对 @引用 的 file-chip 渲染(projectUserText)
|
|
644
|
+
// 要求 @ 前是行首或空白,而 insertReference 只在 chip 后补空格。
|
|
645
|
+
// 草稿末尾紧贴文字时先补一格,否则发送后聊天记录显示裸 @路径
|
|
646
|
+
//(旧版 insertTextAtCaret 的 padStart 同款保底)。
|
|
647
|
+
if (snap !== void 0 && typeof snap.draft === 'string' && snap.draft !== ''
|
|
648
|
+
&& !isSpace(snap.draft.charCodeAt(snap.draft.length - 1))) {
|
|
649
|
+
const leadSpan = { draftRev: snap.draftRev, start: snap.draft.length, end: snap.draft.length }
|
|
650
|
+
if (typeof shell.insertText === 'function') {
|
|
651
|
+
try { shell.insertText(' ', leadSpan) } catch (err) { /* 忽略:补空格失败不阻塞插入 */ }
|
|
652
|
+
} else if (typeof shell.setDraft === 'function') {
|
|
653
|
+
// 含 chip 的草稿末尾必为空白(chip 自动尾随空格),故此处必为纯文本,拼接安全
|
|
654
|
+
try { shell.setDraft(snap.draft + ' ') } catch (err) { /* 忽略 */ }
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// 补空格会递增 draftRev,重新取快照后再定插入 span。
|
|
658
|
+
// 与图片分支同理:insertReference 的 span 须为 detect 坐标,优先 shell.caretSpan()。
|
|
659
|
+
const snap2 = readShellSnapshot(shell)
|
|
660
|
+
let span
|
|
661
|
+
if (typeof shell.caretSpan === 'function') {
|
|
662
|
+
try {
|
|
663
|
+
const cs = shell.caretSpan()
|
|
664
|
+
span = { draftRev: snap2.draftRev, start: cs.start, end: cs.end }
|
|
665
|
+
} catch (err) {
|
|
666
|
+
span = { draftRev: snap2.draftRev, start: snap2.draft.length, end: snap2.draft.length }
|
|
667
|
+
}
|
|
668
|
+
} else {
|
|
669
|
+
span = { draftRev: snap2.draftRev, start: snap2.draft.length, end: snap2.draft.length }
|
|
670
|
+
}
|
|
489
671
|
const okInsert = shell.insertReference(
|
|
490
672
|
{
|
|
491
673
|
source: 'reference', // 复用内置已注册的 @引用 source(带 codec.serialize),否则报 no serializer
|
|
@@ -498,11 +680,12 @@ window.__ModuleLoader__.load({
|
|
|
498
680
|
)
|
|
499
681
|
if (okInsert) {
|
|
500
682
|
inserted += 1
|
|
501
|
-
//
|
|
683
|
+
// 登记到文件条(图片:data URL 缩略图;文档:类型图标;发送后随 chip 清空)
|
|
502
684
|
attachFile({ id: 'fa' + (++faSeq), name, path: res.path, isImage: false, url: null, sessionId: bridge.sessionId })
|
|
503
685
|
} else failed += 1
|
|
504
686
|
} catch (err) {
|
|
505
687
|
failed += 1
|
|
688
|
+
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) })
|
|
506
689
|
}
|
|
507
690
|
}
|
|
508
691
|
if (inserted === 0) {
|
|
@@ -523,12 +706,12 @@ window.__ModuleLoader__.load({
|
|
|
523
706
|
handleFilesEvent(e, files, dt.items, 'drop')
|
|
524
707
|
}
|
|
525
708
|
|
|
526
|
-
// document 级 paste(capture):仅当粘贴目标就是 composer
|
|
709
|
+
// document 级 paste(capture):仅当粘贴目标就是 composer 输入表面时接管文件;
|
|
527
710
|
// 纯文本(含目录路径字符串)完全原生,不改写。
|
|
528
711
|
function onPaste(e) {
|
|
529
712
|
const t = e.target
|
|
530
|
-
const ta =
|
|
531
|
-
if (ta === null || t !== ta) return
|
|
713
|
+
const ta = findComposerInput()
|
|
714
|
+
if (ta === null || (t !== ta && !(ta.contains && ta.contains(t)))) return
|
|
532
715
|
const cd = e.clipboardData
|
|
533
716
|
if (cd === null || cd === void 0) return
|
|
534
717
|
const files = Array.prototype.slice.call(cd.files)
|
|
@@ -548,6 +731,7 @@ window.__ModuleLoader__.load({
|
|
|
548
731
|
react.useEffect(() => {
|
|
549
732
|
if (bridge.noticeText !== null) setText(bridge.noticeText)
|
|
550
733
|
bridge.noticeSub = setText
|
|
734
|
+
if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] FaToast mounted')
|
|
551
735
|
return () => { if (bridge.noticeSub === setText) bridge.noticeSub = null }
|
|
552
736
|
})
|
|
553
737
|
if (text === null || text === '') return null
|
|
@@ -588,8 +772,8 @@ window.__ModuleLoader__.load({
|
|
|
588
772
|
if (shell !== null) {
|
|
589
773
|
addImages = (files) => {
|
|
590
774
|
try {
|
|
591
|
-
const
|
|
592
|
-
if (!shell.
|
|
775
|
+
const drafts = conversation.createDrafts(sessionId, files)
|
|
776
|
+
if (!shell.addAttachments(drafts.map((draft) => draft.id))) conversation.releaseDraftAttachments(drafts)
|
|
593
777
|
return null
|
|
594
778
|
} catch (error) {
|
|
595
779
|
return error instanceof Error ? error.message : String(error)
|
|
@@ -599,13 +783,14 @@ window.__ModuleLoader__.load({
|
|
|
599
783
|
}
|
|
600
784
|
}
|
|
601
785
|
} catch (err) { shell = null; addImages = null }
|
|
602
|
-
const input = shell
|
|
786
|
+
const input = readShellSnapshot(shell)
|
|
603
787
|
react.useEffect(() => {
|
|
604
788
|
bridge.shell = shell
|
|
605
789
|
bridge.addImages = addImages
|
|
606
790
|
bridge.input = input
|
|
607
791
|
bridge.sessionId = sessionId
|
|
608
792
|
bridge.mounted = true
|
|
793
|
+
if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] FaBridge mounted', { sessionId, shell: shell === null ? 'null' : 'ok', addImages: typeof addImages })
|
|
609
794
|
return () => {
|
|
610
795
|
bridge.shell = null
|
|
611
796
|
bridge.addImages = null
|
|
@@ -635,12 +820,12 @@ window.__ModuleLoader__.load({
|
|
|
635
820
|
attachedListeners.add(fn)
|
|
636
821
|
return () => { attachedListeners.delete(fn) }
|
|
637
822
|
}, [])
|
|
638
|
-
//
|
|
823
|
+
// 发送后清空:轮询输入机快照,occurrences 从有→无 即一次发送完成
|
|
639
824
|
const hadOcc = react.useRef(false)
|
|
640
825
|
react.useEffect(() => {
|
|
641
826
|
const timer = setInterval(() => {
|
|
642
827
|
const shell = bridge.shell
|
|
643
|
-
const snap = shell
|
|
828
|
+
const snap = readShellSnapshot(shell)
|
|
644
829
|
const occ = snap ? snap.occurrences : null
|
|
645
830
|
const has = !!(occ && occ.length > 0)
|
|
646
831
|
const s = bridge.sessionId
|
|
@@ -685,87 +870,6 @@ window.__ModuleLoader__.load({
|
|
|
685
870
|
)
|
|
686
871
|
}
|
|
687
872
|
|
|
688
|
-
// conversation.input.left 槽(操作行左侧动作区,官方扩展点):文件上传按钮。
|
|
689
|
-
// 点击触发隐藏 <input type=file multiple>(PC/移动端原生文件选择器,accept 过滤图片+常见文档),
|
|
690
|
-
// 选中后复用既有 runBatch 管线:文档落盘→@引用插入,图片走 createDraftImages+addImages 草稿机制。
|
|
691
|
-
// 运行状态经 bridge(FaBridge 在 input.dock 槽填充 actions/input/sessionId,apply 填充 conversation)。
|
|
692
|
-
function FaUploadButton() {
|
|
693
|
-
const inputRef = react.useRef(null)
|
|
694
|
-
const busyState = react.useState(false)
|
|
695
|
-
const busy = busyState[0]
|
|
696
|
-
const setBusy = busyState[1]
|
|
697
|
-
// 订阅 accept 变化(设置页保存配置 → 重渲染,accept 属性随之刷新)
|
|
698
|
-
const [, forceAccept] = react.useState(0)
|
|
699
|
-
react.useEffect(() => {
|
|
700
|
-
const fn = () => forceAccept((n) => n + 1)
|
|
701
|
-
acceptListeners.add(fn)
|
|
702
|
-
return () => { acceptListeners.delete(fn) }
|
|
703
|
-
}, [])
|
|
704
|
-
function onClick() {
|
|
705
|
-
const el = inputRef.current
|
|
706
|
-
if (el === null || busy) return
|
|
707
|
-
el.click()
|
|
708
|
-
}
|
|
709
|
-
async function onChange(e) {
|
|
710
|
-
const list = e.target.files
|
|
711
|
-
if (list === null || list.length === 0) return
|
|
712
|
-
const images = []
|
|
713
|
-
const docs = []
|
|
714
|
-
for (let i = 0; i < list.length; i++) {
|
|
715
|
-
const file = list[i]
|
|
716
|
-
if (file.type && file.type.indexOf('image/') === 0) { images.push(file); continue }
|
|
717
|
-
docs.push({ path: null, name: file.name, isDir: false, file })
|
|
718
|
-
}
|
|
719
|
-
setBusy(true)
|
|
720
|
-
try {
|
|
721
|
-
await runBatch(images, docs)
|
|
722
|
-
} finally {
|
|
723
|
-
// 重置 input 值,允许再次选择同名文件
|
|
724
|
-
e.target.value = ''
|
|
725
|
-
setBusy(false)
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
return react.createElement('span', { style: { display: 'inline-flex', alignItems: 'center' } },
|
|
729
|
-
react.createElement('input', {
|
|
730
|
-
ref: inputRef,
|
|
731
|
-
type: 'file',
|
|
732
|
-
multiple: true,
|
|
733
|
-
accept: acceptString,
|
|
734
|
-
style: { display: 'none' },
|
|
735
|
-
onChange: onChange,
|
|
736
|
-
}),
|
|
737
|
-
react.createElement('button', {
|
|
738
|
-
type: 'button',
|
|
739
|
-
title: '上传文件',
|
|
740
|
-
'aria-label': '上传文件',
|
|
741
|
-
disabled: busy,
|
|
742
|
-
onClick: onClick,
|
|
743
|
-
style: {
|
|
744
|
-
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
745
|
-
width: '28px', height: '28px', padding: 0, margin: 0, border: 'none',
|
|
746
|
-
// 与 dsh 输入栏加号按钮同款(InputBar.module.css .add):全圆 + selector 填充,无阴影
|
|
747
|
-
background: 'var(--dsw-specific-selector, rgba(128,128,128,0.10))',
|
|
748
|
-
color: 'var(--dsw-alias-label-primary, currentColor)',
|
|
749
|
-
cursor: busy ? 'default' : 'pointer', borderRadius: '999px',
|
|
750
|
-
opacity: busy ? 0.45 : 1, transition: 'background 120ms ease, opacity 120ms ease',
|
|
751
|
-
},
|
|
752
|
-
onMouseEnter: (ev) => { if (!busy) ev.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover-solid, rgba(128,128,128,0.16))' },
|
|
753
|
-
onMouseLeave: (ev) => { ev.currentTarget.style.background = 'var(--dsw-specific-selector, rgba(128,128,128,0.10))' },
|
|
754
|
-
},
|
|
755
|
-
react.createElement('svg', {
|
|
756
|
-
// 回形针按用户反馈取 12px(比加号 14px 小 2px);描边 2/24×12≈1px
|
|
757
|
-
width: 12, height: 12, viewBox: '0 0 24 24', fill: 'none',
|
|
758
|
-
'aria-hidden': true, style: { display: 'block' },
|
|
759
|
-
},
|
|
760
|
-
react.createElement('path', {
|
|
761
|
-
d: 'm21.44 12.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48',
|
|
762
|
-
stroke: 'currentColor', strokeWidth: 2,
|
|
763
|
-
strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
764
|
-
})
|
|
765
|
-
)
|
|
766
|
-
)
|
|
767
|
-
)
|
|
768
|
-
}
|
|
769
873
|
|
|
770
874
|
// 扩展名规范化:小写/去前导点/仅 [a-z0-9];非法返回 null
|
|
771
875
|
function normExt(v) {
|
|
@@ -828,6 +932,10 @@ window.__ModuleLoader__.load({
|
|
|
828
932
|
const [doc, setDoc] = react.useState(DEFAULT_TYPES.doc.slice())
|
|
829
933
|
const [code, setCode] = react.useState(DEFAULT_TYPES.code.slice())
|
|
830
934
|
const [config, setConfig] = react.useState(DEFAULT_TYPES.config.slice())
|
|
935
|
+
const [vlmBaseURL, setVlmBaseURL] = react.useState('')
|
|
936
|
+
const [vlmApiKey, setVlmApiKey] = react.useState('')
|
|
937
|
+
const [vlmModel, setVlmModel] = react.useState('')
|
|
938
|
+
const [vlmThinking, setVlmThinking] = react.useState(false)
|
|
831
939
|
const [docIn, setDocIn] = react.useState('')
|
|
832
940
|
const [codeIn, setCodeIn] = react.useState('')
|
|
833
941
|
const [cfgIn, setCfgIn] = react.useState('')
|
|
@@ -841,6 +949,10 @@ window.__ModuleLoader__.load({
|
|
|
841
949
|
setDoc(allowedTypes.doc.slice())
|
|
842
950
|
setCode(allowedTypes.code.slice())
|
|
843
951
|
setConfig(allowedTypes.config.slice())
|
|
952
|
+
setVlmBaseURL(vlmCfg.baseURL)
|
|
953
|
+
setVlmApiKey(vlmCfg.apiKey)
|
|
954
|
+
setVlmModel(vlmCfg.model)
|
|
955
|
+
setVlmThinking(vlmCfg.thinkingType === 'enabled')
|
|
844
956
|
})
|
|
845
957
|
return () => { live = false }
|
|
846
958
|
}, [])
|
|
@@ -853,7 +965,7 @@ window.__ModuleLoader__.load({
|
|
|
853
965
|
async function save() {
|
|
854
966
|
setSaving(true); setSaved(false)
|
|
855
967
|
try {
|
|
856
|
-
const body = { doc: doc, code: code, config: config }
|
|
968
|
+
const body = { doc: doc, code: code, config: config, vlm: { baseURL: vlmBaseURL, apiKey: vlmApiKey, model: vlmModel, thinkingType: vlmThinking ? 'enabled' : 'disabled' } }
|
|
857
969
|
const r = await fetch('/dsh-file-attachment/config', {
|
|
858
970
|
method: 'POST',
|
|
859
971
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -876,6 +988,23 @@ window.__ModuleLoader__.load({
|
|
|
876
988
|
react.createElement(FaExtGroup, { title: t('doc.title'), list: doc, setList: setDoc, input: docIn, setInput: setDocIn }),
|
|
877
989
|
react.createElement(FaExtGroup, { title: t('code.title'), list: code, setList: setCode, input: codeIn, setInput: setCodeIn }),
|
|
878
990
|
react.createElement(FaExtGroup, { title: t('config.title'), list: config, setList: setConfig, input: cfgIn, setInput: setCfgIn }),
|
|
991
|
+
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)' } },
|
|
992
|
+
react.createElement('div', { style: { marginBottom: '8px', fontSize: '13px', fontWeight: '600' } }, t('vlm.title')),
|
|
993
|
+
react.createElement('div', { style: { marginBottom: '10px', fontSize: '12px', color: 'rgba(128,128,128,0.9)' } }, t('vlm.hint')),
|
|
994
|
+
react.createElement('div', { style: { display: 'grid', gridTemplateColumns: '110px 1fr', gap: '8px 10px', alignItems: 'center' } },
|
|
995
|
+
react.createElement('span', { style: { fontSize: '12px' } }, t('vlm.baseURL')),
|
|
996
|
+
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' } }),
|
|
997
|
+
react.createElement('span', { style: { fontSize: '12px' } }, t('vlm.apiKey')),
|
|
998
|
+
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' } }),
|
|
999
|
+
react.createElement('span', { style: { fontSize: '12px' } }, t('vlm.model')),
|
|
1000
|
+
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' } }),
|
|
1001
|
+
react.createElement('span', { style: { fontSize: '12px' } }),
|
|
1002
|
+
react.createElement('label', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '12px' } },
|
|
1003
|
+
react.createElement('input', { type: 'checkbox', checked: vlmThinking, onChange: (e) => setVlmThinking(e.target.checked) }),
|
|
1004
|
+
t('vlm.thinking'),
|
|
1005
|
+
),
|
|
1006
|
+
),
|
|
1007
|
+
),
|
|
879
1008
|
react.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '12px' } },
|
|
880
1009
|
react.createElement('button', {
|
|
881
1010
|
type: 'button', onClick: save, disabled: saving,
|
|
@@ -892,6 +1021,7 @@ window.__ModuleLoader__.load({
|
|
|
892
1021
|
|
|
893
1022
|
function apply(ctx) {
|
|
894
1023
|
ctxRef = ctx
|
|
1024
|
+
if (typeof console !== 'undefined' && console.log) console.log('[dsh-file-attachment] apply', { hasSlots: ctx.get('slots') !== undefined, hasConversation: ctx.get('conversation') !== undefined })
|
|
895
1025
|
// 接入即拉持久化配置(幂等):让自定义类型每次会话生效,而非仅打开设置页才加载
|
|
896
1026
|
void loadConfig()
|
|
897
1027
|
// 注册设置页文案(zh/en)
|
|
@@ -902,6 +1032,12 @@ window.__ModuleLoader__.load({
|
|
|
902
1032
|
'doc.title': '文档',
|
|
903
1033
|
'code.title': '代码',
|
|
904
1034
|
'config.title': '配置文件',
|
|
1035
|
+
'vlm.title': '多模态识别参数',
|
|
1036
|
+
'vlm.hint': '当前会话模型不支持多模态时,粘贴图片将调用此 VLM 生成描述回填草稿;支持多模态则模型直接看图,不调用。',
|
|
1037
|
+
'vlm.baseURL': 'Base URL',
|
|
1038
|
+
'vlm.apiKey': 'API Key',
|
|
1039
|
+
'vlm.model': '模型',
|
|
1040
|
+
'vlm.thinking': '启用思考模式(默认禁用)',
|
|
905
1041
|
'save': '保存',
|
|
906
1042
|
'saving': '保存中…',
|
|
907
1043
|
'saved': '已保存',
|
|
@@ -912,6 +1048,12 @@ window.__ModuleLoader__.load({
|
|
|
912
1048
|
'doc.title': 'Documents',
|
|
913
1049
|
'code.title': 'Code',
|
|
914
1050
|
'config.title': 'Config Files',
|
|
1051
|
+
'vlm.title': 'Multimodal VLM',
|
|
1052
|
+
'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).',
|
|
1053
|
+
'vlm.baseURL': 'Base URL',
|
|
1054
|
+
'vlm.apiKey': 'API Key',
|
|
1055
|
+
'vlm.model': 'Model',
|
|
1056
|
+
'vlm.thinking': 'Enable thinking mode (disabled by default)',
|
|
915
1057
|
'save': 'Save',
|
|
916
1058
|
'saving': 'Saving…',
|
|
917
1059
|
'saved': 'Saved',
|
|
@@ -942,11 +1084,6 @@ window.__ModuleLoader__.load({
|
|
|
942
1084
|
{ name: 'shell.overlay', id: 'dsh-file-attachment-toast', order: 100 },
|
|
943
1085
|
FaToast
|
|
944
1086
|
))
|
|
945
|
-
// 操作行左侧动作区(官方扩展点):文件上传按钮,复用既有 runBatch 管线
|
|
946
|
-
slots.inject('conversation.input.left', () => slots.register(
|
|
947
|
-
{ name: 'conversation.input.left', id: 'dsh-file-attachment-upload', order: 10 },
|
|
948
|
-
FaUploadButton
|
|
949
|
-
))
|
|
950
1087
|
// 设置页:文件附件类型配置(左侧菜单项 + 右侧配置页)
|
|
951
1088
|
slots.inject('settings.section', () => [
|
|
952
1089
|
slots.register({
|
|
@@ -958,6 +1095,17 @@ window.__ModuleLoader__.load({
|
|
|
958
1095
|
])
|
|
959
1096
|
}
|
|
960
1097
|
bridge.conversation = ctx.get('conversation')
|
|
1098
|
+
// 劫持 dsh 原生📎:让上传入口位于与 dsh 本体一致的位置(hero/composer 两模式同一按钮),
|
|
1099
|
+
// 点击改走本插件文件选择器;MutationObserver + 定时重扫随 ctx.effect 生命周期清理
|
|
1100
|
+
ctx.effect(() => {
|
|
1101
|
+
const h = installNativeAttachHijack()
|
|
1102
|
+
return () => {
|
|
1103
|
+
if (h !== null && h !== void 0) {
|
|
1104
|
+
try { h.mo.disconnect() } catch (err) { /* 忽略 */ }
|
|
1105
|
+
try { clearInterval(h.timer) } catch (err) { /* 忽略 */ }
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}, 'dsh-file-attachment: native attach hijack')
|
|
961
1109
|
// document 级 capture 监听:dragenter/drop/paste 都先于既有 bubble 监听器;随 Fiber 停止
|
|
962
1110
|
ctx.effect(() => {
|
|
963
1111
|
document.addEventListener('dragenter', onDragEnterCap, true)
|
package/lib/index.js
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
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'
|
|
11
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
10
12
|
|
|
11
13
|
/** 本包声明依赖的 Host 服务。 */
|
|
12
14
|
export const name = 'dsh-file-attachment'
|
|
13
|
-
export const inject = ['webServer']
|
|
15
|
+
export const inject = ['webServer', 'tools']
|
|
14
16
|
|
|
15
17
|
// 临时目录名:落在会话工作区根下;每个项目都能看到自己引用过的文档
|
|
16
18
|
const ATTACHMENT_DIR = '.dsh-file-attachment'
|
|
@@ -136,27 +138,50 @@ function normalizeTypeList(arr) {
|
|
|
136
138
|
return out
|
|
137
139
|
}
|
|
138
140
|
|
|
139
|
-
|
|
141
|
+
// 多模态识别(VLM)默认参数:用户在「设置→文件附件」可覆盖;空值回退默认,保证开箱可用。
|
|
142
|
+
const VLM_DEFAULTS = {
|
|
143
|
+
baseURL: 'https://api.xiaomimimo.com/v1',
|
|
144
|
+
apiKey: '',
|
|
145
|
+
model: 'mimo-v2.5',
|
|
146
|
+
thinkingType: 'disabled',
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 规范化多模态参数(缺省/空值回退默认,trim 去空白)。 */
|
|
150
|
+
function normalizeVlm(raw) {
|
|
151
|
+
const r = (raw && typeof raw === 'object') ? raw : {}
|
|
152
|
+
const pick = (key) => (typeof r[key] === 'string' && r[key].trim() !== '' ? r[key].trim() : VLM_DEFAULTS[key])
|
|
153
|
+
return {
|
|
154
|
+
baseURL: pick('baseURL'),
|
|
155
|
+
apiKey: pick('apiKey'),
|
|
156
|
+
model: pick('model'),
|
|
157
|
+
thinkingType: pick('thinkingType'),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 读取配置;文件缺失/损坏时回退默认值(保持三类 + 多模态参数齐全)。 */
|
|
140
162
|
async function readConfig() {
|
|
163
|
+
let stored = {}
|
|
141
164
|
try {
|
|
142
165
|
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
|
-
}
|
|
166
|
+
stored = JSON.parse(text) || {}
|
|
149
167
|
} catch (err) {
|
|
150
|
-
|
|
168
|
+
stored = {}
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
doc: normalizeTypeList(stored.doc),
|
|
172
|
+
code: normalizeTypeList(stored.code),
|
|
173
|
+
config: normalizeTypeList(stored.config),
|
|
174
|
+
vlm: normalizeVlm(stored.vlm || stored.mimo),
|
|
151
175
|
}
|
|
152
176
|
}
|
|
153
177
|
|
|
154
|
-
/**
|
|
178
|
+
/** 保存配置(全量覆盖三类 + 多模态参数);返回规范化后的配置。 */
|
|
155
179
|
async function writeConfig(body) {
|
|
156
180
|
const next = {
|
|
157
181
|
doc: normalizeTypeList(body && body.doc),
|
|
158
182
|
code: normalizeTypeList(body && body.code),
|
|
159
183
|
config: normalizeTypeList(body && body.config),
|
|
184
|
+
vlm: normalizeVlm(body && (body.vlm || body.mimo)),
|
|
160
185
|
}
|
|
161
186
|
await writeFile(CONFIG_PATH, JSON.stringify(next, null, 2) + '\n', 'utf8')
|
|
162
187
|
return next
|
|
@@ -195,6 +220,47 @@ async function handleSave(root, body) {
|
|
|
195
220
|
return { path: target, dir, name: safe, size: bytes.length }
|
|
196
221
|
}
|
|
197
222
|
|
|
223
|
+
/**
|
|
224
|
+
* 调 VLM(OpenAI 兼容 chat/completions)识别图片,返回中文描述。
|
|
225
|
+
* @param vlm - 多模态参数 { baseURL, apiKey, model, thinkingType }(来自配置)。
|
|
226
|
+
* @param dataUrl - base64 data URL(形如 data:image/png;base64,xxx)。
|
|
227
|
+
* @returns 图片的简洁中文描述。
|
|
228
|
+
* @throws VLM 请求失败或未返回有效内容时抛错。
|
|
229
|
+
*/
|
|
230
|
+
async function describeImage(vlm, dataUrl) {
|
|
231
|
+
const url = vlm.baseURL.replace(/\/+$/u, '') + '/chat/completions'
|
|
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: '请描述这张图片。' },
|
|
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,117 @@ 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)
|
|
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 识别 → 描述仅注入模型上下文(deferContext,不进 UI/session 历史),
|
|
363
|
+
* UI 只渲染简短确认。实现「图片进历史后自动识别、结果不发给用户」。
|
|
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 参数填去掉前导 @ 的绝对路径。',
|
|
376
|
+
parameters: {
|
|
377
|
+
path: {
|
|
378
|
+
type: 'string',
|
|
379
|
+
required: true,
|
|
380
|
+
description: '图片文件的绝对路径(去掉前导 @)',
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
output: {
|
|
384
|
+
schema: {
|
|
385
|
+
type: 'object',
|
|
386
|
+
additionalProperties: false,
|
|
387
|
+
properties: {
|
|
388
|
+
ok: { type: 'boolean', required: true },
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
render: (_args, value) => [
|
|
392
|
+
{ type: 'text', text: (value && value.ok === true) ? '(图片已识别,描述已注入上下文)' : '(图片识别失败)' },
|
|
393
|
+
],
|
|
394
|
+
},
|
|
395
|
+
async execute(args, exec) {
|
|
396
|
+
let path = typeof args.path === 'string' ? args.path.trim() : ''
|
|
397
|
+
if (path.startsWith('@')) path = path.slice(1)
|
|
398
|
+
if (path === '') throw new Error('path 不能为空')
|
|
399
|
+
if (!IMAGE_EXT.test(path)) throw new Error('path 不是受支持的图片(png/jpg/jpeg/gif/webp/bmp)')
|
|
400
|
+
const bytes = await readFile(path)
|
|
401
|
+
if (bytes.length === 0) throw new Error('图片文件为空')
|
|
402
|
+
const dataUrl = 'data:' + mimeFromPath(path) + ';base64,' + bytes.toString('base64')
|
|
403
|
+
const cfg = await readConfig()
|
|
404
|
+
if (cfg.vlm.apiKey === '') throw new Error('未配置多模态 API Key(设置→文件附件填写),无法识别图片')
|
|
405
|
+
const description = await describeImage(cfg.vlm, dataUrl)
|
|
406
|
+
// 描述仅注入模型上下文(不进 session 历史 / UI),实现「结果不发给用户」
|
|
407
|
+
exec.deferContext(createUserMessage({
|
|
408
|
+
content: [{ type: 'text', text: '图片 ' + path + ' 的内容描述:' + description }],
|
|
409
|
+
source: { kind: 'plugin', plugin: 'dsh-file-attachment' },
|
|
410
|
+
}))
|
|
411
|
+
return { ok: true }
|
|
412
|
+
},
|
|
413
|
+
}))
|
|
414
|
+
console.log('[dsh-file-attachment] describe_image tool registered')
|
|
415
|
+
}
|
|
416
|
+
|
|
250
417
|
/**
|
|
251
|
-
* @param ctx - 宿主上下文(webServer 注入)。
|
|
418
|
+
* @param ctx - 宿主上下文(webServer + tools 注入)。
|
|
252
419
|
*/
|
|
253
420
|
export function apply(ctx, config = {}) {
|
|
421
|
+
registerTools(ctx)
|
|
254
422
|
registerRoutes(ctx)
|
|
255
|
-
console.log('[dsh-file-attachment] host loaded (webServer routes)')
|
|
423
|
+
console.log('[dsh-file-attachment] host loaded (webServer routes + describe_image tool)')
|
|
256
424
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wszhoho/dsh-file-attachment",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "文件附件:输入框上传按钮 + 拖拽/粘贴文件(支持多文件);图片走 dsh 原生图片草稿机制(addImages,不缩放/不落盘),文档落盘到 .dsh-file-attachment 并以芯片 @短名 引用(发送时还原
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "文件附件:输入框上传按钮 + 拖拽/粘贴文件(支持多文件);图片走 dsh 原生图片草稿机制(addImages,不缩放/不落盘),文档落盘到 .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
|
},
|