@wszhoho/dsh-file-attachment 0.1.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/cordis.patch.yml +4 -0
- package/lib/client.js +457 -0
- package/lib/index.js +187 -0
- package/package.json +39 -0
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// @local/dsh-fileAttachment 浏览器半(client 半,全部功能在此)。
|
|
2
|
+
// 拖放/粘贴文件进输入框:
|
|
3
|
+
// 1) 纯图片 —— 不拦截事件,原样交给既有图片拖放路径(行为零变化);
|
|
4
|
+
// 2) 文档(非图片文件)—— capture 阶段接管:浏览器端读取全文 → base64 →
|
|
5
|
+
// remote.fileAttachment.save 落盘到 <会话工作区>/.dsh-fileAttachment/ →
|
|
6
|
+
// 在草稿光标处插入 @绝对路径 引用;粘贴文件走同一核心;
|
|
7
|
+
// 3) 目录 —— 拒绝并 toast 提示「不支持拖入目录」(需求确认:不插引用);
|
|
8
|
+
// 4) 纯文本粘贴(含目录路径字符串)—— 完全原生,不做任何改写。
|
|
9
|
+
// 5) 拖入任何文件时在 capture 阶段拦截应用自带「拖入图片…」DropOverlay 浮层。
|
|
10
|
+
// 通知:shell.overlay 浮动 toast(frame-wide,fixed 定位,点击穿透,不破坏布局)。
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: '@local/dsh-fileAttachment',
|
|
13
|
+
factory: (require) => {
|
|
14
|
+
const module = { exports: {} }
|
|
15
|
+
const exports = module.exports
|
|
16
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
17
|
+
const react = require('react')
|
|
18
|
+
|
|
19
|
+
// Client 服务依赖:槽位 / 输入机 / 会话(与官方 dsh-upload-file 同范式)。
|
|
20
|
+
// 保存文件不走 remote,而是 fetch POST 宿主 webServer 路由 /dsh-fileAttachment/save。
|
|
21
|
+
const inject = [
|
|
22
|
+
"slots",
|
|
23
|
+
"conversation",
|
|
24
|
+
"sessions"
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
// ---- 模块级状态:document 监听器 / dock 槽(同步桥)/ overlay 槽(toast)共享 ----
|
|
28
|
+
let ctxRef = null // apply 时记下 ctx,供远程调用与降级判断
|
|
29
|
+
const bridge = {
|
|
30
|
+
actions: null, // InputActions(setDraft/addImages...),来自当前会话
|
|
31
|
+
input: undefined, // 最新 InputState(draft/phase...),随渲染刷新
|
|
32
|
+
conversation: undefined, // conversation 服务:登记草稿图片
|
|
33
|
+
mounted: false,
|
|
34
|
+
sessionId: '', // 当前会话 id,宿主据此解析工作区根
|
|
35
|
+
noticeText: null, // 当前 toast 文本(模块级广播)
|
|
36
|
+
noticeSub: null, // toast 组件订阅的 setter
|
|
37
|
+
}
|
|
38
|
+
let noticeTimer = null
|
|
39
|
+
const NOTICE_MS = 4000
|
|
40
|
+
const MAX_BYTES = 50 * 1024 * 1024
|
|
41
|
+
|
|
42
|
+
// ---- 通知广播:composer/dock 与 overlay toast 之间共享文本,自动消失 ----
|
|
43
|
+
function publishNotice(text) {
|
|
44
|
+
bridge.noticeText = text
|
|
45
|
+
if (bridge.noticeSub !== null) bridge.noticeSub(text)
|
|
46
|
+
}
|
|
47
|
+
function announce(text) {
|
|
48
|
+
publishNotice(text)
|
|
49
|
+
if (noticeTimer !== null) clearTimeout(noticeTimer)
|
|
50
|
+
noticeTimer = setTimeout(() => { noticeTimer = null; if (bridge.noticeText === text) publishNotice(null) }, NOTICE_MS)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- 宿主保存调用:fetch POST 宿主 webServer 路由(签名同 dsh-upload-file attach)----
|
|
54
|
+
async function saveFileToHost(fileName, b64, sessionId) {
|
|
55
|
+
if (typeof fetch !== 'function') throw new Error('当前环境无 fetch,无法上传')
|
|
56
|
+
let response
|
|
57
|
+
try {
|
|
58
|
+
response = await fetch('/dsh-fileAttachment/save', {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'content-type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({ name: fileName, data: b64, sessionId: sessionId }),
|
|
62
|
+
})
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error('无法连接宿主上传服务(网络错误)')
|
|
65
|
+
}
|
|
66
|
+
let envelope
|
|
67
|
+
try {
|
|
68
|
+
envelope = await response.json()
|
|
69
|
+
} catch (err) {
|
|
70
|
+
throw new Error('宿主返回非 JSON(HTTP ' + response.status + ')')
|
|
71
|
+
}
|
|
72
|
+
const record = envelope !== null && typeof envelope === 'object' ? envelope : null
|
|
73
|
+
if (record !== null && record.ok === true && record.value !== null && typeof record.value === 'object') {
|
|
74
|
+
const value = record.value
|
|
75
|
+
if (typeof value.path === 'string' && value.path !== '') return value
|
|
76
|
+
throw new Error('宿主保存返回缺少 path')
|
|
77
|
+
}
|
|
78
|
+
const errMsg = record !== null && typeof record.error === 'string' && record.error !== '' ? record.error : '宿主保存失败(HTTP ' + response.status + ')'
|
|
79
|
+
throw new Error(errMsg)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 复刻 dsh-file-reference 的 formatFileMention:
|
|
83
|
+
// 目录补尾斜杠;含不可表示字符返回 undefined;含空白走引号形式(目录引号保持开启)
|
|
84
|
+
function hasBadChar(p) {
|
|
85
|
+
for (let i = 0; i < p.length; i++) {
|
|
86
|
+
const c = p.charCodeAt(i)
|
|
87
|
+
if (c === 34 || c < 32 || (c >= 127 && c <= 159)) return true
|
|
88
|
+
}
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
// 等价于 /\s/u 的空白判定(含不可见分隔符)
|
|
92
|
+
function isSpace(c) {
|
|
93
|
+
return c === 9 || c === 10 || c === 11 || c === 12 || c === 13 || c === 32 || c === 160 || c === 5760
|
|
94
|
+
|| (c >= 8192 && c <= 8202) || c === 8232 || c === 8233 || c === 8239 || c === 8287 || c === 12288 || c === 65279
|
|
95
|
+
}
|
|
96
|
+
function formatMention(path, isDir) {
|
|
97
|
+
const p = isDir ? path + '/' : path
|
|
98
|
+
if (hasBadChar(p)) return undefined
|
|
99
|
+
for (let i = 0; i < p.length; i++) {
|
|
100
|
+
if (isSpace(p.charCodeAt(i))) return isDir ? '@"' + p : '@"' + p + '"'
|
|
101
|
+
}
|
|
102
|
+
return '@' + p
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 把浏览器提供的路径源(webkitGetAsEntry().fullPath / file.path)规范化为绝对路径
|
|
106
|
+
function isDrivePath(p) {
|
|
107
|
+
if (p.length < 3) return false
|
|
108
|
+
const c0 = p.charCodeAt(0); const c1 = p.charCodeAt(1); const c2 = p.charCodeAt(2)
|
|
109
|
+
const isLetter = (c0 >= 65 && c0 <= 90) || (c0 >= 97 && c0 <= 122)
|
|
110
|
+
return isLetter && c1 === 58 && (c2 === 47 || c2 === 92)
|
|
111
|
+
}
|
|
112
|
+
function resolveDropPath(raw) {
|
|
113
|
+
if (typeof raw !== 'string' || raw === '') return null
|
|
114
|
+
// Chrome 虚拟文件系统会给盘符路径加前导斜杠:/C:/... 或 /C:\...
|
|
115
|
+
let p = raw
|
|
116
|
+
if (p.length > 3 && p.charCodeAt(0) === 47 && p.charCodeAt(2) === 58
|
|
117
|
+
&& (p.charCodeAt(3) === 47 || p.charCodeAt(3) === 92)) p = p.slice(1)
|
|
118
|
+
if (isDrivePath(p) || (p.length > 0 && p.charCodeAt(0) === 47)) return p
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
// 取路径最后一段(等价 split(/[\\/]/).filter(Boolean).pop(),charCode 版防转义损坏)
|
|
122
|
+
function lastSegment(s) {
|
|
123
|
+
let seg = ''
|
|
124
|
+
let cur = ''
|
|
125
|
+
for (let i = 0; i < s.length; i++) {
|
|
126
|
+
const c = s.charCodeAt(i)
|
|
127
|
+
if (c === 47 || c === 92) { if (cur !== '') { seg = cur; cur = '' } }
|
|
128
|
+
else cur += s.charAt(i)
|
|
129
|
+
}
|
|
130
|
+
if (cur !== '') seg = cur
|
|
131
|
+
return seg
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 找可写的 composer textarea:可见、未禁用、未只读、phase 为 plain;
|
|
135
|
+
// 多个候选时优先 value 与桥内草稿一致的那一个(排除审批弹窗里的同名元素)
|
|
136
|
+
function findComposerTextarea() {
|
|
137
|
+
const list = Array.from(document.querySelectorAll('[data-composer-card] textarea'))
|
|
138
|
+
const live = list.filter((ta) => {
|
|
139
|
+
if (ta.disabled || ta.readOnly) return false
|
|
140
|
+
const phase = ta.getAttribute('data-phase')
|
|
141
|
+
if (phase !== null && phase !== 'plain') return false
|
|
142
|
+
return ta.getClientRects().length > 0
|
|
143
|
+
})
|
|
144
|
+
if (live.length === 0) return null
|
|
145
|
+
const draft = bridge.input !== void 0 ? bridge.input.draft : undefined
|
|
146
|
+
if (typeof draft === 'string') {
|
|
147
|
+
for (let i = 0; i < live.length; i++) {
|
|
148
|
+
if (live[i].value === draft) return live[i]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return live[0]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 在光标(或选区)处插入文本;setDraft 是唯一的草稿写路径
|
|
155
|
+
function insertTextAtCaret(ta, actions, text) {
|
|
156
|
+
const draft = ta.value
|
|
157
|
+
let start = ta.selectionStart
|
|
158
|
+
let end = ta.selectionEnd
|
|
159
|
+
if (typeof start !== 'number' || start < 0 || start > draft.length) start = draft.length
|
|
160
|
+
if (typeof end !== 'number' || end < start) end = start
|
|
161
|
+
const before = draft.slice(0, start)
|
|
162
|
+
const after = draft.slice(end)
|
|
163
|
+
const padStart = before.length > 0 && !isSpace(before.charCodeAt(before.length - 1)) ? ' ' : ''
|
|
164
|
+
const padEnd = after.length > 0 && !isSpace(after.charCodeAt(0)) ? ' ' : ''
|
|
165
|
+
const next = before + padStart + text + padEnd + after
|
|
166
|
+
const caret = start + padStart.length + text.length + padEnd.length
|
|
167
|
+
actions.setDraft(next)
|
|
168
|
+
// React 重渲染会覆盖光标,下一帧校验写入成功后回填 selectionRange(同既有输入路径模式)
|
|
169
|
+
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
|
170
|
+
window.requestAnimationFrame(() => {
|
|
171
|
+
if (ta.value !== next) return // 写入被输入机拒绝,不动光标
|
|
172
|
+
try {
|
|
173
|
+
ta.focus({ preventScroll: true })
|
|
174
|
+
ta.setSelectionRange(caret, caret)
|
|
175
|
+
} catch (err) { /* 忽略:光标回填失败不影响草稿内容 */ }
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function hasFiles(dataTransfer) {
|
|
181
|
+
const types = dataTransfer && dataTransfer.types
|
|
182
|
+
if (!types) return false
|
|
183
|
+
for (let i = 0; i < types.length; i++) if (types[i] === 'Files') return true
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 手动 3 字节 base64(不依赖 btoa,纯 JS;50MB 上限内拼接安全)
|
|
188
|
+
const B64C = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
189
|
+
function bytesToBase64(bytes) {
|
|
190
|
+
let out = ''
|
|
191
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
192
|
+
const a = bytes[i]
|
|
193
|
+
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
|
|
194
|
+
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
|
|
195
|
+
out += B64C.charAt(a >> 2)
|
|
196
|
+
out += B64C.charAt(((a & 3) << 4) | (b >> 4))
|
|
197
|
+
out += i + 1 < bytes.length ? B64C.charAt(((b & 15) << 2) | (c >> 6)) : '='
|
|
198
|
+
out += i + 2 < bytes.length ? B64C.charAt(c & 63) : '='
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 触发应用自身 drop 浮层的关闭:应用在 window 上监听 dragend 执行 reset()。
|
|
204
|
+
// 从 OS 拖入时浏览器不派发 dragend,且我们拦截 drop 后应用的 drop 监听收不到事件,浮层会残留。
|
|
205
|
+
function dismissDropOverlay() {
|
|
206
|
+
try { window.dispatchEvent(new Event('dragend')) } catch (err) { /* 尽力而为 */ }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// capture 阶段拦截 dragenter:应用自身 DropOverlay 文案固定为「拖入图片…」,
|
|
210
|
+
// 拖入任何文件都会误提示;这里阻止应用注册的 dragenter 监听执行。
|
|
211
|
+
function onDragEnterCap(e) {
|
|
212
|
+
const dt = e.dataTransfer
|
|
213
|
+
if (dt === null || dt === undefined || !hasFiles(dt)) return
|
|
214
|
+
e.preventDefault()
|
|
215
|
+
e.stopImmediatePropagation()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 拖放/粘贴文件共用核心。source:'drop' | 'paste'
|
|
219
|
+
// 返回是否已接管事件(false = 纯图片拖放,原样交给既有图片路径)
|
|
220
|
+
function handleFilesEvent(e, files, items, source) {
|
|
221
|
+
const images = []
|
|
222
|
+
const docs = [] // { path, name, isDir, file }
|
|
223
|
+
for (let i = 0; i < files.length; i++) {
|
|
224
|
+
const file = files[i]
|
|
225
|
+
if (file.type && file.type.indexOf('image/') === 0) { images.push(file); continue }
|
|
226
|
+
let entry = null
|
|
227
|
+
const item = items ? items[i] : null
|
|
228
|
+
if (item && typeof item.webkitGetAsEntry === 'function') {
|
|
229
|
+
try { entry = item.webkitGetAsEntry() } catch (err) { entry = null }
|
|
230
|
+
}
|
|
231
|
+
let raw = entry ? entry.fullPath : null
|
|
232
|
+
if (raw === null || raw === undefined) raw = file.path !== void 0 ? file.path : null
|
|
233
|
+
const isDir = !!(entry && entry.isDirectory)
|
|
234
|
+
let name = file.name
|
|
235
|
+
if (name === '' && typeof raw === 'string') { const seg = lastSegment(raw); if (seg !== '') name = seg }
|
|
236
|
+
docs.push({ path: resolveDropPath(raw), name, isDir, file })
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 纯图片拖放:完全不碰事件,交给既有图片路径(零行为变化)
|
|
240
|
+
if (docs.length === 0 && source === 'drop') return false
|
|
241
|
+
|
|
242
|
+
// 含文档(或纯图片粘贴:应用内无既有 paste 路径,按同一草稿图片机制登记)—— 完全接管
|
|
243
|
+
e.preventDefault()
|
|
244
|
+
e.stopImmediatePropagation()
|
|
245
|
+
dismissDropOverlay()
|
|
246
|
+
void runBatch(images, docs)
|
|
247
|
+
return true
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 异步批处理:文档读全文→宿主落盘→@引用;目录拒绝;图片沿用原有流程
|
|
251
|
+
async function runBatch(images, docs) {
|
|
252
|
+
const actions = bridge.actions
|
|
253
|
+
if (actions === null || typeof actions.setDraft !== 'function') {
|
|
254
|
+
announce('无法插入文件引用:当前没有可用的输入框')
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
const phase = bridge.input !== void 0 ? bridge.input.phase : 'plain'
|
|
258
|
+
if (phase !== 'plain') {
|
|
259
|
+
announce('输入框正忙,完成当前操作后再试')
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
const mentions = []
|
|
263
|
+
const savedNames = []
|
|
264
|
+
const dirRejected = []
|
|
265
|
+
let savedDir = ''
|
|
266
|
+
let savedCount = 0
|
|
267
|
+
let skipped = 0
|
|
268
|
+
let lastErr = ''
|
|
269
|
+
for (let i = 0; i < docs.length; i++) {
|
|
270
|
+
const d = docs[i]
|
|
271
|
+
if (d.isDir) {
|
|
272
|
+
dirRejected.push(d.name !== '' ? d.name : '(未命名目录)')
|
|
273
|
+
skipped++
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
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()
|
|
280
|
+
const b64 = bytesToBase64(new Uint8Array(buf))
|
|
281
|
+
const res = await saveFileToHost(d.name, b64, bridge.sessionId)
|
|
282
|
+
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
|
|
288
|
+
} catch (err) {
|
|
289
|
+
lastErr = err && err.message ? err.message : String(err)
|
|
290
|
+
skipped++
|
|
291
|
+
}
|
|
292
|
+
}
|
|
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 + ' 个)')
|
|
325
|
+
return
|
|
326
|
+
}
|
|
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)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// document 级 drop(capture):保证先于图片路径的 document 级 bubble 监听器执行
|
|
350
|
+
function onDrop(e) {
|
|
351
|
+
const dt = e.dataTransfer
|
|
352
|
+
if (!dt || !hasFiles(dt)) return
|
|
353
|
+
const files = Array.prototype.slice.call(dt.files)
|
|
354
|
+
if (files.length === 0) return
|
|
355
|
+
handleFilesEvent(e, files, dt.items, 'drop')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// document 级 paste(capture):仅当粘贴目标就是 composer textarea 时接管文件;
|
|
359
|
+
// 纯文本(含目录路径字符串)完全原生,不改写。
|
|
360
|
+
function onPaste(e) {
|
|
361
|
+
const t = e.target
|
|
362
|
+
const ta = findComposerTextarea()
|
|
363
|
+
if (ta === null || t !== ta) return
|
|
364
|
+
const cd = e.clipboardData
|
|
365
|
+
if (cd === null || cd === void 0) return
|
|
366
|
+
const files = Array.prototype.slice.call(cd.files)
|
|
367
|
+
if (files.length > 0) {
|
|
368
|
+
handleFilesEvent(e, files, cd.items, 'paste')
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
// 纯文本:不干预,走原生行为
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// shell.overlay 槽:frame-wide 浮动层(官方定位:badge / toast / status pill)。
|
|
375
|
+
// 通知在此渲染为底部浮动 toast,自动消失、点击穿透、不破坏布局。
|
|
376
|
+
function FaToast(props) {
|
|
377
|
+
const state = react.useState(null)
|
|
378
|
+
const text = state[0]
|
|
379
|
+
const setText = state[1]
|
|
380
|
+
react.useEffect(() => {
|
|
381
|
+
if (bridge.noticeText !== null) setText(bridge.noticeText)
|
|
382
|
+
bridge.noticeSub = setText
|
|
383
|
+
return () => { if (bridge.noticeSub === setText) bridge.noticeSub = null }
|
|
384
|
+
})
|
|
385
|
+
if (text === null || text === '') return null
|
|
386
|
+
return react.createElement('div', {
|
|
387
|
+
style: {
|
|
388
|
+
position: 'fixed',
|
|
389
|
+
bottom: '24px',
|
|
390
|
+
left: '50%',
|
|
391
|
+
transform: 'translateX(-50%)',
|
|
392
|
+
zIndex: 2000,
|
|
393
|
+
maxWidth: 'min(640px, calc(100vw - 48px))',
|
|
394
|
+
background: 'rgba(20, 24, 35, 0.92)',
|
|
395
|
+
color: '#f2f4f8',
|
|
396
|
+
padding: '10px 16px',
|
|
397
|
+
borderRadius: '10px',
|
|
398
|
+
fontSize: '13px',
|
|
399
|
+
lineHeight: '1.5',
|
|
400
|
+
boxShadow: '0 6px 24px rgba(0, 0, 0, 0.28)',
|
|
401
|
+
pointerEvents: 'none',
|
|
402
|
+
},
|
|
403
|
+
}, text)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// composer.dock 槽:仅同步桥(actions/input/sessionId/conversation),不渲染可见文本。
|
|
407
|
+
function FaBridge(props) {
|
|
408
|
+
const actions = props.inputActions !== void 0 ? props.inputActions : null
|
|
409
|
+
const input = props.input !== void 0 ? props.input : undefined
|
|
410
|
+
const sessionId = props.sessionId !== void 0 ? props.sessionId : ''
|
|
411
|
+
react.useEffect(() => {
|
|
412
|
+
bridge.actions = actions
|
|
413
|
+
bridge.input = input
|
|
414
|
+
bridge.sessionId = sessionId
|
|
415
|
+
bridge.mounted = true
|
|
416
|
+
return () => {
|
|
417
|
+
bridge.actions = null
|
|
418
|
+
bridge.input = undefined
|
|
419
|
+
bridge.sessionId = ''
|
|
420
|
+
bridge.mounted = false
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
return null
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function apply(ctx) {
|
|
427
|
+
ctxRef = ctx
|
|
428
|
+
const slots = ctx.get('slots')
|
|
429
|
+
if (slots !== undefined) {
|
|
430
|
+
slots.inject('conversation.composer.dock', () => slots.register(
|
|
431
|
+
{ name: 'conversation.composer.dock', id: 'dsh-fileAttachment', order: 300 },
|
|
432
|
+
FaBridge
|
|
433
|
+
))
|
|
434
|
+
slots.inject('shell.overlay', () => slots.register(
|
|
435
|
+
{ name: 'shell.overlay', id: 'dsh-fileAttachment-toast', order: 100 },
|
|
436
|
+
FaToast
|
|
437
|
+
))
|
|
438
|
+
}
|
|
439
|
+
bridge.conversation = ctx.get('conversation')
|
|
440
|
+
// document 级 capture 监听:dragenter/drop/paste 都先于既有 bubble 监听器;随 Fiber 停止
|
|
441
|
+
ctx.effect(() => {
|
|
442
|
+
document.addEventListener('dragenter', onDragEnterCap, true)
|
|
443
|
+
document.addEventListener('drop', onDrop, true)
|
|
444
|
+
document.addEventListener('paste', onPaste, true)
|
|
445
|
+
return () => {
|
|
446
|
+
document.removeEventListener('dragenter', onDragEnterCap, true)
|
|
447
|
+
document.removeEventListener('drop', onDrop, true)
|
|
448
|
+
document.removeEventListener('paste', onPaste, true)
|
|
449
|
+
}
|
|
450
|
+
}, 'dsh-fileAttachment: document dragenter/drop/paste listeners')
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
exports.apply = apply
|
|
454
|
+
exports.inject = inject
|
|
455
|
+
return module.exports
|
|
456
|
+
}
|
|
457
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// @local/dsh-fileAttachment Host 半:webServer HTTP 路由方案(参考 dsh-upload-file 同架构)。
|
|
2
|
+
// Client 半读取文件全文(base64)后 POST /dsh-fileAttachment/save 到这里,
|
|
3
|
+
// 写入 <会话工作区根>/.dsh-fileAttachment/<时间戳>-<文件名> 并返回 { path, dir, name, size }。
|
|
4
|
+
// 项目根解析:Client 传 sessionId → sessions.get(sessionId).header.cwd →
|
|
5
|
+
// sandboxPolicy.workspaceRoot → process.cwd()(三平台绝对路径,Node path.join 自适应分隔符)。
|
|
6
|
+
// 纯 ESM 无构建:不依赖 TypertRemoteService/装饰器,原生 Node 可直接加载。
|
|
7
|
+
import { access, mkdir, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
/** 本包声明依赖的 Host 服务。 */
|
|
11
|
+
export const name = 'dsh-fileAttachment'
|
|
12
|
+
export const inject = ['webServer']
|
|
13
|
+
|
|
14
|
+
// 临时目录名:落在会话工作区根下;每个项目都能看到自己引用过的文档
|
|
15
|
+
const ATTACHMENT_DIR = '.dsh-fileAttachment'
|
|
16
|
+
// 单文件上限 50MB(与 Client 半一致)
|
|
17
|
+
const MAX_BYTES = 50 * 1024 * 1024
|
|
18
|
+
// 请求体上限:base64(50MB) + 信封余量
|
|
19
|
+
const MAX_BODY_BYTES = 68 * 1024 * 1024
|
|
20
|
+
// 文件名非法字符:路径分隔符、Windows 保留字符、控制字符
|
|
21
|
+
const BAD_CHARS = /[/\\:*?"<>|\u0000-\u001f\u007f]/gu
|
|
22
|
+
|
|
23
|
+
/** 时间戳前缀:2026-02-11T15-04-05(去冒号/毫秒/Z,各平台文件名安全且可排序)。 */
|
|
24
|
+
function stamp() {
|
|
25
|
+
return new Date().toISOString().slice(0, 19).replace(/[:.]/gu, '-')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function fileExists(target) {
|
|
29
|
+
try {
|
|
30
|
+
await access(target)
|
|
31
|
+
return true
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 解析项目根:会话 cwd → sandboxPolicy.workspaceRoot → process.cwd()。 */
|
|
38
|
+
function projectRoot(ctx, sessionId) {
|
|
39
|
+
const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : undefined
|
|
40
|
+
if (sessions !== undefined && typeof sessions.get === 'function' && typeof sessionId === 'string' && sessionId !== '') {
|
|
41
|
+
try {
|
|
42
|
+
const sess = sessions.get(sessionId)
|
|
43
|
+
if (sess !== undefined && sess.header !== undefined && typeof sess.header.cwd === 'string' && sess.header.cwd !== '') {
|
|
44
|
+
return sess.header.cwd
|
|
45
|
+
}
|
|
46
|
+
} catch (err) { /* 回退下一来源 */ }
|
|
47
|
+
}
|
|
48
|
+
if (typeof ctx.get === 'function') {
|
|
49
|
+
const sp = ctx.get('sandboxPolicy')
|
|
50
|
+
if (sp !== undefined && typeof sp.workspaceRoot === 'string' && sp.workspaceRoot !== '') {
|
|
51
|
+
return sp.workspaceRoot
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return process.cwd()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function json(res, payload, status = 200) {
|
|
58
|
+
const body = JSON.stringify(payload)
|
|
59
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) })
|
|
60
|
+
res.end(body)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 读取 JSON 请求体(带上限)。 */
|
|
64
|
+
async function readJsonBody(req, maxBytes) {
|
|
65
|
+
let size = 0
|
|
66
|
+
let data = ''
|
|
67
|
+
const decoder = new TextDecoder('utf-8')
|
|
68
|
+
for await (const chunk of req) {
|
|
69
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
70
|
+
size += buf.length
|
|
71
|
+
if (size > maxBytes) return null
|
|
72
|
+
data += decoder.decode(buf, { stream: true })
|
|
73
|
+
}
|
|
74
|
+
data += decoder.decode()
|
|
75
|
+
if (data === '') return null
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(data)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 严格 base64 解码(拒绝畸形输入)。 */
|
|
84
|
+
function decodeBase64(encoded) {
|
|
85
|
+
if (typeof encoded !== 'string' || encoded === '') return undefined
|
|
86
|
+
if (encoded.length % 4 !== 0) return undefined
|
|
87
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) return undefined
|
|
88
|
+
const bytes = Buffer.from(encoded, 'base64')
|
|
89
|
+
return bytes
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 清洗文件名:只取 last segment、去非法字符、空名 → file。 */
|
|
93
|
+
function cleanName(raw) {
|
|
94
|
+
let s = raw === undefined || raw === null ? '' : String(raw)
|
|
95
|
+
while (s.length > 0) {
|
|
96
|
+
const c = s.charCodeAt(s.length - 1)
|
|
97
|
+
if (c === 47 || c === 92) s = s.slice(0, -1)
|
|
98
|
+
else break
|
|
99
|
+
}
|
|
100
|
+
let seg = ''
|
|
101
|
+
let cur = ''
|
|
102
|
+
for (let i = 0; i < s.length; i++) {
|
|
103
|
+
const c = s.charCodeAt(i)
|
|
104
|
+
if (c === 47 || c === 92) { if (cur !== '') { seg = cur; cur = '' } }
|
|
105
|
+
else cur += s.charAt(i)
|
|
106
|
+
}
|
|
107
|
+
if (cur !== '') seg = cur
|
|
108
|
+
const safe = seg.replace(BAD_CHARS, '_').trim() || 'file'
|
|
109
|
+
return safe
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 保存一个上传文件并返回落盘信息。
|
|
114
|
+
* @param root - 项目根(会话工作区)。
|
|
115
|
+
* @param body - { name, data, sessionId }。
|
|
116
|
+
* @returns { path, dir, name, size }。
|
|
117
|
+
*/
|
|
118
|
+
async function handleSave(root, body) {
|
|
119
|
+
const name = body !== null && typeof body === 'object' ? body.name : undefined
|
|
120
|
+
const data = body !== null && typeof body === 'object' ? body.data : undefined
|
|
121
|
+
if (typeof data !== 'string' || data === '') throw new Error('data 必须是非空 base64 字符串')
|
|
122
|
+
const bytes = decodeBase64(data)
|
|
123
|
+
if (bytes === undefined) throw new Error('base64 内容非法')
|
|
124
|
+
if (bytes.length === 0) throw new Error('解码后内容为空')
|
|
125
|
+
if (bytes.length > MAX_BYTES) throw new Error('文件超过 ' + Math.round(MAX_BYTES / 1024 / 1024) + 'MB 上限')
|
|
126
|
+
|
|
127
|
+
const dir = join(root, ATTACHMENT_DIR)
|
|
128
|
+
await mkdir(dir, { recursive: true })
|
|
129
|
+
const safe = cleanName(name)
|
|
130
|
+
const t = stamp()
|
|
131
|
+
const dot = safe.lastIndexOf('.')
|
|
132
|
+
const stem = dot > 0 ? safe.slice(0, dot) : safe
|
|
133
|
+
const ext = dot > 0 ? safe.slice(dot) : ''
|
|
134
|
+
// 同一秒内重复保存同名文件时追加序号,避免覆盖
|
|
135
|
+
let target = join(dir, t + '-' + safe)
|
|
136
|
+
for (let i = 1; await fileExists(target); i += 1) {
|
|
137
|
+
target = join(dir, t + '-' + stem + '-' + i + ext)
|
|
138
|
+
}
|
|
139
|
+
await writeFile(target, bytes)
|
|
140
|
+
return { path: target, dir, name: t + '-' + safe, size: bytes.length }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 注册 /dsh-fileAttachment 前缀路由(save POST)。 */
|
|
144
|
+
function registerRoutes(ctx) {
|
|
145
|
+
const webserver = ctx.get('webServer')
|
|
146
|
+
if (webserver === undefined) return
|
|
147
|
+
webserver.register({
|
|
148
|
+
kind: 'prefix',
|
|
149
|
+
path: '/dsh-fileAttachment',
|
|
150
|
+
handler: async (req, res) => {
|
|
151
|
+
const pathname = new URL(req.url ?? '/', 'http://x').pathname
|
|
152
|
+
if (req.method === 'POST' && pathname === '/dsh-fileAttachment/save') {
|
|
153
|
+
const body = await readJsonBody(req, MAX_BODY_BYTES)
|
|
154
|
+
if (body === null || typeof body !== 'object') {
|
|
155
|
+
json(res, { ok: false, error: '请求体必须是 JSON(68MB 内)' }, 400)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
|
|
159
|
+
let root
|
|
160
|
+
try {
|
|
161
|
+
root = projectRoot(ctx, sessionId)
|
|
162
|
+
} catch (err) {
|
|
163
|
+
json(res, { ok: false, error: '无法解析项目根' }, 500)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const value = await handleSave(root, body)
|
|
168
|
+
json(res, { ok: true, value })
|
|
169
|
+
return
|
|
170
|
+
} catch (err) {
|
|
171
|
+
const message = err && err.message ? err.message : '保存失败'
|
|
172
|
+
json(res, { ok: false, error: message }, 400)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
json(res, { ok: false, error: 'only POST /dsh-fileAttachment/save is allowed' }, 405)
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @param ctx - 宿主上下文(webServer 注入)。
|
|
183
|
+
*/
|
|
184
|
+
export function apply(ctx, config = {}) {
|
|
185
|
+
registerRoutes(ctx)
|
|
186
|
+
console.log('[dsh-fileAttachment] host loaded (webServer routes)')
|
|
187
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wszhoho/dsh-file-attachment",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "拖拽/粘贴文件进输入框:图片沿用原有草稿图片流程;文档读取内容存入项目根 .dsh-fileAttachment 临时目录并插入 @绝对路径引用",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"plugin",
|
|
8
|
+
"file-attachment",
|
|
9
|
+
"upload",
|
|
10
|
+
"drag-drop"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "lib/index.js",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./lib/index.js",
|
|
16
|
+
"./client": "./lib/client.js",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"dsh": {
|
|
20
|
+
"bundle": {
|
|
21
|
+
"patch": "./cordis.patch.yml"
|
|
22
|
+
},
|
|
23
|
+
"client": {
|
|
24
|
+
"inject": [],
|
|
25
|
+
"platform": "web"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib",
|
|
30
|
+
"cordis.patch.yml"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"registry": "https://registry.npmjs.org/"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT"
|
|
39
|
+
}
|