@wszhoho/dsh-file-attachment 0.2.1 → 0.3.3
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 +20 -6
- package/lib/client.js +574 -95
- package/lib/index.js +70 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,16 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
DeepSeek Harness (dsh) web GUI 插件:在会话输入框中**拖入或 Ctrl+V 粘贴文档(支持多文件,一次可拖入/粘贴多个文件)**时,浏览器端批量读取全文,保存到**当前会话工作区根目录下的 `.dsh-file-attachment/`**,并在输入框光标处插入 `@<保存副本绝对路径>` 引用。agent 读取 `@路径` 时直接读到落盘副本,不再依赖原始文件位置。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
图片文件由插件接管后走 dsh web 既有草稿图片流程(`addImages`):不落盘、不进文件条、不缩放;仅当输入框未就绪且为纯图片时,才原样交回原生。
|
|
6
|
+
|
|
7
|
+
另外,输入框工具栏(加号之后)提供了一个**上传按钮(↑ 向上箭头图标)**,点击后弹出系统文件选择框,可多选文档/图片,复用与拖入/粘贴完全一致的落盘 + `@路径` 插入管线。PC 与移动端浏览器均适用(移动端走原生文件/拍照选择器)。
|
|
8
|
+
|
|
9
|
+
## 界面
|
|
10
|
+
|
|
11
|
+
**上传按钮**:输入框工具栏 `+` 之后的 `↑` 图标,点击弹出系统文件选择框(可多选文档/图片)。
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
**待上传状态**:文档落盘后,输入框上方 dock 列出待发送附件(点 `×` 移除),输入框内以 `@路径` 引用 chip 形式内联展示。
|
|
16
|
+
|
|
17
|
+

|
|
6
18
|
|
|
7
19
|
## 行为一览
|
|
8
20
|
|
|
9
21
|
| 操作 | 行为 |
|
|
10
22
|
| --- | --- |
|
|
11
|
-
|
|
|
12
|
-
|
|
|
23
|
+
| 点击**上传按钮(↑)**,在系统文件选择框中**多选**文档/图片 | 与拖入/粘贴同一管线:文档 → 落盘 + 光标插入全部 `@` 路径;图片 → 走既有草稿图片流程;`accept` 限定图片 + 常见文档扩展名,移动端自动弹出文件/拍照选择器 |
|
|
24
|
+
| 拖入/粘贴 **文档/代码/配置文件**(非图片,默认 doc·code·config 三类扩展名,如 docx/xlsx/pdf/md/txt/js/json) | 浏览器读全文 → base64 → 宿主落盘 `<会话工作区>/.dsh-file-attachment/<时间戳>-<文件名>` → 光标插入 `@绝对路径`,成功不提示、失败简短 toast |
|
|
25
|
+
| 一次拖入/粘贴 **多个文件**(支持多文件) | 批量处理:逐个读取全文 → base64 → 落盘 → 在光标处一次性插入全部 `@` 引用;全部成功不提示,部分失败仅简短提示「N 个已跳过」 |
|
|
13
26
|
| 拖入 **目录** | 拒绝,toast 提示「不支持拖入目录」,不插入引用 |
|
|
14
|
-
| 拖入/粘贴 **图片** |
|
|
27
|
+
| 拖入/粘贴 **图片** | 接管后走 dsh 既有草稿图片流程(`addImages`):不落盘、不进文件条、不缩放;输入框未就绪且纯图片时原样交原生 |
|
|
15
28
|
| 粘贴 **纯文本**(含从地址栏复制的目录路径字符串) | 不改写,完全原生行为 |
|
|
16
29
|
| 重复拖入同名文件(同一秒内) | 追加 `-1`、`-2` 序号,绝不覆盖 |
|
|
17
30
|
| 单文件 > 50MB | 跳过并提示 |
|
|
@@ -71,13 +84,14 @@ packages/dsh-file-attachment/
|
|
|
71
84
|
```
|
|
72
85
|
|
|
73
86
|
- **Host 半**:`webServer.register` 前缀路由 `/dsh-file-attachment`,POST `/dsh-file-attachment/save` 接收 `{name, data(base64), sessionId}`,base64 解码后用 `node:fs/promises` 写盘到会话工作区 `.dsh-file-attachment/`,返回 `{ok, value:{path,dir,name,size}}`。
|
|
74
|
-
- **Client 半**:`conversation.
|
|
87
|
+
- **Client 半**:`conversation.input.dock`(list 槽,同步桥状态,不渲染文本;经会话作用域取输入机,hero 首轮空白态也挂载)+ `shell.overlay`(list 槽,toast 通知展示)+ `conversation.input.left`(list 槽,**上传按钮**,渲染为左动作簇第 3 项),document 级 capture 监听先于应用 bubble 监听执行。
|
|
88
|
+
- **上传按钮**:`FaUploadButton` 组件注入 `conversation.input.left`(`order:10`),内部是隐藏 `<input type="file" multiple accept>`(`accept` 限定 `image/*` + 常见文档扩展名),点击触发原生选择框;`onChange` 按 `file.type` 是否为 `image/` 分派到 `runBatch`(文档落盘插 `@`、图片走草稿图片流程),完成后清空 `input.value` 以便重复选择同一文件。图标为内联 SVG 向上箭头(↑,`stroke=currentColor`,随主题变色)。
|
|
75
89
|
- **通知**:`shell.overlay` frame-wide 浮动 toast(不参与文档流,不破坏布局),4 秒自动消失。
|
|
76
90
|
- **浮层**:拖入任何文件时 capture 阶段拦截应用自带「拖入图片…」DropOverlay(文案面向图片,对文档是误导)。
|
|
77
91
|
|
|
78
92
|
## 开发说明
|
|
79
93
|
|
|
80
|
-
- `lib/*.js
|
|
94
|
+
- `lib/*.js`、`package.json`、`cordis.patch.yml`、`README.md` 当前均为 **UTF-8 无 BOM**(以仓库实测为准);编辑时保持原编码,不引入 BOM。
|
|
81
95
|
- Host 半用 `webServer.register({ kind: 'prefix', path: '/dsh-file-attachment', handler })` 收文件(参考 `dsh-upload-file` 同架构的 `/dsh-upload` 路由),纯 ESM 无构建,不依赖装饰器/远程反射。
|
|
82
96
|
- Client 半保存用 `fetch('/dsh-file-attachment/save', { method: 'POST', body: JSON.stringify({ name, data, sessionId }) })`,信封为 `{ ok, value | error }`。
|
|
83
97
|
- 项目根 = 会话工作区(`session.header.cwd` → `sandboxPolicy.workspaceRoot` → `process.cwd()` 兜底)。
|
package/lib/client.js
CHANGED
|
@@ -21,12 +21,14 @@ window.__ModuleLoader__.load({
|
|
|
21
21
|
const inject = [
|
|
22
22
|
"slots",
|
|
23
23
|
"conversation",
|
|
24
|
-
"sessions"
|
|
24
|
+
"sessions",
|
|
25
|
+
"locale"
|
|
25
26
|
]
|
|
26
27
|
|
|
27
28
|
// ---- 模块级状态:document 监听器 / dock 槽(同步桥)/ overlay 槽(toast)共享 ----
|
|
28
29
|
let ctxRef = null // apply 时记下 ctx,供远程调用与降级判断
|
|
29
30
|
const bridge = {
|
|
31
|
+
shell: null, // 输入机 shell(insertReference/snapshot),来自当前会话
|
|
30
32
|
actions: null, // InputActions(setDraft/addImages...),来自当前会话
|
|
31
33
|
input: undefined, // 最新 InputState(draft/phase...),随渲染刷新
|
|
32
34
|
conversation: undefined, // conversation 服务:登记草稿图片
|
|
@@ -38,6 +40,72 @@ window.__ModuleLoader__.load({
|
|
|
38
40
|
let noticeTimer = null
|
|
39
41
|
const NOTICE_MS = 4000
|
|
40
42
|
const MAX_BYTES = 50 * 1024 * 1024
|
|
43
|
+
const MAX_DIM = 640 // 图片缩放:最长边上限(GIF 例外,原样保动画)
|
|
44
|
+
// 可上传类型(三类:文档/代码/配置文件);图片恒允许(image/*)。
|
|
45
|
+
// 首启为默认值,apply/设置页 fetch 持久化配置(~/.dsh/file-attachment.json)后覆盖。
|
|
46
|
+
const DEFAULT_TYPES = {
|
|
47
|
+
doc: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv'],
|
|
48
|
+
code: ['js', 'mjs', 'cjs', 'ts', 'jsx', 'tsx', 'py', 'java', 'c', 'h', 'cpp', 'cc', 'cs', 'go', 'rs', 'rb', 'php', 'swift', 'kt', 'sh', 'bash', 'ps1', 'sql', 'r', 'lua', 'pl', 'log'],
|
|
49
|
+
config: ['json', 'yml', 'yaml', 'toml', 'ini', 'conf', 'cfg', 'env', 'properties', 'xml', 'html', 'css'],
|
|
50
|
+
}
|
|
51
|
+
const allowedTypes = { doc: DEFAULT_TYPES.doc.slice(), code: DEFAULT_TYPES.code.slice(), config: DEFAULT_TYPES.config.slice() }
|
|
52
|
+
let allowedExts = null // Set<ext>,rebuildAllowed 填充;上传校验用
|
|
53
|
+
let acceptString = 'image/*' // 上传按钮 <input accept>,buildAccept 生成
|
|
54
|
+
const acceptListeners = new Set() // accept 变化订阅(设置页保存 → 上传按钮重渲染)
|
|
55
|
+
// 规范化扩展名列表(小写/去点/去重/仅 [a-z0-9]),与 host 侧同逻辑
|
|
56
|
+
function normalizeList(arr) {
|
|
57
|
+
const out = []
|
|
58
|
+
const seen = new Set()
|
|
59
|
+
if (!Array.isArray(arr)) return out
|
|
60
|
+
for (const raw of arr) {
|
|
61
|
+
const s = String(raw == null ? '' : raw).trim().toLowerCase().replace(/^\./u, '')
|
|
62
|
+
if (s === '' || !/^[a-z0-9]+$/u.test(s) || seen.has(s)) continue
|
|
63
|
+
seen.add(s)
|
|
64
|
+
out.push(s)
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
function rebuildAllowed() {
|
|
69
|
+
const set = new Set()
|
|
70
|
+
for (const key of ['doc', 'code', 'config']) for (const ext of allowedTypes[key]) set.add(ext)
|
|
71
|
+
allowedExts = set
|
|
72
|
+
}
|
|
73
|
+
function buildAccept() {
|
|
74
|
+
const parts = ['image/*']
|
|
75
|
+
const set = new Set()
|
|
76
|
+
for (const key of ['doc', 'code', 'config']) for (const ext of allowedTypes[key]) set.add(ext)
|
|
77
|
+
for (const ext of set) parts.push('.' + ext)
|
|
78
|
+
return parts.join(',')
|
|
79
|
+
}
|
|
80
|
+
// 应用配置:更新 allowedTypes → 重建校验 Set + accept → 通知上传按钮重渲染
|
|
81
|
+
function applyConfig(cfg) {
|
|
82
|
+
allowedTypes.doc = normalizeList(cfg && cfg.doc)
|
|
83
|
+
allowedTypes.code = normalizeList(cfg && cfg.code)
|
|
84
|
+
allowedTypes.config = normalizeList(cfg && cfg.config)
|
|
85
|
+
rebuildAllowed()
|
|
86
|
+
acceptString = buildAccept()
|
|
87
|
+
for (const fn of acceptListeners) { try { fn() } catch (err) { /* 忽略订阅者异常 */ } }
|
|
88
|
+
}
|
|
89
|
+
// 拉取持久化配置;成功覆盖默认,失败保留默认。幂等(复用同一 Promise)
|
|
90
|
+
let configPromise = null
|
|
91
|
+
function loadConfig() {
|
|
92
|
+
if (configPromise === null) {
|
|
93
|
+
configPromise = (async () => {
|
|
94
|
+
let cfg = null
|
|
95
|
+
try {
|
|
96
|
+
const r = await fetch('/dsh-file-attachment/config')
|
|
97
|
+
const env = await r.json()
|
|
98
|
+
if (env && env.ok === true && env.value && typeof env.value === 'object') cfg = env.value
|
|
99
|
+
} catch (err) { /* 无配置接口时保留默认 */ }
|
|
100
|
+
if (cfg) applyConfig(cfg)
|
|
101
|
+
else { rebuildAllowed(); acceptString = buildAccept() }
|
|
102
|
+
return allowedTypes
|
|
103
|
+
})()
|
|
104
|
+
}
|
|
105
|
+
return configPromise
|
|
106
|
+
}
|
|
107
|
+
rebuildAllowed()
|
|
108
|
+
acceptString = buildAccept()
|
|
41
109
|
|
|
42
110
|
// ---- 通知广播:composer/dock 与 overlay toast 之间共享文本,自动消失 ----
|
|
43
111
|
function publishNotice(text) {
|
|
@@ -50,6 +118,19 @@ window.__ModuleLoader__.load({
|
|
|
50
118
|
noticeTimer = setTimeout(() => { noticeTimer = null; if (bridge.noticeText === text) publishNotice(null) }, NOTICE_MS)
|
|
51
119
|
}
|
|
52
120
|
|
|
121
|
+
// ---- 已附加文件(待发送):runBatch 插入 chip 成功后登记,FaFileDock 渲染,发送后清空 ----
|
|
122
|
+
let faSeq = 0
|
|
123
|
+
const attached = new Map() // id -> { id, name, path, isImage, url, sessionId }
|
|
124
|
+
const attachedListeners = new Set() // 变化订阅(FaFileDock 重渲染)
|
|
125
|
+
function emitAttached() { for (const fn of attachedListeners) { try { fn() } catch (err) { /* 忽略 */ } } }
|
|
126
|
+
function attachFile(entry) { attached.set(entry.id, entry); emitAttached() }
|
|
127
|
+
function detachFile(id) { if (attached.delete(id)) emitAttached() }
|
|
128
|
+
function clearAttachedFor(sid) {
|
|
129
|
+
let changed = false
|
|
130
|
+
for (const [id, e] of attached) if (e.sessionId === sid) { attached.delete(id); changed = true }
|
|
131
|
+
if (changed) emitAttached()
|
|
132
|
+
}
|
|
133
|
+
|
|
53
134
|
// ---- 宿主保存调用:fetch POST 宿主 webServer 路由(签名同 dsh-upload-file attach)----
|
|
54
135
|
async function saveFileToHost(fileName, b64, sessionId) {
|
|
55
136
|
if (typeof fetch !== 'function') throw new Error('当前环境无 fetch,无法上传')
|
|
@@ -79,6 +160,59 @@ window.__ModuleLoader__.load({
|
|
|
79
160
|
throw new Error(errMsg)
|
|
80
161
|
}
|
|
81
162
|
|
|
163
|
+
// ---- 图片处理:扩展名 / GIF 判定 / 加载 / 缩放(≤MAX_DIM,GIF 原样保动画,PNG 保透明) ----
|
|
164
|
+
function fileExt(name) {
|
|
165
|
+
const s = String(name == null ? '' : name)
|
|
166
|
+
const dot = s.lastIndexOf('.')
|
|
167
|
+
return dot > 0 ? s.slice(dot + 1).toLowerCase() : ''
|
|
168
|
+
}
|
|
169
|
+
function isGif(file) {
|
|
170
|
+
const t = String(file && file.type ? file.type : '').toLowerCase()
|
|
171
|
+
return t === 'image/gif' || fileExt(file && file.name) === 'gif'
|
|
172
|
+
}
|
|
173
|
+
function loadImage(url) {
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
const img = new Image()
|
|
176
|
+
img.onload = () => resolve(img)
|
|
177
|
+
img.onerror = () => reject(new Error('图片解码失败'))
|
|
178
|
+
img.src = url
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
// 缩放:最长边 > maxDim 时等比缩小;返回 { data, name }(data 为 Blob/File)。
|
|
182
|
+
// GIF 原样(canvas 取首帧丢动画 + 不支持 gif 输出);PNG 输出 PNG 保透明;其余 JPEG 0.85。
|
|
183
|
+
// 缩放失败/无需缩放时回退原文件。
|
|
184
|
+
async function downscaleImage(file, maxDim) {
|
|
185
|
+
if (isGif(file)) return { data: file, name: file.name }
|
|
186
|
+
let url = ''
|
|
187
|
+
try {
|
|
188
|
+
url = URL.createObjectURL(file)
|
|
189
|
+
const img = await loadImage(url)
|
|
190
|
+
const w = img.naturalWidth || img.width || 0
|
|
191
|
+
const h = img.naturalHeight || img.height || 0
|
|
192
|
+
if (!w || !h) return { data: file, name: file.name }
|
|
193
|
+
const scale = Math.min(1, maxDim / Math.max(w, h))
|
|
194
|
+
if (scale >= 1) return { data: file, name: file.name } // 已够小,原样
|
|
195
|
+
const nw = Math.max(1, Math.round(w * scale))
|
|
196
|
+
const nh = Math.max(1, Math.round(h * scale))
|
|
197
|
+
const canvas = document.createElement('canvas')
|
|
198
|
+
canvas.width = nw
|
|
199
|
+
canvas.height = nh
|
|
200
|
+
canvas.getContext('2d').drawImage(img, 0, 0, nw, nh)
|
|
201
|
+
const isPng = String(file.type || '').toLowerCase() === 'image/png' || fileExt(file.name) === 'png'
|
|
202
|
+
const mime = isPng ? 'image/png' : 'image/jpeg'
|
|
203
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.85))
|
|
204
|
+
if (!blob) return { data: file, name: file.name }
|
|
205
|
+
// 输出 mime 决定扩展名(png→.png,jpeg→.jpg);文件名主体保留
|
|
206
|
+
const base = file.name.lastIndexOf('.') > 0 ? file.name.slice(0, file.name.lastIndexOf('.')) : file.name
|
|
207
|
+
const name = base + (mime === 'image/png' ? '.png' : '.jpg')
|
|
208
|
+
return { data: blob, name }
|
|
209
|
+
} catch (err) {
|
|
210
|
+
return { data: file, name: file.name } // 缩放失败回退原文件
|
|
211
|
+
} finally {
|
|
212
|
+
if (url !== '') URL.revokeObjectURL(url)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
82
216
|
// 复刻 dsh-file-reference 的 formatFileMention:
|
|
83
217
|
// 目录补尾斜杠;含不可表示字符返回 undefined;含空白走引号形式(目录引号保持开启)
|
|
84
218
|
function hasBadChar(p) {
|
|
@@ -216,7 +350,7 @@ window.__ModuleLoader__.load({
|
|
|
216
350
|
}
|
|
217
351
|
|
|
218
352
|
// 拖放/粘贴文件共用核心。source:'drop' | 'paste'
|
|
219
|
-
// 返回是否已接管事件(false =
|
|
353
|
+
// 返回是否已接管事件(false = 纯图片拖放/粘贴,原样交给既有图片路径)
|
|
220
354
|
function handleFilesEvent(e, files, items, source) {
|
|
221
355
|
const images = []
|
|
222
356
|
const docs = [] // { path, name, isDir, file }
|
|
@@ -236,10 +370,16 @@ window.__ModuleLoader__.load({
|
|
|
236
370
|
docs.push({ path: resolveDropPath(raw), name, isDir, file })
|
|
237
371
|
}
|
|
238
372
|
|
|
239
|
-
//
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
373
|
+
// 无输入桥(hero/blank 未就绪):纯图片交原生图片路径保留行为;含文档则提示不可用
|
|
374
|
+
if (bridge.shell === null) {
|
|
375
|
+
if (docs.length === 0) return false
|
|
376
|
+
e.preventDefault()
|
|
377
|
+
e.stopImmediatePropagation()
|
|
378
|
+
dismissDropOverlay()
|
|
379
|
+
announce('输入框不可用')
|
|
380
|
+
return true
|
|
381
|
+
}
|
|
382
|
+
// 有输入桥:接管全部(图片 + 文档统一落盘 → 芯片),彻底绕开 dsh 原生图片发送流程(解 413)
|
|
243
383
|
e.preventDefault()
|
|
244
384
|
e.stopImmediatePropagation()
|
|
245
385
|
dismissDropOverlay()
|
|
@@ -247,103 +387,96 @@ window.__ModuleLoader__.load({
|
|
|
247
387
|
return true
|
|
248
388
|
}
|
|
249
389
|
|
|
250
|
-
//
|
|
390
|
+
// 异步批处理:文档 + 图片统一落盘 → shell.insertReference 芯片(不显示长路径,发送还原 @ 路径)。
|
|
391
|
+
// 图片先 downscale(≤MAX_DIM,GIF 原样保动画);目录拒绝;扩展名按配置校验(图片恒允许)。
|
|
251
392
|
async function runBatch(images, docs) {
|
|
252
|
-
const
|
|
253
|
-
if (
|
|
254
|
-
announce('
|
|
393
|
+
const shell = bridge.shell
|
|
394
|
+
if (shell === null || typeof shell.insertReference !== 'function') {
|
|
395
|
+
announce(`输入框不可用 [mounted:${bridge.mounted} shell:${shell === null ? 'null' : 'ok'} addImg:${typeof bridge.addImages}]`)
|
|
255
396
|
return
|
|
256
397
|
}
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
398
|
+
const snap0 = shell.snapshot
|
|
399
|
+
const phase = snap0 ? snap0.phase : 'plain'
|
|
400
|
+
if (phase !== 'plain' && phase !== 'command' && phase !== 'claimed') {
|
|
401
|
+
announce('输入框正忙')
|
|
260
402
|
return
|
|
261
403
|
}
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
const dirRejected = []
|
|
265
|
-
let savedDir = ''
|
|
266
|
-
let savedCount = 0
|
|
267
|
-
let skipped = 0
|
|
268
|
-
let lastErr = ''
|
|
404
|
+
// 汇总待上传项:图片恒允许;文档按配置扩展名校验;目录拒绝
|
|
405
|
+
const items = []
|
|
269
406
|
for (let i = 0; i < docs.length; i++) {
|
|
270
407
|
const d = docs[i]
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
408
|
+
items.push({ file: d.file, name: d.name, isImage: false, isDir: d.isDir })
|
|
409
|
+
}
|
|
410
|
+
for (let i = 0; i < images.length; i++) items.push({ file: images[i], name: images[i].name, isImage: true, isDir: false })
|
|
411
|
+
const pending = []
|
|
412
|
+
let dirRejected = 0
|
|
413
|
+
let rejectedExt = 0
|
|
414
|
+
for (let i = 0; i < items.length; i++) {
|
|
415
|
+
const it = items[i]
|
|
416
|
+
if (it.isDir) { dirRejected += 1; continue }
|
|
417
|
+
if (it.isImage) { pending.push(it); continue }
|
|
418
|
+
const ext = fileExt(it.name)
|
|
419
|
+
if (ext === '' || allowedExts === null || !allowedExts.has(ext)) { rejectedExt += 1; continue }
|
|
420
|
+
pending.push(it)
|
|
421
|
+
}
|
|
422
|
+
if (pending.length === 0) {
|
|
423
|
+
if (dirRejected > 0 && rejectedExt === 0) { announce('不支持拖入目录'); return }
|
|
424
|
+
if (rejectedExt > 0) { announce('文件类型不在允许列表(设置→文件附件可调整)'); return }
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
let inserted = 0
|
|
428
|
+
let failed = 0
|
|
429
|
+
for (let i = 0; i < pending.length; i++) {
|
|
430
|
+
const it = pending[i]
|
|
276
431
|
try {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
432
|
+
if (it.isImage) {
|
|
433
|
+
// 图片完全走 dsh 原生链路:addImages(草稿图片),不缩放/不落盘/不进文件条
|
|
434
|
+
const addImages = typeof bridge.addImages === 'function' ? bridge.addImages : null
|
|
435
|
+
if (addImages === null) { failed += 1; continue }
|
|
436
|
+
let imgErr = null; try { imgErr = addImages([it.file]) } catch (err) { imgErr = err instanceof Error ? err.message : String(err) }
|
|
437
|
+
if (imgErr) { failed += 1; continue }
|
|
438
|
+
inserted += 1
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
// 文档:原样落盘 → @引用芯片 + 文件条登记
|
|
442
|
+
const data = it.file
|
|
443
|
+
const name = it.name
|
|
444
|
+
if (data === null || typeof data.size !== 'number' || data.size === 0) throw new Error('size')
|
|
445
|
+
if (data.size > MAX_BYTES) throw new Error('size')
|
|
446
|
+
const buf = await data.arrayBuffer()
|
|
280
447
|
const b64 = bytesToBase64(new Uint8Array(buf))
|
|
281
|
-
const res = await saveFileToHost(
|
|
448
|
+
const res = await saveFileToHost(name, b64, bridge.sessionId)
|
|
282
449
|
const m = formatMention(res.path, false)
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
450
|
+
const clipboardText = m !== undefined ? m : '@' + res.path
|
|
451
|
+
// 每次插入前重取 snapshot(draftRev 随插入递增,span 取当前末尾)
|
|
452
|
+
const snap = shell.snapshot
|
|
453
|
+
const span = { draftRev: snap.draftRev, start: snap.draft.length, end: snap.draft.length }
|
|
454
|
+
const okInsert = shell.insertReference(
|
|
455
|
+
{
|
|
456
|
+
source: 'reference', // 复用内置已注册的 @引用 source(带 codec.serialize),否则报 no serializer
|
|
457
|
+
ref: clipboardText, // codec.serialize 原样返回 ref 作为模型文本,故放完整 @路径
|
|
458
|
+
label: name,
|
|
459
|
+
appearance: 'file',
|
|
460
|
+
clipboardText: clipboardText,
|
|
461
|
+
},
|
|
462
|
+
span,
|
|
463
|
+
)
|
|
464
|
+
if (okInsert) {
|
|
465
|
+
inserted += 1
|
|
466
|
+
// 登记到文件条(文档:类型图标 + 点击打开;图片走原生 addImages 不登记)
|
|
467
|
+
attachFile({ id: 'fa' + (++faSeq), name, path: res.path, isImage: false, url: null, sessionId: bridge.sessionId })
|
|
468
|
+
} else failed += 1
|
|
288
469
|
} catch (err) {
|
|
289
|
-
|
|
290
|
-
skipped++
|
|
470
|
+
failed += 1
|
|
291
471
|
}
|
|
292
472
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const ta = findComposerTextarea()
|
|
297
|
-
if (ta === null) announce('未找到可用的输入框,无法插入文件引用')
|
|
298
|
-
else { insertTextAtCaret(ta, actions, mentions.join(' ')); inserted = mentions.length }
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// 图片:与既有图片路径完全相同的调用序列登记(addImages 失败则释放)
|
|
302
|
-
let imagesAdded = 0
|
|
303
|
-
const conversation = bridge.conversation
|
|
304
|
-
if (images.length > 0 && conversation !== void 0
|
|
305
|
-
&& typeof conversation.createDraftImages === 'function'
|
|
306
|
-
&& typeof actions.addImages === 'function') {
|
|
307
|
-
try {
|
|
308
|
-
const created = conversation.createDraftImages(images)
|
|
309
|
-
const ids = created.map((a) => a.id)
|
|
310
|
-
if (actions.addImages(ids)) {
|
|
311
|
-
imagesAdded = ids.length
|
|
312
|
-
} else if (typeof conversation.releaseDraftImages === 'function') {
|
|
313
|
-
conversation.releaseDraftImages(created)
|
|
314
|
-
}
|
|
315
|
-
} catch (err) { /* 图片登记失败不阻断文档引用插入 */ }
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
if (inserted === 0 && imagesAdded === 0) {
|
|
319
|
-
if (savedCount > 0 && skipped === 0) return
|
|
320
|
-
if (dirRejected.length > 0 && skipped === dirRejected.length && savedCount === 0) {
|
|
321
|
-
announce('不支持拖入目录:' + dirRejected.slice(0, 3).join('、') + (dirRejected.length > 3 ? ' 等' : '') + '。仅支持文档文件')
|
|
322
|
-
return
|
|
323
|
-
}
|
|
324
|
-
announce('文件处理失败:' + (lastErr !== '' ? lastErr : '全部文件不可用') + '(共 ' + docs.length + ' 个)')
|
|
473
|
+
if (inserted === 0) {
|
|
474
|
+
if (dirRejected > 0 && rejectedExt === 0 && failed === 0) { announce('不支持拖入目录'); return }
|
|
475
|
+
announce('文件处理失败')
|
|
325
476
|
return
|
|
326
477
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if (inserted > 0) {
|
|
330
|
-
text = '已插入 ' + inserted + ' 个文件引用'
|
|
331
|
-
if (savedCount > 0) {
|
|
332
|
-
text += '(已保存 ' + savedCount + ' 个文件'
|
|
333
|
-
if (savedNames.length > 0) text += ':' + savedNames.slice(0, 3).join('、') + (savedNames.length > 3 ? ' 等' : '')
|
|
334
|
-
text += ')'
|
|
335
|
-
}
|
|
336
|
-
if (savedDir !== '') text += ',保存目录:' + savedDir
|
|
337
|
-
}
|
|
338
|
-
if (imagesAdded > 0) text += (text === '' ? '' : ',') + imagesAdded + ' 张图片已按原有方式添加'
|
|
339
|
-
if (skipped > 0) {
|
|
340
|
-
text += '(' + skipped + ' 个已跳过'
|
|
341
|
-
if (dirRejected.length > 0) text += ',不支持拖入目录:' + dirRejected.slice(0, 3).join('、') + (dirRejected.length > 3 ? ' 等' : '')
|
|
342
|
-
if (dirRejected.length < skipped) text += ',超 50MB、不可读或保存失败'
|
|
343
|
-
if (lastErr !== '') text += ';原因:' + lastErr
|
|
344
|
-
text += ')'
|
|
345
|
-
}
|
|
346
|
-
announce(text)
|
|
478
|
+
const skipped = failed + rejectedExt
|
|
479
|
+
if (skipped > 0) announce(inserted + ' 个已插入 · ' + skipped + ' 个已跳过')
|
|
347
480
|
}
|
|
348
481
|
|
|
349
482
|
// document 级 drop(capture):保证先于图片路径的 document 级 bubble 监听器执行
|
|
@@ -403,18 +536,44 @@ window.__ModuleLoader__.load({
|
|
|
403
536
|
}, text)
|
|
404
537
|
}
|
|
405
538
|
|
|
406
|
-
// composer.dock
|
|
539
|
+
// input.dock 槽:比 composer.dock 更早且不受 hero(首轮空白无底部栏)限制,
|
|
540
|
+
// 仅同步桥(actions/input/sessionId/conversation),不渲染可见文本。
|
|
407
541
|
function FaBridge(props) {
|
|
408
|
-
const actions = props.inputActions !== void 0 ? props.inputActions : null
|
|
409
|
-
const input = props.input !== void 0 ? props.input : undefined
|
|
410
542
|
const sessionId = props.sessionId !== void 0 ? props.sessionId : ''
|
|
543
|
+
// inject 只传 sessionId(string 可靠);shell/addImages 由本组件经 ctxRef 自行计算(dsh slot 对 inject 返回的 object/function 会丢失)
|
|
544
|
+
let shell = null
|
|
545
|
+
let addImages = null
|
|
546
|
+
try {
|
|
547
|
+
const ctx = ctxRef
|
|
548
|
+
if (ctx !== null && sessionId !== '') {
|
|
549
|
+
const conversation = ctx.get('conversation')
|
|
550
|
+
const actx = ctx.sessions.scope(sessionId)
|
|
551
|
+
if (conversation !== void 0 && actx !== void 0) {
|
|
552
|
+
shell = conversation.input.for(actx)
|
|
553
|
+
if (shell !== null) {
|
|
554
|
+
addImages = (files) => {
|
|
555
|
+
try {
|
|
556
|
+
const images = conversation.createDraftImages(files)
|
|
557
|
+
if (!shell.addImages(images.map((image) => image.id))) conversation.releaseDraftImages(images)
|
|
558
|
+
return null
|
|
559
|
+
} catch (error) {
|
|
560
|
+
return error instanceof Error ? error.message : String(error)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} catch (err) { shell = null; addImages = null }
|
|
567
|
+
const input = shell !== null && shell.snapshot !== void 0 ? shell.snapshot : undefined
|
|
411
568
|
react.useEffect(() => {
|
|
412
|
-
bridge.
|
|
569
|
+
bridge.shell = shell
|
|
570
|
+
bridge.addImages = addImages
|
|
413
571
|
bridge.input = input
|
|
414
572
|
bridge.sessionId = sessionId
|
|
415
573
|
bridge.mounted = true
|
|
416
574
|
return () => {
|
|
417
|
-
bridge.
|
|
575
|
+
bridge.shell = null
|
|
576
|
+
bridge.addImages = null
|
|
418
577
|
bridge.input = undefined
|
|
419
578
|
bridge.sessionId = ''
|
|
420
579
|
bridge.mounted = false
|
|
@@ -423,18 +582,338 @@ window.__ModuleLoader__.load({
|
|
|
423
582
|
return null
|
|
424
583
|
}
|
|
425
584
|
|
|
585
|
+
// 文件类型图标:文档轮廓 SVG + 扩展名缩写(颜色继承 currentColor)
|
|
586
|
+
function fileGlyph(name) {
|
|
587
|
+
const ext = (fileExt(name) || '').toUpperCase().slice(0, 4) || 'FILE'
|
|
588
|
+
return react.createElement('svg', { width: 18, height: 20, viewBox: '0 0 18 20', 'aria-hidden': true, style: { display: 'block' } },
|
|
589
|
+
react.createElement('path', { d: 'M3 1h8l4 4v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z', fill: 'rgba(128,128,128,0.22)', stroke: 'currentColor', strokeWidth: 1 }),
|
|
590
|
+
react.createElement('path', { d: 'M11 1v4h4', fill: 'none', stroke: 'currentColor', strokeWidth: 1 }),
|
|
591
|
+
react.createElement('text', { x: 9, y: 14, textAnchor: 'middle', fontSize: 5.5, fill: 'currentColor', fontFamily: 'monospace' }, ext),
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// 文件条(输入框上方 dock):图片真缩略图 / 文件类型图标,点击用默认程序打开,可移除,发送后自动清空。
|
|
596
|
+
function FaFileDock() {
|
|
597
|
+
const [, force] = react.useState(0)
|
|
598
|
+
react.useEffect(() => {
|
|
599
|
+
const fn = () => force(n => n + 1)
|
|
600
|
+
attachedListeners.add(fn)
|
|
601
|
+
return () => { attachedListeners.delete(fn) }
|
|
602
|
+
}, [])
|
|
603
|
+
// 发送后清空:轮询 shell.snapshot,occurrences 从有→无 即一次发送完成
|
|
604
|
+
const hadOcc = react.useRef(false)
|
|
605
|
+
react.useEffect(() => {
|
|
606
|
+
const timer = setInterval(() => {
|
|
607
|
+
const shell = bridge.shell
|
|
608
|
+
const snap = shell ? shell.snapshot : null
|
|
609
|
+
const occ = snap ? snap.occurrences : null
|
|
610
|
+
const has = !!(occ && occ.length > 0)
|
|
611
|
+
const s = bridge.sessionId
|
|
612
|
+
if (has) hadOcc.current = true
|
|
613
|
+
else if (hadOcc.current && s) { hadOcc.current = false; clearAttachedFor(s) }
|
|
614
|
+
}, 400)
|
|
615
|
+
return () => clearInterval(timer)
|
|
616
|
+
}, [])
|
|
617
|
+
const sid = bridge.sessionId
|
|
618
|
+
const entries = Array.from(attached.values()).filter(e => e.sessionId === sid)
|
|
619
|
+
if (entries.length === 0) return null
|
|
620
|
+
return react.createElement('div', {
|
|
621
|
+
style: {
|
|
622
|
+
display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center',
|
|
623
|
+
width: '100%', maxWidth: 'calc(var(--dsh-composer-card-max-width, 720px) - 24px)',
|
|
624
|
+
margin: '0 auto 6px', padding: '2px 4px', boxSizing: 'border-box',
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
entries.map(e =>
|
|
628
|
+
react.createElement('div', { key: e.id, style: {
|
|
629
|
+
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
|
630
|
+
background: 'rgba(128,128,128,0.12)', border: '1px solid rgba(128,128,128,0.2)',
|
|
631
|
+
borderRadius: '8px', padding: '3px 8px', fontSize: '12px', lineHeight: '1.4', maxWidth: '240px',
|
|
632
|
+
} },
|
|
633
|
+
e.isImage && e.url
|
|
634
|
+
? react.createElement('img', { src: e.url, alt: '', style: { width: '20px', height: '20px', objectFit: 'cover', borderRadius: '4px', flex: 'none' } })
|
|
635
|
+
: react.createElement('span', { style: { display: 'inline-flex', width: '20px', height: '20px', flex: 'none', color: 'inherit' } }, fileGlyph(e.name)),
|
|
636
|
+
react.createElement('span', {
|
|
637
|
+
style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 },
|
|
638
|
+
}, e.name),
|
|
639
|
+
react.createElement('span', {
|
|
640
|
+
onClick: () => detachFile(e.id),
|
|
641
|
+
title: '移除',
|
|
642
|
+
style: { cursor: 'pointer', opacity: 0.6, fontSize: '14px', lineHeight: '1', flex: 'none', padding: '0 2px' },
|
|
643
|
+
}, '×'),
|
|
644
|
+
)
|
|
645
|
+
),
|
|
646
|
+
)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// conversation.input.left 槽(操作行左侧动作区,官方扩展点):文件上传按钮。
|
|
650
|
+
// 点击触发隐藏 <input type=file multiple>(PC/移动端原生文件选择器,accept 过滤图片+常见文档),
|
|
651
|
+
// 选中后复用既有 runBatch 管线:文档落盘→@引用插入,图片走 createDraftImages+addImages 草稿机制。
|
|
652
|
+
// 运行状态经 bridge(FaBridge 在 input.dock 槽填充 actions/input/sessionId,apply 填充 conversation)。
|
|
653
|
+
function FaUploadButton() {
|
|
654
|
+
const inputRef = react.useRef(null)
|
|
655
|
+
const busyState = react.useState(false)
|
|
656
|
+
const busy = busyState[0]
|
|
657
|
+
const setBusy = busyState[1]
|
|
658
|
+
// 订阅 accept 变化(设置页保存配置 → 重渲染,accept 属性随之刷新)
|
|
659
|
+
const [, forceAccept] = react.useState(0)
|
|
660
|
+
react.useEffect(() => {
|
|
661
|
+
const fn = () => forceAccept((n) => n + 1)
|
|
662
|
+
acceptListeners.add(fn)
|
|
663
|
+
return () => { acceptListeners.delete(fn) }
|
|
664
|
+
}, [])
|
|
665
|
+
function onClick() {
|
|
666
|
+
const el = inputRef.current
|
|
667
|
+
if (el === null || busy) return
|
|
668
|
+
el.click()
|
|
669
|
+
}
|
|
670
|
+
async function onChange(e) {
|
|
671
|
+
const list = e.target.files
|
|
672
|
+
if (list === null || list.length === 0) return
|
|
673
|
+
const images = []
|
|
674
|
+
const docs = []
|
|
675
|
+
for (let i = 0; i < list.length; i++) {
|
|
676
|
+
const file = list[i]
|
|
677
|
+
if (file.type && file.type.indexOf('image/') === 0) { images.push(file); continue }
|
|
678
|
+
docs.push({ path: null, name: file.name, isDir: false, file })
|
|
679
|
+
}
|
|
680
|
+
setBusy(true)
|
|
681
|
+
try {
|
|
682
|
+
await runBatch(images, docs)
|
|
683
|
+
} finally {
|
|
684
|
+
// 重置 input 值,允许再次选择同名文件
|
|
685
|
+
e.target.value = ''
|
|
686
|
+
setBusy(false)
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return react.createElement('span', { style: { display: 'inline-flex', alignItems: 'center' } },
|
|
690
|
+
react.createElement('input', {
|
|
691
|
+
ref: inputRef,
|
|
692
|
+
type: 'file',
|
|
693
|
+
multiple: true,
|
|
694
|
+
accept: acceptString,
|
|
695
|
+
style: { display: 'none' },
|
|
696
|
+
onChange: onChange,
|
|
697
|
+
}),
|
|
698
|
+
react.createElement('button', {
|
|
699
|
+
type: 'button',
|
|
700
|
+
title: '上传文件',
|
|
701
|
+
'aria-label': '上传文件',
|
|
702
|
+
disabled: busy,
|
|
703
|
+
onClick: onClick,
|
|
704
|
+
style: {
|
|
705
|
+
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
706
|
+
width: '28px', height: '28px', padding: 0, margin: 0, border: 'none',
|
|
707
|
+
background: 'transparent', color: 'inherit',
|
|
708
|
+
cursor: busy ? 'default' : 'pointer', borderRadius: '6px',
|
|
709
|
+
opacity: busy ? 0.45 : 1, transition: 'background 120ms ease, opacity 120ms ease',
|
|
710
|
+
},
|
|
711
|
+
onMouseEnter: (ev) => { if (!busy) ev.currentTarget.style.background = 'rgba(128,128,128,0.16)' },
|
|
712
|
+
onMouseLeave: (ev) => { ev.currentTarget.style.background = 'transparent' },
|
|
713
|
+
},
|
|
714
|
+
react.createElement('svg', {
|
|
715
|
+
width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none',
|
|
716
|
+
'aria-hidden': true, style: { display: 'block' },
|
|
717
|
+
},
|
|
718
|
+
react.createElement('path', {
|
|
719
|
+
d: 'M8 13V3M8 3L4 7M8 3L12 7',
|
|
720
|
+
stroke: 'currentColor', strokeWidth: 1.5,
|
|
721
|
+
strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
722
|
+
})
|
|
723
|
+
)
|
|
724
|
+
)
|
|
725
|
+
)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// 扩展名规范化:小写/去前导点/仅 [a-z0-9];非法返回 null
|
|
729
|
+
function normExt(v) {
|
|
730
|
+
const s = String(v == null ? '' : v).trim().toLowerCase().replace(/^\.+/u, '')
|
|
731
|
+
return (s !== '' && /^[a-z0-9]+$/u.test(s)) ? s : null
|
|
732
|
+
}
|
|
733
|
+
function addExt(setList, value, current) {
|
|
734
|
+
const s = normExt(value)
|
|
735
|
+
if (s === null || current.indexOf(s) >= 0) return
|
|
736
|
+
setList(current.concat(s))
|
|
737
|
+
}
|
|
738
|
+
function removeExt(setList, current, idx) {
|
|
739
|
+
const next = current.slice()
|
|
740
|
+
next.splice(idx, 1)
|
|
741
|
+
setList(next)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// 设置页扩展名分组:标题 + 可删 tag + 输入框 + 添加按钮
|
|
745
|
+
function FaExtGroup(props) {
|
|
746
|
+
const list = props.list
|
|
747
|
+
const setList = props.setList
|
|
748
|
+
const input = props.input
|
|
749
|
+
const setInput = props.setInput
|
|
750
|
+
function commit() { addExt(setList, input, list); setInput('') }
|
|
751
|
+
const tags = list.map((ext, i) => react.createElement('span', {
|
|
752
|
+
key: i,
|
|
753
|
+
style: {
|
|
754
|
+
display: 'inline-flex', alignItems: 'center',
|
|
755
|
+
padding: '2px 8px', margin: '2px', borderRadius: '6px',
|
|
756
|
+
background: 'rgba(128,128,128,0.14)', fontSize: '12px', fontFamily: 'monospace',
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
ext,
|
|
760
|
+
react.createElement('span', {
|
|
761
|
+
onClick: () => removeExt(setList, list, i),
|
|
762
|
+
style: { cursor: 'pointer', marginLeft: '4px', opacity: 0.55 },
|
|
763
|
+
}, '×')
|
|
764
|
+
))
|
|
765
|
+
return react.createElement('div', { style: { marginBottom: '18px' } },
|
|
766
|
+
react.createElement('div', { style: { fontWeight: 600, marginBottom: '8px', fontSize: '13px' } }, props.title),
|
|
767
|
+
react.createElement('div', { style: { marginBottom: '8px', minHeight: '22px' } }, tags),
|
|
768
|
+
react.createElement('div', { style: { display: 'flex', gap: '8px' } },
|
|
769
|
+
react.createElement('input', {
|
|
770
|
+
value: input,
|
|
771
|
+
placeholder: '如 pdf / docx / py(回车或点「添加」)',
|
|
772
|
+
style: { flex: 1, padding: '6px 10px', borderRadius: '8px', border: '1px solid rgba(128,128,128,0.3)', fontSize: '13px' },
|
|
773
|
+
onChange: (e) => setInput(e.target.value),
|
|
774
|
+
onKeyDown: (e) => { if (e.key === 'Enter') commit() },
|
|
775
|
+
}),
|
|
776
|
+
react.createElement('button', {
|
|
777
|
+
type: 'button', onClick: commit,
|
|
778
|
+
style: { padding: '6px 14px', borderRadius: '8px', border: '1px solid rgba(128,128,128,0.3)', background: 'none', cursor: 'pointer', fontSize: '13px' },
|
|
779
|
+
}, '添加')
|
|
780
|
+
)
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// 设置页:配置可上传的文档/代码/配置文件扩展名,保存写 ~/.dsh/file-attachment.json
|
|
785
|
+
function FaSettingsPage() {
|
|
786
|
+
const [doc, setDoc] = react.useState(DEFAULT_TYPES.doc.slice())
|
|
787
|
+
const [code, setCode] = react.useState(DEFAULT_TYPES.code.slice())
|
|
788
|
+
const [config, setConfig] = react.useState(DEFAULT_TYPES.config.slice())
|
|
789
|
+
const [docIn, setDocIn] = react.useState('')
|
|
790
|
+
const [codeIn, setCodeIn] = react.useState('')
|
|
791
|
+
const [cfgIn, setCfgIn] = react.useState('')
|
|
792
|
+
const [saved, setSaved] = react.useState(false)
|
|
793
|
+
const [saving, setSaving] = react.useState(false)
|
|
794
|
+
react.useEffect(() => {
|
|
795
|
+
// 挂载时载入持久化配置到编辑副本(幂等,复用 loadConfig Promise)
|
|
796
|
+
let live = true
|
|
797
|
+
loadConfig().then(() => {
|
|
798
|
+
if (!live) return
|
|
799
|
+
setDoc(allowedTypes.doc.slice())
|
|
800
|
+
setCode(allowedTypes.code.slice())
|
|
801
|
+
setConfig(allowedTypes.config.slice())
|
|
802
|
+
})
|
|
803
|
+
return () => { live = false }
|
|
804
|
+
}, [])
|
|
805
|
+
function t(key) {
|
|
806
|
+
if (ctxRef !== null && ctxRef.locale !== void 0 && typeof ctxRef.locale.bind === 'function') {
|
|
807
|
+
try { return ctxRef.locale.bind('dsh-file-attachment')(key) } catch (err) { /* 回退 key */ }
|
|
808
|
+
}
|
|
809
|
+
return key
|
|
810
|
+
}
|
|
811
|
+
async function save() {
|
|
812
|
+
setSaving(true); setSaved(false)
|
|
813
|
+
try {
|
|
814
|
+
const body = { doc: doc, code: code, config: config }
|
|
815
|
+
const r = await fetch('/dsh-file-attachment/config', {
|
|
816
|
+
method: 'POST',
|
|
817
|
+
headers: { 'Content-Type': 'application/json' },
|
|
818
|
+
body: JSON.stringify(body),
|
|
819
|
+
})
|
|
820
|
+
const env = await r.json()
|
|
821
|
+
if (env && env.ok === true) {
|
|
822
|
+
applyConfig(body) // 立即生效:更新 allowedExts + accept(上传按钮随之刷新)
|
|
823
|
+
setSaved(true)
|
|
824
|
+
setTimeout(() => setSaved(false), 2000)
|
|
825
|
+
} else {
|
|
826
|
+
announce('保存失败')
|
|
827
|
+
}
|
|
828
|
+
} catch (err) {
|
|
829
|
+
announce('保存失败')
|
|
830
|
+
} finally { setSaving(false) }
|
|
831
|
+
}
|
|
832
|
+
return react.createElement('div', { style: { padding: '16px', maxWidth: '680px' } },
|
|
833
|
+
react.createElement('div', { style: { marginBottom: '16px', fontSize: '13px', color: 'rgba(128,128,128,0.9)' } }, t('settings.hint')),
|
|
834
|
+
react.createElement(FaExtGroup, { title: t('doc.title'), list: doc, setList: setDoc, input: docIn, setInput: setDocIn }),
|
|
835
|
+
react.createElement(FaExtGroup, { title: t('code.title'), list: code, setList: setCode, input: codeIn, setInput: setCodeIn }),
|
|
836
|
+
react.createElement(FaExtGroup, { title: t('config.title'), list: config, setList: setConfig, input: cfgIn, setInput: setCfgIn }),
|
|
837
|
+
react.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '12px' } },
|
|
838
|
+
react.createElement('button', {
|
|
839
|
+
type: 'button', onClick: save, disabled: saving,
|
|
840
|
+
style: {
|
|
841
|
+
padding: '8px 20px', borderRadius: '8px', border: 'none',
|
|
842
|
+
background: 'rgba(80,120,220,0.9)', color: '#fff',
|
|
843
|
+
cursor: saving ? 'default' : 'pointer', fontSize: '13px', opacity: saving ? 0.6 : 1,
|
|
844
|
+
},
|
|
845
|
+
}, saving ? t('saving') : t('save')),
|
|
846
|
+
saved ? react.createElement('span', { style: { fontSize: '13px', color: 'rgba(80,160,120,0.95)' } }, t('saved')) : null
|
|
847
|
+
)
|
|
848
|
+
)
|
|
849
|
+
}
|
|
850
|
+
|
|
426
851
|
function apply(ctx) {
|
|
427
852
|
ctxRef = ctx
|
|
853
|
+
// 接入即拉持久化配置(幂等):让自定义类型每次会话生效,而非仅打开设置页才加载
|
|
854
|
+
void loadConfig()
|
|
855
|
+
// 注册设置页文案(zh/en)
|
|
856
|
+
ctx.effect(() => ctx.locale.register('dsh-file-attachment', {
|
|
857
|
+
zh: {
|
|
858
|
+
'settings.title': '文件附件',
|
|
859
|
+
'settings.hint': '用于设置聊天中可发送的文件类型。图片恒可发送;以下三类按扩展名校验文档/代码/配置文件(扩展名不带点、小写,如 pdf、py)。',
|
|
860
|
+
'doc.title': '文档',
|
|
861
|
+
'code.title': '代码',
|
|
862
|
+
'config.title': '配置文件',
|
|
863
|
+
'save': '保存',
|
|
864
|
+
'saving': '保存中…',
|
|
865
|
+
'saved': '已保存',
|
|
866
|
+
},
|
|
867
|
+
en: {
|
|
868
|
+
'settings.title': 'File Attachments',
|
|
869
|
+
'settings.hint': 'Set which file types can be sent in chat. Images are always allowed; the lists below gate documents / code / config files by extension (no dot, lowercase, e.g. pdf, py).',
|
|
870
|
+
'doc.title': 'Documents',
|
|
871
|
+
'code.title': 'Code',
|
|
872
|
+
'config.title': 'Config Files',
|
|
873
|
+
'save': 'Save',
|
|
874
|
+
'saving': 'Saving…',
|
|
875
|
+
'saved': 'Saved',
|
|
876
|
+
},
|
|
877
|
+
}), 'dsh-file-attachment: dictionaries')
|
|
428
878
|
const slots = ctx.get('slots')
|
|
429
879
|
if (slots !== undefined) {
|
|
430
|
-
|
|
431
|
-
|
|
880
|
+
// input.dock:hero(首轮空白无底部栏)态也渲染;inject 在渲染前经会话作用域直取输入机 shell
|
|
881
|
+
slots.inject('conversation.input.dock', () => slots.register(
|
|
882
|
+
{
|
|
883
|
+
name: 'conversation.input.dock',
|
|
884
|
+
id: 'dsh-file-attachment',
|
|
885
|
+
order: 300,
|
|
886
|
+
inject: (sessionId) => {
|
|
887
|
+
// 只传 sessionId(string 可靠);shell/addImages 由 FaBridge 组件经 ctxRef 自行计算
|
|
888
|
+
if (sessionId === void 0) return {}
|
|
889
|
+
return { sessionId }
|
|
890
|
+
}
|
|
891
|
+
},
|
|
432
892
|
FaBridge
|
|
433
893
|
))
|
|
894
|
+
// 文件条:渲染已附加文件(缩略图/类型图标 + 点击打开 + 可移除),发送后清空
|
|
895
|
+
slots.inject('conversation.input.dock', () => slots.register(
|
|
896
|
+
{ name: 'conversation.input.dock', id: 'dsh-file-attachment-dock', order: 400 },
|
|
897
|
+
FaFileDock
|
|
898
|
+
))
|
|
434
899
|
slots.inject('shell.overlay', () => slots.register(
|
|
435
900
|
{ name: 'shell.overlay', id: 'dsh-file-attachment-toast', order: 100 },
|
|
436
901
|
FaToast
|
|
437
902
|
))
|
|
903
|
+
// 操作行左侧动作区(官方扩展点):文件上传按钮,复用既有 runBatch 管线
|
|
904
|
+
slots.inject('conversation.input.left', () => slots.register(
|
|
905
|
+
{ name: 'conversation.input.left', id: 'dsh-file-attachment-upload', order: 10 },
|
|
906
|
+
FaUploadButton
|
|
907
|
+
))
|
|
908
|
+
// 设置页:文件附件类型配置(左侧菜单项 + 右侧配置页)
|
|
909
|
+
slots.inject('settings.section', () => [
|
|
910
|
+
slots.register({
|
|
911
|
+
name: 'settings.section',
|
|
912
|
+
id: 'dsh-file-attachment-settings',
|
|
913
|
+
order: 120,
|
|
914
|
+
label: () => ctx.locale.bind('dsh-file-attachment')('settings.title'),
|
|
915
|
+
}, () => react.createElement(FaSettingsPage, {})),
|
|
916
|
+
])
|
|
438
917
|
}
|
|
439
918
|
bridge.conversation = ctx.get('conversation')
|
|
440
919
|
// document 级 capture 监听:dragenter/drop/paste 都先于既有 bubble 监听器;随 Fiber 停止
|
package/lib/index.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// 项目根解析:Client 传 sessionId → sessions.get(sessionId).header.cwd →
|
|
5
5
|
// sandboxPolicy.workspaceRoot → process.cwd()(三平台绝对路径,Node path.join 自适应分隔符)。
|
|
6
6
|
// 纯 ESM 无构建:不依赖 TypertRemoteService/装饰器,原生 Node 可直接加载。
|
|
7
|
-
import { access, mkdir, writeFile } from 'node:fs/promises'
|
|
8
|
-
import {
|
|
7
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { join, resolve } from 'node:path'
|
|
9
10
|
|
|
10
11
|
/** 本包声明依赖的 Host 服务。 */
|
|
11
12
|
export const name = 'dsh-file-attachment'
|
|
@@ -19,6 +20,16 @@ const MAX_BYTES = 50 * 1024 * 1024
|
|
|
19
20
|
const MAX_BODY_BYTES = 68 * 1024 * 1024
|
|
20
21
|
// 文件名非法字符:路径分隔符、Windows 保留字符、控制字符
|
|
21
22
|
const BAD_CHARS = /[/\\:*?"<>|\u0000-\u001f\u007f]/gu
|
|
23
|
+
// 配置持久化:用户级 ~/.dsh/file-attachment.json(跨项目通用,存「可上传类型」白名单)
|
|
24
|
+
const DSH_HOME = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
25
|
+
const CONFIG_PATH = join(DSH_HOME, 'file-attachment.json')
|
|
26
|
+
// 默认允许类型(三类:文档 / 代码 / 配置文件);图片恒允许(image/*,不受此配置影响)
|
|
27
|
+
// 用户可在「设置 → 文件附件」页增删扩展名;此处仅为首启兜底与重置基准。
|
|
28
|
+
const DEFAULT_TYPES = {
|
|
29
|
+
doc: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv'],
|
|
30
|
+
code: ['js', 'mjs', 'cjs', 'ts', 'jsx', 'tsx', 'py', 'java', 'c', 'h', 'cpp', 'cc', 'cs', 'go', 'rs', 'rb', 'php', 'swift', 'kt', 'sh', 'bash', 'ps1', 'sql', 'r', 'lua', 'pl', 'log'],
|
|
31
|
+
config: ['json', 'yml', 'yaml', 'toml', 'ini', 'conf', 'cfg', 'env', 'properties', 'xml', 'html', 'css'],
|
|
32
|
+
}
|
|
22
33
|
|
|
23
34
|
/** 时间戳前缀:2026-02-11T15-04-05(去冒号/毫秒/Z,各平台文件名安全且可排序)。 */
|
|
24
35
|
function stamp() {
|
|
@@ -109,6 +120,48 @@ function cleanName(raw) {
|
|
|
109
120
|
return safe
|
|
110
121
|
}
|
|
111
122
|
|
|
123
|
+
/** 规范化一个扩展名列表:小写、去点、去重、仅保留 [a-z0-9];非法项丢弃。 */
|
|
124
|
+
function normalizeTypeList(arr) {
|
|
125
|
+
const out = []
|
|
126
|
+
const seen = new Set()
|
|
127
|
+
if (!Array.isArray(arr)) return out
|
|
128
|
+
for (const raw of arr) {
|
|
129
|
+
const s = String(raw ?? '').trim().toLowerCase().replace(/^\./u, '')
|
|
130
|
+
if (s === '') continue
|
|
131
|
+
if (!/^[a-z0-9]+$/u.test(s)) continue
|
|
132
|
+
if (seen.has(s)) continue
|
|
133
|
+
seen.add(s)
|
|
134
|
+
out.push(s)
|
|
135
|
+
}
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 读取配置;文件缺失/损坏时回退默认值(保持三类齐全)。 */
|
|
140
|
+
async function readConfig() {
|
|
141
|
+
try {
|
|
142
|
+
const text = await readFile(CONFIG_PATH, 'utf8')
|
|
143
|
+
const doc = JSON.parse(text)
|
|
144
|
+
return {
|
|
145
|
+
doc: normalizeTypeList(doc && doc.doc),
|
|
146
|
+
code: normalizeTypeList(doc && doc.code),
|
|
147
|
+
config: normalizeTypeList(doc && doc.config),
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
return { doc: [...DEFAULT_TYPES.doc], code: [...DEFAULT_TYPES.code], config: [...DEFAULT_TYPES.config] }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 保存配置(全量覆盖三类);返回规范化后的配置。 */
|
|
155
|
+
async function writeConfig(body) {
|
|
156
|
+
const next = {
|
|
157
|
+
doc: normalizeTypeList(body && body.doc),
|
|
158
|
+
code: normalizeTypeList(body && body.code),
|
|
159
|
+
config: normalizeTypeList(body && body.config),
|
|
160
|
+
}
|
|
161
|
+
await writeFile(CONFIG_PATH, JSON.stringify(next, null, 2) + '\n', 'utf8')
|
|
162
|
+
return next
|
|
163
|
+
}
|
|
164
|
+
|
|
112
165
|
/**
|
|
113
166
|
* 保存一个上传文件并返回落盘信息。
|
|
114
167
|
* @param root - 项目根(会话工作区)。
|
|
@@ -173,7 +226,21 @@ function registerRoutes(ctx) {
|
|
|
173
226
|
return
|
|
174
227
|
}
|
|
175
228
|
}
|
|
176
|
-
|
|
229
|
+
if (req.method === 'GET' && pathname === '/dsh-file-attachment/config') {
|
|
230
|
+
json(res, { ok: true, value: await readConfig() })
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
if (req.method === 'POST' && pathname === '/dsh-file-attachment/config') {
|
|
234
|
+
const body = await readJsonBody(req, 64 * 1024)
|
|
235
|
+
if (body === null || typeof body !== 'object') {
|
|
236
|
+
json(res, { ok: false, error: '请求体必须是 JSON' }, 400)
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
const value = await writeConfig(body)
|
|
240
|
+
json(res, { ok: true, value: { saved: true, config: value } })
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
json(res, { ok: false, error: 'only POST /dsh-file-attachment/save and GET/POST /dsh-file-attachment/config are allowed' }, 405)
|
|
177
244
|
},
|
|
178
245
|
})
|
|
179
246
|
}
|
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.3.3",
|
|
4
|
+
"description": "文件附件:输入框上传按钮 + 拖拽/粘贴文件(支持多文件);图片走 dsh 原生图片草稿机制(addImages,不缩放/不落盘),文档落盘到 .dsh-file-attachment 并以芯片 @短名 引用(发送时还原 @绝对路径);文档/代码/配置文件可上传类型可在设置页配置;支持 PC 与移动端浏览器",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
7
7
|
"plugin",
|