@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 CHANGED
@@ -2,16 +2,29 @@
2
2
 
3
3
  DeepSeek Harness (dsh) web GUI 插件:在会话输入框中**拖入或 Ctrl+V 粘贴文档(支持多文件,一次可拖入/粘贴多个文件)**时,浏览器端批量读取全文,保存到**当前会话工作区根目录下的 `.dsh-file-attachment/`**,并在输入框光标处插入 `@<保存副本绝对路径>` 引用。agent 读取 `@路径` 时直接读到落盘副本,不再依赖原始文件位置。
4
4
 
5
- 图片文件完全沿用 dsh web 既有草稿图片流程,不拦截、不改动。
5
+ 图片文件由插件接管后走 dsh web 既有草稿图片流程(`addImages`):不落盘、不进文件条、不缩放;仅当输入框未就绪且为纯图片时,才原样交回原生。
6
+
7
+ 另外,输入框工具栏(加号之后)提供了一个**上传按钮(↑ 向上箭头图标)**,点击后弹出系统文件选择框,可多选文档/图片,复用与拖入/粘贴完全一致的落盘 + `@路径` 插入管线。PC 与移动端浏览器均适用(移动端走原生文件/拍照选择器)。
8
+
9
+ ## 界面
10
+
11
+ **上传按钮**:输入框工具栏 `+` 之后的 `↑` 图标,点击弹出系统文件选择框(可多选文档/图片)。
12
+
13
+ ![上传按钮](docs/upload-button.png)
14
+
15
+ **待上传状态**:文档落盘后,输入框上方 dock 列出待发送附件(点 `×` 移除),输入框内以 `@路径` 引用 chip 形式内联展示。
16
+
17
+ ![待上传状态](docs/pending-attachment.png)
6
18
 
7
19
  ## 行为一览
8
20
 
9
21
  | 操作 | 行为 |
10
22
  | --- | --- |
11
- | 拖入/粘贴 **文档**(docx/xlsx/pdf/md/txt/rpk 等非图片文件) | 浏览器读全文base64 宿主落盘 `<会话工作区>/.dsh-file-attachment/<时间戳>-<文件名>`光标插入 `@绝对路径`,底部 toast 提示保存目录 |
12
- | 一次拖入/粘贴 **多个文件**(支持多文件) | 批量处理:逐个读取全文 → base64 → 落盘在光标处一次性插入全部 `@` 引用,toast 汇总保存数量与目录 |
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
- | 拖入/粘贴 **图片** | 不拦截,原样走 dsh web 既有草稿图片流程(零变化) |
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.composer.dock`(list 槽,同步桥状态,不渲染文本)+ `shell.overlay`(list 槽,toast 通知展示),document 级 capture 监听先于应用 bubble 监听执行。
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` 使用 **UTF-8 with BOM**(项目约定);`package.json`、`cordis.patch.yml` 使用 **UTF-8 无 BOM**。
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 (docs.length === 0 && source === 'drop') return false
241
-
242
- // 含文档(或纯图片粘贴:应用内无既有 paste 路径,按同一草稿图片机制登记)—— 完全接管
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 actions = bridge.actions
253
- if (actions === null || typeof actions.setDraft !== 'function') {
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 phase = bridge.input !== void 0 ? bridge.input.phase : 'plain'
258
- if (phase !== 'plain') {
259
- announce('输入框正忙,完成当前操作后再试')
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
- const mentions = []
263
- const savedNames = []
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
- if (d.isDir) {
272
- dirRejected.push(d.name !== '' ? d.name : '(未命名目录)')
273
- skipped++
274
- continue
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
- const f = d.file
278
- if (f === null || typeof f.size !== 'number' || f.size > MAX_BYTES) throw new Error('size')
279
- const buf = await f.arrayBuffer()
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(d.name, b64, bridge.sessionId)
448
+ const res = await saveFileToHost(name, b64, bridge.sessionId)
282
449
  const m = formatMention(res.path, false)
283
- if (m === undefined) throw new Error('path')
284
- mentions.push(m)
285
- savedCount++
286
- if (typeof res.name === 'string' && res.name !== '') savedNames.push(res.name)
287
- if (typeof res.dir === 'string' && res.dir !== '') savedDir = res.dir
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
- lastErr = err && err.message ? err.message : String(err)
290
- skipped++
470
+ failed += 1
291
471
  }
292
472
  }
293
-
294
- let inserted = 0
295
- if (mentions.length > 0) {
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
- let text = ''
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 槽:仅同步桥(actions/input/sessionId/conversation),不渲染可见文本。
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.actions = actions
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.actions = null
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
- slots.inject('conversation.composer.dock', () => slots.register(
431
- { name: 'conversation.composer.dock', id: 'dsh-file-attachment', order: 300 },
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 { join } from 'node:path'
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
- json(res, { ok: false, error: 'only POST /dsh-file-attachment/save is allowed' }, 405)
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.2.1",
4
- "description": "拖拽/粘贴文件(支持多文件)进输入框:图片沿用原有草稿图片流程;文档读取内容存入项目根 .dsh-file-attachment 临时目录并插入 @绝对路径引用",
3
+ "version": "0.3.3",
4
+ "description": "文件附件:输入框上传按钮 + 拖拽/粘贴文件(支持多文件);图片走 dsh 原生图片草稿机制(addImages,不缩放/不落盘),文档落盘到 .dsh-file-attachment 并以芯片 @短名 引用(发送时还原 @绝对路径);文档/代码/配置文件可上传类型可在设置页配置;支持 PC 与移动端浏览器",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "plugin",