dsh-long-plugins 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/client/client.js +2821 -0
- package/client/vendor/chart.umd.min.js +20 -0
- package/client/vendor/docx-preview.min.js +8 -0
- package/client/vendor/jszip.min.js +13 -0
- package/client/vendor/pptxviewjs.min.js +1 -0
- package/cordis.patch.yml +12 -0
- package/dsh.plugin.json +14 -0
- package/lib/index.js +2027 -0
- package/lib/md2docx.py +210 -0
- package/package.json +58 -0
- package/patches/dsh-client-connection-heartbeat.sh +107 -0
- package/skill/dsh-common-plugins-install/SKILL.md +128 -0
- package/skill/dsh-long-plugins-install/SKILL.md +200 -0
- package/skill/dsh-upgrade/SKILL.md +89 -0
- package/skill/dsh-web-start-panel-install/SKILL.md +349 -0
- package/skill/dsh-web-win-service-install/SKILL.md +243 -0
package/client/client.js
ADDED
|
@@ -0,0 +1,2821 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-long-plugins',
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
const module = { exports: {} }
|
|
5
|
+
const exports = module.exports
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
7
|
+
|
|
8
|
+
// ===== dsh-file-uploads: upload manager + workspace 输出文件 section =====
|
|
9
|
+
const uploadPlugin = (() => {
|
|
10
|
+
|
|
11
|
+
const module = { exports: {} }
|
|
12
|
+
const exports = module.exports
|
|
13
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
14
|
+
|
|
15
|
+
const React = require('react')
|
|
16
|
+
const API_PATH = '/api/dsh-uploads'
|
|
17
|
+
const DOWNLOAD_PATH = '/api/dsh-uploads/download'
|
|
18
|
+
const PREVIEW_PATH = '/api/dsh-uploads/preview'
|
|
19
|
+
const SOURCE = 'local-upload-files'
|
|
20
|
+
const HIDDEN_LABEL = '__dsh_upload_hidden__:'
|
|
21
|
+
|
|
22
|
+
function errorMessage(error) {
|
|
23
|
+
return error instanceof Error ? error.message : String(error)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function responseJson(response) {
|
|
27
|
+
const body = await response.json().catch(() => ({}))
|
|
28
|
+
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`)
|
|
29
|
+
return body
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function uploadFile(file) {
|
|
33
|
+
const response = await fetch(API_PATH, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: {
|
|
36
|
+
'content-type': 'application/octet-stream',
|
|
37
|
+
'x-file-name': encodeURIComponent(file.name),
|
|
38
|
+
},
|
|
39
|
+
body: file,
|
|
40
|
+
})
|
|
41
|
+
return (await responseJson(response)).file
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function downloadUrl(name) {
|
|
45
|
+
return `${DOWNLOAD_PATH}?name=${encodeURIComponent(name)}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function previewUrl(name) {
|
|
49
|
+
return `${PREVIEW_PATH}?name=${encodeURIComponent(name)}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Extensions the browser can render inline (image/office handled separately). */
|
|
53
|
+
const INLINE_PREVIEW_EXTS = new Set([
|
|
54
|
+
'.pdf', '.txt', '.md', '.markdown', '.json', '.yml', '.yaml', '.xml', '.html', '.htm',
|
|
55
|
+
'.csv', '.tsv', '.log', '.ini', '.conf', '.env', '.toml', '.rtf',
|
|
56
|
+
'.py', '.js', '.mjs', '.cjs', '.ts', '.sh', '.css', '.sql', '.rs', '.go', '.c', '.h', '.cpp',
|
|
57
|
+
'.java', '.kt', '.swift', '.rb', '.php', '.vue', '.jsx', '.tsx',
|
|
58
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif',
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
/** Whether a file can be previewed inline in the browser. */
|
|
62
|
+
function isInlinePreviewable(name) {
|
|
63
|
+
return INLINE_PREVIEW_EXTS.has(extnameOf(name).toLowerCase())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Extension of a file name (with dot), or '' when none. */
|
|
67
|
+
function extnameOf(name) {
|
|
68
|
+
const i = String(name).lastIndexOf('.')
|
|
69
|
+
return i > 0 ? String(name).slice(i) : ''
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Upper-case type label for a file name (e.g. 'PDF', 'DOCX', 'MD'), or 'FILE' when unknown. */
|
|
73
|
+
function fileTypeLabel(name) {
|
|
74
|
+
const ext = extnameOf(name).replace(/^\./, '')
|
|
75
|
+
return ext ? ext.toUpperCase() : 'FILE'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Basename of a path without its final extension (keeps the folder prefix intact). */
|
|
79
|
+
function basenameWithoutExt(path) {
|
|
80
|
+
const base = String(path).split(/[\\/]/).pop() || String(path)
|
|
81
|
+
return base.replace(/\.[^.]+$/, '')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Trigger a browser download for a URL (no navigation, keeps the page). */
|
|
85
|
+
function triggerDownload(url, name) {
|
|
86
|
+
const a = document.createElement('a')
|
|
87
|
+
a.href = url
|
|
88
|
+
a.download = name || ''
|
|
89
|
+
a.style.display = 'none'
|
|
90
|
+
document.body.appendChild(a)
|
|
91
|
+
a.click()
|
|
92
|
+
document.body.removeChild(a)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function modelLine(file) {
|
|
96
|
+
return `上传文件:\`${file.path}\``
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function serializedFile(file) {
|
|
100
|
+
return `\n${modelLine(file)}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function stripSerializedFiles(draft, files) {
|
|
104
|
+
const lines = new Set(files.map(modelLine))
|
|
105
|
+
return String(draft || '')
|
|
106
|
+
.split('\n')
|
|
107
|
+
.filter((line) => !lines.has(line.trim()))
|
|
108
|
+
.join('\n')
|
|
109
|
+
.replace(/^\n+|\n+$/g, '')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function legacyUploadPaths(draft) {
|
|
113
|
+
const paths = []
|
|
114
|
+
for (const line of String(draft || '').split('\n')) {
|
|
115
|
+
const match = line.trim().match(/^上传文件:\s*`([^`]+)`$/)
|
|
116
|
+
if (match) paths.push(match[1])
|
|
117
|
+
}
|
|
118
|
+
return paths
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function stripLegacyPaths(draft, paths) {
|
|
122
|
+
const remove = new Set(paths)
|
|
123
|
+
return String(draft || '')
|
|
124
|
+
.split('\n')
|
|
125
|
+
.filter((line) => {
|
|
126
|
+
const match = line.trim().match(/^上传文件:\s*`([^`]+)`$/)
|
|
127
|
+
return !match || !remove.has(match[1])
|
|
128
|
+
})
|
|
129
|
+
.join('\n')
|
|
130
|
+
.replace(/^\n+|\n+$/g, '')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class FileDraftController {
|
|
134
|
+
constructor(ctx) {
|
|
135
|
+
this.ctx = ctx
|
|
136
|
+
this.pending = new Map()
|
|
137
|
+
this.inFlight = new Map()
|
|
138
|
+
this.refIndex = new Map()
|
|
139
|
+
this.listeners = new Map()
|
|
140
|
+
this.expiry = new Map()
|
|
141
|
+
this.serializing = new Set()
|
|
142
|
+
this.migrated = new Set()
|
|
143
|
+
this.sinkHooked = new Set()
|
|
144
|
+
this.counter = 0
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
pendingFor(sessionId) {
|
|
148
|
+
return this.pending.get(String(sessionId)) || []
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
subscribe(sessionId, listener) {
|
|
152
|
+
const key = String(sessionId)
|
|
153
|
+
let set = this.listeners.get(key)
|
|
154
|
+
if (!set) {
|
|
155
|
+
set = new Set()
|
|
156
|
+
this.listeners.set(key, set)
|
|
157
|
+
}
|
|
158
|
+
set.add(listener)
|
|
159
|
+
return () => {
|
|
160
|
+
set.delete(listener)
|
|
161
|
+
if (set.size === 0) this.listeners.delete(key)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
publish(sessionId) {
|
|
166
|
+
const set = this.listeners.get(String(sessionId))
|
|
167
|
+
if (!set) return
|
|
168
|
+
for (const listener of set) listener()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
scope(sessionId) {
|
|
172
|
+
const actx = this.ctx.sessions.scope(sessionId)
|
|
173
|
+
if (!actx) throw new Error('当前会话尚未就绪')
|
|
174
|
+
return { actx, shell: this.ctx.conversation.input.for(actx) }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
insertReference(sessionId, entry) {
|
|
178
|
+
const { actx, shell } = this.scope(sessionId)
|
|
179
|
+
const input = shell.snapshot
|
|
180
|
+
if (!input || input.phase !== 'plain') return false
|
|
181
|
+
return actx.bail(actx, 'slash/input-insert-reference', {
|
|
182
|
+
reference: {
|
|
183
|
+
source: SOURCE,
|
|
184
|
+
ref: entry.ref,
|
|
185
|
+
label: `${HIDDEN_LABEL}${entry.ref}`,
|
|
186
|
+
clipboardText: '',
|
|
187
|
+
},
|
|
188
|
+
span: {
|
|
189
|
+
start: input.draft.length,
|
|
190
|
+
end: input.draft.length,
|
|
191
|
+
draftRev: input.draftRev,
|
|
192
|
+
},
|
|
193
|
+
}) === true
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
attach(sessionId, file) {
|
|
197
|
+
const key = String(sessionId)
|
|
198
|
+
this.clearInFlight(key)
|
|
199
|
+
const entry = { ...file, ref: `${key}-${++this.counter}` }
|
|
200
|
+
// 不再往草稿插入引用 token;文件仅以卡片形式显示在输入区上方,
|
|
201
|
+
// 发送时由 ensureSinkHook 安装的 defaultSink 包装统一拼进消息。
|
|
202
|
+
this.ensureSinkHook(key)
|
|
203
|
+
const next = [...this.pendingFor(key), entry]
|
|
204
|
+
this.pending.set(key, next)
|
|
205
|
+
this.refIndex.set(entry.ref, { sessionId: key, entry })
|
|
206
|
+
this.publish(key)
|
|
207
|
+
return entry
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
ensureSinkHook(key) {
|
|
211
|
+
const k = String(key)
|
|
212
|
+
if (this.sinkHooked.has(k)) return
|
|
213
|
+
let shell
|
|
214
|
+
try {
|
|
215
|
+
shell = this.scope(k).shell
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
const deps = shell.deps
|
|
220
|
+
const original = deps && deps.defaultSink
|
|
221
|
+
if (typeof original !== 'function') return
|
|
222
|
+
this.sinkHooked.add(k)
|
|
223
|
+
const controller = this
|
|
224
|
+
deps.defaultSink = function (text, imageIds, mode, signal) {
|
|
225
|
+
const entries = controller.pendingFor(k)
|
|
226
|
+
let extra = ''
|
|
227
|
+
for (const entry of entries) extra += serializedFile(entry)
|
|
228
|
+
if (entries.length > 0) {
|
|
229
|
+
controller.pending.delete(k)
|
|
230
|
+
controller.inFlight.set(k, entries)
|
|
231
|
+
controller.publish(k)
|
|
232
|
+
}
|
|
233
|
+
const result = original(text + extra, imageIds, mode, signal)
|
|
234
|
+
if (entries.length === 0) return result
|
|
235
|
+
return Promise.resolve(result).then((outcome) => {
|
|
236
|
+
if (outcome && outcome.kind === 'success') {
|
|
237
|
+
const expiry = controller.expiry.get(k)
|
|
238
|
+
if (expiry) expiry()
|
|
239
|
+
controller.expiry.delete(k)
|
|
240
|
+
controller.inFlight.delete(k)
|
|
241
|
+
for (const entry of entries) controller.refIndex.delete(entry.ref)
|
|
242
|
+
}
|
|
243
|
+
return outcome
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
remove(sessionId, ref) {
|
|
249
|
+
const key = String(sessionId)
|
|
250
|
+
const entries = this.pendingFor(key)
|
|
251
|
+
const entry = entries.find((item) => item.ref === ref)
|
|
252
|
+
if (!entry) return
|
|
253
|
+
const next = entries.filter((item) => item.ref !== ref)
|
|
254
|
+
if (next.length > 0) this.pending.set(key, next)
|
|
255
|
+
else this.pending.delete(key)
|
|
256
|
+
this.refIndex.delete(ref)
|
|
257
|
+
this.publish(key)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
fileForRef(ref) {
|
|
261
|
+
return this.refIndex.get(ref)?.entry
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
markSerializing(ref) {
|
|
265
|
+
const record = this.refIndex.get(ref)
|
|
266
|
+
if (record) this.serializing.add(record.sessionId)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
reconcile(sessionId, occurrences) {
|
|
270
|
+
// 卡片模式:文件不再以草稿引用/occurrence 形式存在,[pending] 的发送与失败
|
|
271
|
+
// 恢复改由 ensureSinkHook 的 defaultSink 包装 + restoreFailed 负责;这里不再按
|
|
272
|
+
// occurrence 对账,避免草稿一变就把待发送卡片误判为已发送而提前清空。
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
restoreFailed(sessionId) {
|
|
277
|
+
const key = String(sessionId)
|
|
278
|
+
const entries = this.inFlight.get(key)
|
|
279
|
+
if (!entries || entries.length === 0) return
|
|
280
|
+
const expiry = this.expiry.get(key)
|
|
281
|
+
if (expiry) expiry()
|
|
282
|
+
this.expiry.delete(key)
|
|
283
|
+
this.inFlight.delete(key)
|
|
284
|
+
// 卡片模式:失败时只把文件恢复为待发送卡片(不再回写草稿/插入引用)。
|
|
285
|
+
this.pending.set(key, entries)
|
|
286
|
+
for (const entry of entries) this.refIndex.set(entry.ref, { sessionId: key, entry })
|
|
287
|
+
this.publish(key)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
clearInFlight(sessionId) {
|
|
291
|
+
const key = String(sessionId)
|
|
292
|
+
const expiry = this.expiry.get(key)
|
|
293
|
+
if (expiry) expiry()
|
|
294
|
+
this.expiry.delete(key)
|
|
295
|
+
const entries = this.inFlight.get(key) || []
|
|
296
|
+
for (const entry of entries) this.refIndex.delete(entry.ref)
|
|
297
|
+
this.inFlight.delete(key)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async migrateLegacy(sessionId, draft) {
|
|
301
|
+
// 旧格式草稿兼容:只清理残留的「上传文件:`路径`」文本行,不再重新挂载附件。
|
|
302
|
+
// (原先会把它转成待发送附件,导致刷新后幽灵文件反复出现——2026-08-19 修复)
|
|
303
|
+
const key = String(sessionId)
|
|
304
|
+
if (this.migrated.has(key)) return
|
|
305
|
+
this.migrated.add(key)
|
|
306
|
+
const paths = legacyUploadPaths(draft)
|
|
307
|
+
if (paths.length === 0) return
|
|
308
|
+
try {
|
|
309
|
+
const { shell } = this.scope(sessionId)
|
|
310
|
+
shell.setDraft(stripLegacyPaths(shell.snapshot.draft, paths))
|
|
311
|
+
} catch (error) {
|
|
312
|
+
console.error('[dsh-upload-manager] legacy draft cleanup failed', error)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
dispose() {
|
|
317
|
+
for (const cancel of this.expiry.values()) cancel()
|
|
318
|
+
this.expiry.clear()
|
|
319
|
+
this.pending.clear()
|
|
320
|
+
this.inFlight.clear()
|
|
321
|
+
this.refIndex.clear()
|
|
322
|
+
this.listeners.clear()
|
|
323
|
+
this.serializing.clear()
|
|
324
|
+
this.migrated.clear()
|
|
325
|
+
this.sinkHooked.clear()
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function usePending(controller, sessionId) {
|
|
330
|
+
const [, render] = React.useState(0)
|
|
331
|
+
React.useEffect(
|
|
332
|
+
() => controller.subscribe(sessionId, () => render((value) => value + 1)),
|
|
333
|
+
[controller, sessionId],
|
|
334
|
+
)
|
|
335
|
+
return controller.pendingFor(sessionId)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function PaperclipIcon() {
|
|
339
|
+
return React.createElement(
|
|
340
|
+
'svg',
|
|
341
|
+
{
|
|
342
|
+
className: 'dsh-upload-icon',
|
|
343
|
+
viewBox: '0 0 24 24',
|
|
344
|
+
fill: 'none',
|
|
345
|
+
stroke: 'currentColor',
|
|
346
|
+
strokeWidth: 1.8,
|
|
347
|
+
strokeLinecap: 'round',
|
|
348
|
+
strokeLinejoin: 'round',
|
|
349
|
+
'aria-hidden': true,
|
|
350
|
+
},
|
|
351
|
+
React.createElement('path', { d: 'M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48' }),
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function UploadControl(props) {
|
|
356
|
+
const input = props.useInput((state) => state) || props.input
|
|
357
|
+
const pickerRef = React.useRef(null)
|
|
358
|
+
const [busy, setBusy] = React.useState(false)
|
|
359
|
+
const [status, setStatus] = React.useState('')
|
|
360
|
+
|
|
361
|
+
async function onPick(event) {
|
|
362
|
+
const files = Array.from(event.currentTarget.files || [])
|
|
363
|
+
event.currentTarget.value = ''
|
|
364
|
+
if (files.length === 0) return
|
|
365
|
+
|
|
366
|
+
setBusy(true)
|
|
367
|
+
setStatus(`正在上传 ${files.length} 个文件…`)
|
|
368
|
+
let attached = 0
|
|
369
|
+
const failures = []
|
|
370
|
+
try {
|
|
371
|
+
for (const file of files) {
|
|
372
|
+
try {
|
|
373
|
+
const stored = await uploadFile(file)
|
|
374
|
+
props.controller.attach(props.sessionId, stored)
|
|
375
|
+
attached += 1
|
|
376
|
+
} catch (error) {
|
|
377
|
+
failures.push(`${file.name}: ${errorMessage(error)}`)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
setStatus(failures.length === 0
|
|
381
|
+
? `已添加 ${attached} 个待发送文件`
|
|
382
|
+
: `已添加 ${attached} 个,失败 ${failures.length} 个:${failures.join(';')}`)
|
|
383
|
+
} finally {
|
|
384
|
+
setBusy(false)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const disabled = busy || !input || input.phase !== 'plain'
|
|
389
|
+
return React.createElement(
|
|
390
|
+
'div',
|
|
391
|
+
{ className: 'dsh-upload-control', title: status || '上传本地文件并加入本次消息' },
|
|
392
|
+
React.createElement('input', {
|
|
393
|
+
ref: pickerRef,
|
|
394
|
+
className: 'dsh-upload-picker',
|
|
395
|
+
type: 'file',
|
|
396
|
+
multiple: true,
|
|
397
|
+
onChange: onPick,
|
|
398
|
+
}),
|
|
399
|
+
React.createElement(
|
|
400
|
+
'button',
|
|
401
|
+
{
|
|
402
|
+
type: 'button',
|
|
403
|
+
className: 'dsh-upload-button',
|
|
404
|
+
disabled,
|
|
405
|
+
'aria-label': busy ? '正在上传文件' : '上传文件',
|
|
406
|
+
title: status || '上传本地文件并加入本次消息',
|
|
407
|
+
onClick: () => pickerRef.current?.click(),
|
|
408
|
+
},
|
|
409
|
+
React.createElement(PaperclipIcon, null),
|
|
410
|
+
),
|
|
411
|
+
React.createElement('span', { className: 'dsh-upload-live', 'aria-live': 'polite' }, status),
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ===== 拖放上传:把本地文件拖到会话框任意位置即可加入本条消息 =====
|
|
416
|
+
// 复用与钉选上传完全相同的 uploadFile() + controller.attach() 管线;
|
|
417
|
+
// 监听 window 级 dragenter/dragover/dragleave/drop(仅在携带 Files 时接管),
|
|
418
|
+
// 视觉层用 fixed 全屏遮罩提示,pointer-events:none 保证不干扰拖拽本身。
|
|
419
|
+
function DragDropOverlay(props) {
|
|
420
|
+
const controller = props.controller
|
|
421
|
+
const sessionId = props.sessionId
|
|
422
|
+
const [active, setActive] = React.useState(false)
|
|
423
|
+
const counterRef = React.useRef(0)
|
|
424
|
+
|
|
425
|
+
React.useEffect(() => {
|
|
426
|
+
// 只在真正拖入「文件」时才接管;拖文本/链接等(无 Files)一律放行给 DSH 原生行为。
|
|
427
|
+
// 四个事件都用 捕获阶段(capture) + stopPropagation,让本插件成为文件拖放的
|
|
428
|
+
// 唯一所有者 —— 这样 DSH 核心的「图像拖放/粘贴」验证器不会触发(否则非图片文件
|
|
429
|
+
// 会报「仅支持 PNG、JPG、WebP、GIF」),核心自己的拖放遮罩也不会出现/卡住。
|
|
430
|
+
const hasFiles = (e) => !!(e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'))
|
|
431
|
+
const onDragEnter = (e) => {
|
|
432
|
+
if (!hasFiles(e)) return
|
|
433
|
+
e.preventDefault()
|
|
434
|
+
e.stopPropagation()
|
|
435
|
+
counterRef.current += 1
|
|
436
|
+
setActive(true)
|
|
437
|
+
}
|
|
438
|
+
const onDragOver = (e) => {
|
|
439
|
+
if (!hasFiles(e)) return
|
|
440
|
+
e.preventDefault()
|
|
441
|
+
e.stopPropagation()
|
|
442
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
|
443
|
+
}
|
|
444
|
+
const onDragLeave = (e) => {
|
|
445
|
+
if (!hasFiles(e)) return
|
|
446
|
+
e.stopPropagation()
|
|
447
|
+
counterRef.current -= 1
|
|
448
|
+
if (counterRef.current <= 0) {
|
|
449
|
+
counterRef.current = 0
|
|
450
|
+
setActive(false)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const onDrop = async (e) => {
|
|
454
|
+
if (!hasFiles(e)) return
|
|
455
|
+
e.preventDefault()
|
|
456
|
+
e.stopPropagation()
|
|
457
|
+
counterRef.current = 0
|
|
458
|
+
setActive(false)
|
|
459
|
+
const files = Array.from((e.dataTransfer && e.dataTransfer.files) || [])
|
|
460
|
+
if (files.length === 0) return
|
|
461
|
+
// 复用与回形针完全相同的 uploadFile + attach 管线;结果直接体现在输入区上方的
|
|
462
|
+
// 「待发送文件」卡片上。
|
|
463
|
+
for (const file of files) {
|
|
464
|
+
try {
|
|
465
|
+
const stored = await uploadFile(file)
|
|
466
|
+
controller.attach(sessionId, stored)
|
|
467
|
+
} catch (error) {
|
|
468
|
+
console.warn(`[dsh-long-plugins] 拖放上传失败:${file.name} → ${errorMessage(error)}`)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
window.addEventListener('dragenter', onDragEnter, true)
|
|
473
|
+
window.addEventListener('dragover', onDragOver, true)
|
|
474
|
+
window.addEventListener('dragleave', onDragLeave, true)
|
|
475
|
+
window.addEventListener('drop', onDrop, true)
|
|
476
|
+
return () => {
|
|
477
|
+
window.removeEventListener('dragenter', onDragEnter, true)
|
|
478
|
+
window.removeEventListener('dragover', onDragOver, true)
|
|
479
|
+
window.removeEventListener('dragleave', onDragLeave, true)
|
|
480
|
+
window.removeEventListener('drop', onDrop, true)
|
|
481
|
+
}
|
|
482
|
+
}, [controller, sessionId])
|
|
483
|
+
|
|
484
|
+
if (!active) return null
|
|
485
|
+
|
|
486
|
+
return React.createElement(
|
|
487
|
+
'div',
|
|
488
|
+
{ className: 'dsh-upload-dropzone' },
|
|
489
|
+
React.createElement(
|
|
490
|
+
'div',
|
|
491
|
+
{ className: 'dsh-upload-dropzone-card' },
|
|
492
|
+
React.createElement(
|
|
493
|
+
'div',
|
|
494
|
+
{ className: 'dsh-upload-dropzone-icon' },
|
|
495
|
+
React.createElement(PaperclipIcon, null)
|
|
496
|
+
),
|
|
497
|
+
React.createElement(
|
|
498
|
+
'div',
|
|
499
|
+
{ className: 'dsh-upload-dropzone-text' },
|
|
500
|
+
'松开鼠标,将文件加入本条消息'
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function PendingFileRail(props) {
|
|
507
|
+
const input = props.useInput((state) => state)
|
|
508
|
+
const promptError = props.useSession((session) => session.promptError) || null
|
|
509
|
+
const files = usePending(props.controller, props.sessionId)
|
|
510
|
+
|
|
511
|
+
React.useEffect(() => {
|
|
512
|
+
props.controller.migrateLegacy(props.sessionId, input?.draft || '')
|
|
513
|
+
props.controller.reconcile(props.sessionId, input?.occurrences || [])
|
|
514
|
+
}, [props.controller, props.sessionId, input?.draftRev])
|
|
515
|
+
|
|
516
|
+
React.useEffect(() => {
|
|
517
|
+
if (promptError) props.controller.restoreFailed(props.sessionId)
|
|
518
|
+
}, [props.controller, props.sessionId, promptError])
|
|
519
|
+
|
|
520
|
+
if (files.length === 0) return null
|
|
521
|
+
return React.createElement(
|
|
522
|
+
'div',
|
|
523
|
+
{ className: 'dsh-upload-rail', 'aria-label': '待发送文件' },
|
|
524
|
+
files.map((file) => React.createElement(
|
|
525
|
+
'div',
|
|
526
|
+
{ className: 'dsh-upload-chip', key: file.ref },
|
|
527
|
+
React.createElement('span', { className: 'dsh-upload-chip-icon', 'aria-hidden': true }, '▤'),
|
|
528
|
+
React.createElement(
|
|
529
|
+
'span',
|
|
530
|
+
{ className: 'dsh-upload-chip-copy' },
|
|
531
|
+
React.createElement('strong', { title: file.name }, file.name),
|
|
532
|
+
React.createElement('small', null, sizeText(file.size)),
|
|
533
|
+
),
|
|
534
|
+
React.createElement(
|
|
535
|
+
'button',
|
|
536
|
+
{
|
|
537
|
+
type: 'button',
|
|
538
|
+
'aria-label': `移除 ${file.name}`,
|
|
539
|
+
title: '从本次消息移除',
|
|
540
|
+
onClick: () => props.controller.remove(props.sessionId, file.ref),
|
|
541
|
+
},
|
|
542
|
+
'×',
|
|
543
|
+
),
|
|
544
|
+
)),
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function sizeText(bytes) {
|
|
549
|
+
if (bytes < 1024) return `${bytes} B`
|
|
550
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`
|
|
551
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MiB`
|
|
552
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function dateText(value) {
|
|
556
|
+
try {
|
|
557
|
+
return new Intl.DateTimeFormat('zh-CN', {
|
|
558
|
+
year: 'numeric',
|
|
559
|
+
month: '2-digit',
|
|
560
|
+
day: '2-digit',
|
|
561
|
+
hour: '2-digit',
|
|
562
|
+
minute: '2-digit',
|
|
563
|
+
}).format(new Date(value))
|
|
564
|
+
} catch {
|
|
565
|
+
return value
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function UploadSettingsSection() {
|
|
570
|
+
const [state, setState] = React.useState({
|
|
571
|
+
loading: true,
|
|
572
|
+
root: '',
|
|
573
|
+
maxFileBytes: 0,
|
|
574
|
+
totalMaxBytes: 0,
|
|
575
|
+
usedBytes: 0,
|
|
576
|
+
files: [],
|
|
577
|
+
error: '',
|
|
578
|
+
})
|
|
579
|
+
const [deleting, setDeleting] = React.useState('')
|
|
580
|
+
const [preview, setPreview] = React.useState(null)
|
|
581
|
+
const [previewMaximized, setPreviewMaximized] = React.useState(false)
|
|
582
|
+
const [groupByDate, setGroupByDate] = React.useState(false)
|
|
583
|
+
|
|
584
|
+
async function refresh() {
|
|
585
|
+
setState((current) => ({ ...current, loading: true, error: '' }))
|
|
586
|
+
try {
|
|
587
|
+
const response = await fetch(API_PATH, { cache: 'no-store' })
|
|
588
|
+
const body = await responseJson(response)
|
|
589
|
+
setState({
|
|
590
|
+
loading: false,
|
|
591
|
+
root: body.root,
|
|
592
|
+
maxFileBytes: body.maxFileBytes,
|
|
593
|
+
totalMaxBytes: body.totalMaxBytes,
|
|
594
|
+
usedBytes: body.usedBytes,
|
|
595
|
+
files: body.files,
|
|
596
|
+
error: '',
|
|
597
|
+
})
|
|
598
|
+
} catch (error) {
|
|
599
|
+
setState((current) => ({ ...current, loading: false, error: errorMessage(error) }))
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
React.useEffect(() => {
|
|
604
|
+
refresh()
|
|
605
|
+
}, [])
|
|
606
|
+
|
|
607
|
+
async function remove(name) {
|
|
608
|
+
if (!globalThis.confirm(`确定删除“${name}”吗?此操作不可恢复。`)) return
|
|
609
|
+
setDeleting(name)
|
|
610
|
+
try {
|
|
611
|
+
const response = await fetch(`${API_PATH}?name=${encodeURIComponent(name)}`, { method: 'DELETE' })
|
|
612
|
+
await responseJson(response)
|
|
613
|
+
if (preview !== null && preview.name === name) closePreview()
|
|
614
|
+
await refresh()
|
|
615
|
+
} catch (error) {
|
|
616
|
+
setState((current) => ({ ...current, error: errorMessage(error) }))
|
|
617
|
+
} finally {
|
|
618
|
+
setDeleting('')
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function previewFile(name) {
|
|
623
|
+
setPreviewMaximized(false)
|
|
624
|
+
try {
|
|
625
|
+
const isOffice = /\.(docx|xlsx|pptx)$/i.test(name)
|
|
626
|
+
if (isOffice) {
|
|
627
|
+
// 先打开弹窗并提示转换中(NAS 上 docx/xlsx 转换可能耗时 1~2 秒)
|
|
628
|
+
setPreview({ name, officeLoading: true })
|
|
629
|
+
const response = await fetch(previewUrl(name), { cache: 'no-store' })
|
|
630
|
+
if (!response.ok) {
|
|
631
|
+
const body = await response.json().catch(() => ({}))
|
|
632
|
+
throw new Error(body.error || `HTTP ${response.status}`)
|
|
633
|
+
}
|
|
634
|
+
const data = await response.json().catch(() => ({}))
|
|
635
|
+
setPreview({
|
|
636
|
+
name,
|
|
637
|
+
officeHtml: data.officeHtml ?? '<p style="font-family:sans-serif;padding:12px">(无法渲染此文档)</p>',
|
|
638
|
+
})
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
// 无法内嵌预览的类型(压缩包、程序、视频、字体等):点预览直接下载。
|
|
642
|
+
if (!isInlinePreviewable(name)) {
|
|
643
|
+
triggerDownload(downloadUrl(name), name)
|
|
644
|
+
return
|
|
645
|
+
}
|
|
646
|
+
const response = await fetch(previewUrl(name), { cache: 'no-store' })
|
|
647
|
+
if (!response.ok) {
|
|
648
|
+
const body = await response.json().catch(() => ({}))
|
|
649
|
+
throw new Error(body.error || `HTTP ${response.status}`)
|
|
650
|
+
}
|
|
651
|
+
const type = response.headers.get('content-type') || ''
|
|
652
|
+
if (type.startsWith('image/')) {
|
|
653
|
+
const blob = await response.blob()
|
|
654
|
+
const url = URL.createObjectURL(blob)
|
|
655
|
+
setPreview({ url, name })
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
// PDF / txt / 其它可内嵌文件:弹窗内嵌预览(iframe 指向预览端点),
|
|
659
|
+
// 只有用户点「打开」才在新浏览器标签中打开。
|
|
660
|
+
setPreview({ url: previewUrl(name), name })
|
|
661
|
+
} catch (error) {
|
|
662
|
+
setState((current) => ({ ...current, error: errorMessage(error) }))
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function closePreview() {
|
|
667
|
+
if (preview?.url) URL.revokeObjectURL(preview.url)
|
|
668
|
+
setPreview(null)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Group files by calendar day of their modifiedAt (YYYY-MM-DD, desc). */
|
|
672
|
+
function groupFilesByDate(files) {
|
|
673
|
+
const groups = []
|
|
674
|
+
const byDay = new Map()
|
|
675
|
+
for (const file of files) {
|
|
676
|
+
let day = ''
|
|
677
|
+
try {
|
|
678
|
+
day = new Date(file.modifiedAt).toISOString().slice(0, 10)
|
|
679
|
+
} catch {
|
|
680
|
+
day = '未知日期'
|
|
681
|
+
}
|
|
682
|
+
if (!byDay.has(day)) byDay.set(day, [])
|
|
683
|
+
byDay.get(day).push(file)
|
|
684
|
+
}
|
|
685
|
+
const days = [...byDay.keys()].sort((a, b) => b.localeCompare(a))
|
|
686
|
+
for (const day of days) groups.push({ day, files: byDay.get(day) })
|
|
687
|
+
return groups
|
|
688
|
+
}
|
|
689
|
+
const dateGroups = groupByDate ? groupFilesByDate(state.files) : null
|
|
690
|
+
|
|
691
|
+
return React.createElement(
|
|
692
|
+
'section',
|
|
693
|
+
{ className: 'dsh-upload-settings' },
|
|
694
|
+
React.createElement(
|
|
695
|
+
'div',
|
|
696
|
+
{ className: 'dsh-upload-settings-head' },
|
|
697
|
+
React.createElement(
|
|
698
|
+
'div',
|
|
699
|
+
null,
|
|
700
|
+
React.createElement('h2', null, '上传文件'),
|
|
701
|
+
React.createElement('p', null, '管理从输入框上传到 Harness 容器中的文件。'),
|
|
702
|
+
),
|
|
703
|
+
React.createElement(
|
|
704
|
+
'div',
|
|
705
|
+
{ className: 'dsh-upload-head-actions' },
|
|
706
|
+
React.createElement(
|
|
707
|
+
'button',
|
|
708
|
+
{
|
|
709
|
+
type: 'button',
|
|
710
|
+
className: 'dsh-upload-refresh',
|
|
711
|
+
disabled: state.loading,
|
|
712
|
+
onClick: () => setGroupByDate((g) => !g),
|
|
713
|
+
},
|
|
714
|
+
groupByDate ? '日期 ✓' : '日期',
|
|
715
|
+
),
|
|
716
|
+
React.createElement(
|
|
717
|
+
'button',
|
|
718
|
+
{ type: 'button', className: 'dsh-upload-refresh', disabled: state.loading, onClick: refresh },
|
|
719
|
+
state.loading ? '刷新中…' : '刷新',
|
|
720
|
+
),
|
|
721
|
+
),
|
|
722
|
+
),
|
|
723
|
+
React.createElement(
|
|
724
|
+
'div',
|
|
725
|
+
{ className: 'dsh-upload-root' },
|
|
726
|
+
React.createElement('span', null, '固定目录'),
|
|
727
|
+
React.createElement('code', null, state.root || '读取中…'),
|
|
728
|
+
state.maxFileBytes
|
|
729
|
+
? React.createElement('small', null, `单文件上限 ${sizeText(state.maxFileBytes)}`)
|
|
730
|
+
: null,
|
|
731
|
+
state.totalMaxBytes
|
|
732
|
+
? React.createElement('small', null, `已使用 ${sizeText(state.usedBytes)} / ${sizeText(state.totalMaxBytes)}`)
|
|
733
|
+
: null,
|
|
734
|
+
),
|
|
735
|
+
state.error ? React.createElement('div', { className: 'dsh-upload-error' }, state.error) : null,
|
|
736
|
+
!state.loading && state.files.length === 0
|
|
737
|
+
? React.createElement('div', { className: 'dsh-upload-empty' }, '当前没有已上传文件。')
|
|
738
|
+
: null,
|
|
739
|
+
preview
|
|
740
|
+
? React.createElement(
|
|
741
|
+
'div',
|
|
742
|
+
{ className: 'dsh-upload-preview-overlay' + (previewMaximized ? ' dsh-upload-preview-overlay-max' : ''), onClick: closePreview },
|
|
743
|
+
React.createElement(
|
|
744
|
+
'div',
|
|
745
|
+
{ className: 'dsh-upload-preview-card' + (previewMaximized ? ' dsh-upload-preview-card-max' : ''), onClick: (event) => event.stopPropagation() },
|
|
746
|
+
React.createElement(
|
|
747
|
+
'div',
|
|
748
|
+
{ className: 'dsh-upload-preview-head' },
|
|
749
|
+
React.createElement('strong', null, preview.name),
|
|
750
|
+
React.createElement(
|
|
751
|
+
'div',
|
|
752
|
+
{ className: 'dsh-upload-preview-actions', style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
|
753
|
+
!preview.officeLoading && !(preview.url && preview.url.startsWith('blob:')) && preview.name !== void 0
|
|
754
|
+
? React.createElement('a', { href: preview.officeHtml !== void 0 ? downloadUrl(preview.name) : previewUrl(preview.name), target: '_blank', rel: 'noopener noreferrer', className: 'dsh-upload-preview-open' }, '打开')
|
|
755
|
+
: null,
|
|
756
|
+
!preview.officeLoading && !(preview.url && preview.url.startsWith('blob:')) && preview.name !== void 0
|
|
757
|
+
? React.createElement('a', { href: downloadUrl(preview.name), download: preview.name, className: 'dsh-upload-preview-open' }, '下载')
|
|
758
|
+
: null,
|
|
759
|
+
React.createElement('button', { type: 'button', onClick: () => setPreviewMaximized((m) => !m) }, previewMaximized ? '还原' : '放大'),
|
|
760
|
+
React.createElement('button', { type: 'button', className: 'dsh-upload-preview-del', disabled: deleting === preview.name, onClick: () => remove(preview.name) }, deleting === preview.name ? '删除中…' : '删除'),
|
|
761
|
+
React.createElement('button', { type: 'button', onClick: closePreview }, '关闭'),
|
|
762
|
+
),
|
|
763
|
+
),
|
|
764
|
+
preview.url && preview.url.startsWith('blob:')
|
|
765
|
+
? React.createElement('img', { src: preview.url, alt: preview.name, className: 'dsh-upload-preview-img' })
|
|
766
|
+
: React.createElement(
|
|
767
|
+
'div',
|
|
768
|
+
{ style: { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, background: '#fff' } },
|
|
769
|
+
preview.officeLoading === true
|
|
770
|
+
? React.createElement('div', { className: 'dsh-upload-preview-loading' }, '转换中…')
|
|
771
|
+
: preview.officeHtml !== void 0
|
|
772
|
+
? React.createElement('iframe', { title: preview.name, srcDoc: preview.officeHtml, style: previewMaximized ? { width: '100%', height: 'calc(100vh - 60px)', border: 'none', background: '#fff', flex: 1 } : { width: '100%', height: '70vh', border: 'none', background: '#fff' } })
|
|
773
|
+
: preview.url && /\.pdf$/i.test(preview.name)
|
|
774
|
+
? React.createElement('embed', { src: preview.url, type: 'application/pdf', title: preview.name, style: previewMaximized ? { width: '100%', height: 'calc(100vh - 60px)', border: 'none', background: '#fff', flex: 1 } : { width: '100%', height: '70vh', border: 'none', background: '#fff' } })
|
|
775
|
+
: React.createElement('iframe', { title: preview.name, src: preview.url, style: previewMaximized ? { width: '100%', height: 'calc(100vh - 60px)', border: 'none', background: '#fff', flex: 1 } : { width: '100%', height: '70vh', border: 'none', background: '#fff' } }),
|
|
776
|
+
),
|
|
777
|
+
),
|
|
778
|
+
)
|
|
779
|
+
: null,
|
|
780
|
+
React.createElement(
|
|
781
|
+
'div',
|
|
782
|
+
{ className: 'dsh-upload-list' },
|
|
783
|
+
(dateGroups === null ? [{ day: null, files: state.files }] : dateGroups).map((group) => React.createElement(
|
|
784
|
+
'div',
|
|
785
|
+
{ key: group.day ?? '__all__', className: 'dsh-upload-group' },
|
|
786
|
+
group.day !== null
|
|
787
|
+
? React.createElement(
|
|
788
|
+
'div',
|
|
789
|
+
{ className: 'dsh-upload-group-day' },
|
|
790
|
+
group.day,
|
|
791
|
+
React.createElement('span', null, `${group.files.length} 个文件`),
|
|
792
|
+
)
|
|
793
|
+
: null,
|
|
794
|
+
group.files.map((file) => React.createElement(
|
|
795
|
+
'div',
|
|
796
|
+
{ className: 'dsh-upload-row', key: file.name },
|
|
797
|
+
React.createElement(
|
|
798
|
+
'span',
|
|
799
|
+
{ className: 'dsh-upload-file-name', title: file.path },
|
|
800
|
+
React.createElement('span', { className: 'dsh-upload-file-label' }, file.name.replace(/\.[^.]+$/, '')),
|
|
801
|
+
React.createElement('span', { className: 'dsh-upload-file-type' }, fileTypeLabel(file.name)),
|
|
802
|
+
),
|
|
803
|
+
React.createElement('span', { className: 'dsh-upload-file-meta' }, `${sizeText(file.size)} · ${dateText(file.modifiedAt)}`),
|
|
804
|
+
React.createElement(
|
|
805
|
+
'div',
|
|
806
|
+
{ className: 'dsh-upload-actions' },
|
|
807
|
+
React.createElement('button', { type: 'button', className: 'dsh-upload-preview', onClick: () => previewFile(file.name) }, '预览'),
|
|
808
|
+
React.createElement('a', { href: downloadUrl(file.name), download: file.name }, '下载'),
|
|
809
|
+
React.createElement(
|
|
810
|
+
'button',
|
|
811
|
+
{ type: 'button', disabled: deleting === file.name, onClick: () => remove(file.name) },
|
|
812
|
+
deleting === file.name ? '删除中…' : '删除',
|
|
813
|
+
),
|
|
814
|
+
),
|
|
815
|
+
)),
|
|
816
|
+
)),
|
|
817
|
+
),
|
|
818
|
+
)
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const CSS = `
|
|
822
|
+
@font-face{font-family:DshChipCellInput;src:url(data:font/ttf;base64,AAEAAAAKAIAAAwAgT1MvMkT8SmIAAAEoAAAAYGNtYXAADQBPAAABkAAAADRnbHlmAAAAAAAAAcwAAAABaGVhZCwtPGoAAACsAAAANmhoZWEDIg7bAAAA5AAAACRobXR4EZQAAAAAAYgAAAAIbG9jYQAAAAAAAAHEAAAABm1heHAAAwACAAABCAAAACBuYW1lvljk2gAAAdAAAABscG9zdNNweNQAAAI8AAAALQABAAAAAQAAdia1tV8PPPUAAwPoAAAAAOaLfcUAAAAA5ot9xQAAAAAAAAAAAAAAAwACAAAAAAAAAAEAAAMg/zgAAA+gAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAACAAEAAAACAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAwjKAZAABQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAPz8/PwAA//z//AMg/zgAAAMgAMgAAAAAAAAAAAAAAAAAAAAgAAAB9AAAAAAAAAAAAAIAAAADAAAAFAADAAEAAAAUAAQAIAAAAAQABAABAAD//P//AAD//P//AAUAAQAAAAAAAAAAAAAAAAAAAAAAAAAEADYAAQAAAAAAAQALAAAAAQAAAAAAAgAHAAsAAwABBAkAAQAWABIAAwABBAkAAgAOAChEc2hDaGlwQ2VsbFJlZ3VsYXIARABzAGgAQwBoAGkAcABDAGUAbABsAFIAZQBnAHUAbABhAHIAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAABAgZvYmpyZXAAAAA=)format("truetype")}
|
|
823
|
+
.uV2eYG_input,.uV2eYG_mirror{font-family:"DshChipCellInput",var(--dsw-font-family)!important}
|
|
824
|
+
[data-decoration="chip"][title^="${HIDDEN_LABEL}"]{display:inline-block!important;width:0!important;height:0!important;overflow:hidden!important;background:transparent!important;border:none!important;box-shadow:none!important;margin:0!important;padding:0!important;font-size:0!important;line-height:0!important}
|
|
825
|
+
[data-decoration="chip"][title^="${HIDDEN_LABEL}"]:before,[data-decoration="chip"][title^="${HIDDEN_LABEL}"]>*{display:none!important}
|
|
826
|
+
.dsh-upload-control{display:flex;align-items:center;min-width:0}
|
|
827
|
+
.dsh-upload-picker,.dsh-upload-live{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
828
|
+
.dsh-upload-button{height:28px;width:28px;padding:0;border:0;border-radius:8px;display:inline-flex;align-items:center;justify-content:center;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
|
829
|
+
.dsh-upload-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
830
|
+
.dsh-upload-button:disabled{opacity:.55;cursor:wait}
|
|
831
|
+
.dsh-upload-icon{width:18px;height:18px;display:block}
|
|
832
|
+
.dsh-upload-rail{box-sizing:border-box;width:100%;max-width:var(--dsh-composer-card-max-width);margin:0 auto 6px;padding:0 var(--dsh-composer-side-clearance);display:flex;gap:8px;overflow-x:auto;scrollbar-width:none}
|
|
833
|
+
.dsh-upload-rail::-webkit-scrollbar{display:none}
|
|
834
|
+
.dsh-upload-chip{min-width:180px;max-width:260px;height:54px;box-sizing:border-box;display:flex;align-items:center;gap:9px;padding:8px 8px 8px 11px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-input-major);box-shadow:var(--dsw-shadow-lv1);color:var(--dsw-alias-label-primary)}
|
|
835
|
+
.dsh-upload-chip-icon{width:28px;height:28px;display:grid;place-items:center;flex:none;border-radius:8px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);font-size:16px}
|
|
836
|
+
.dsh-upload-chip-copy{display:flex;flex-direction:column;min-width:0;flex:1}
|
|
837
|
+
.dsh-upload-chip-copy strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:18px}
|
|
838
|
+
.dsh-upload-chip-copy small{color:var(--dsw-alias-label-secondary);font-size:10px;line-height:14px}
|
|
839
|
+
.dsh-upload-chip>button{width:24px;height:24px;border:0;border-radius:50%;background:transparent;color:var(--dsw-alias-label-secondary);font-size:18px;line-height:20px;cursor:pointer;flex:none}
|
|
840
|
+
.dsh-upload-chip>button:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}
|
|
841
|
+
.dsh-upload-settings{display:flex;flex-direction:column;gap:16px;min-width:0;padding:4px 2px 24px;color:var(--dsw-alias-label-primary)}
|
|
842
|
+
.dsh-upload-settings-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
|
|
843
|
+
.dsh-upload-head-actions{display:flex;gap:8px;align-items:center;flex:none}
|
|
844
|
+
.dsh-upload-settings h2{margin:0;font-size:20px;line-height:28px}
|
|
845
|
+
.dsh-upload-settings p{margin:4px 0 0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
|
|
846
|
+
.dsh-upload-refresh,.dsh-upload-actions button,.dsh-upload-actions a{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);padding:6px 11px;font:inherit;font-size:12px;line-height:18px;text-decoration:none;cursor:pointer}
|
|
847
|
+
.dsh-upload-refresh:hover:not(:disabled),.dsh-upload-actions button:hover:not(:disabled),.dsh-upload-actions a:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
848
|
+
.dsh-upload-root{display:grid;grid-template-columns:auto minmax(0,1fr);gap:5px 12px;align-items:center;padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-specific-input-major)}
|
|
849
|
+
.dsh-upload-root span{font-size:12px;color:var(--dsw-alias-label-secondary)}
|
|
850
|
+
.dsh-upload-root code{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
|
|
851
|
+
.dsh-upload-root small{grid-column:2;color:var(--dsw-alias-label-secondary)}
|
|
852
|
+
.dsh-upload-error{padding:10px 12px;border-radius:8px;background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);font-size:12px}
|
|
853
|
+
.dsh-upload-empty{padding:24px;text-align:center;color:var(--dsw-alias-label-secondary);border:1px dashed var(--dsw-alias-border-l2);border-radius:10px}
|
|
854
|
+
.dsh-upload-list{display:flex;flex-direction:column;border-top:1px solid var(--dsw-alias-border-l2)}
|
|
855
|
+
.dsh-upload-group{display:flex;flex-direction:column}
|
|
856
|
+
.dsh-upload-group+.dsh-upload-group{margin-top:14px}
|
|
857
|
+
.dsh-upload-group-day{display:flex;align-items:baseline;gap:8px;padding:8px 4px 4px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}
|
|
858
|
+
.dsh-upload-group-day span{font-weight:400;color:var(--dsw-alias-label-tertiary)}
|
|
859
|
+
.dsh-upload-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-l2);border-radius:8px;font-size:13px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-module-platform,transparent);margin-bottom:4px}
|
|
860
|
+
.dsh-upload-file-name{flex:1;min-width:0;display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-primary)}
|
|
861
|
+
.dsh-upload-file-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
862
|
+
.dsh-upload-file-type{flex:none;font-size:10px;line-height:14px;padding:1px 6px;border-radius:5px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover);white-space:nowrap}
|
|
863
|
+
.dsh-upload-file-meta{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px}
|
|
864
|
+
.dsh-upload-actions{display:flex;flex:none;gap:7px}
|
|
865
|
+
.dsh-upload-actions button{color:var(--dsw-alias-state-error-primary)}
|
|
866
|
+
@media (max-width:640px){.dsh-upload-row{align-items:stretch;flex-direction:column;gap:4px}.dsh-upload-file-name{white-space:normal;word-break:break-all}.dsh-upload-actions{width:100%}.dsh-upload-actions a,.dsh-upload-actions button{flex:1;text-align:center}.dsh-upload-chip{min-width:160px}.dsh-upload-settings-head{flex-direction:column;align-items:stretch;gap:10px}.dsh-upload-head-actions{width:100%;justify-content:flex-end}.dsh-upload-head-actions .dsh-upload-refresh{flex:1;text-align:center;padding:7px 8px;max-width:none}}
|
|
867
|
+
.dsh-ws-folder:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
868
|
+
.dsh-ws-file-type{flex:none;font-size:10px;line-height:14px;padding:1px 6px;border-radius:5px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover);white-space:nowrap}
|
|
869
|
+
.dsh-upload-preview-del{color:var(--dsw-alias-state-error-primary)!important}
|
|
870
|
+
@media (max-width: 767px){ .dsh-upload-preview-head{padding:8px 10px;gap:8px;flex-direction:column;align-items:stretch!important} .dsh-upload-preview-head strong{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .dsh-upload-preview-actions{flex-wrap:wrap;justify-content:flex-start} .dsh-upload-preview-actions a,.dsh-upload-preview-actions button{flex:1 1 auto;min-width:56px;text-align:center;padding:5px 8px;font-size:11px}
|
|
871
|
+
.dsh-ws-row{flex-direction:column!important;align-items:stretch!important;gap:4px!important}
|
|
872
|
+
.dsh-ws-name{white-space:normal!important;word-break:break-all}
|
|
873
|
+
.dsh-ws-meta{font-size:11px}
|
|
874
|
+
.dsh-ws-actions{width:100%;display:flex!important;gap:6px}
|
|
875
|
+
.dsh-ws-actions button,.dsh-ws-actions a{flex:1;text-align:center;padding:5px 4px}
|
|
876
|
+
}
|
|
877
|
+
.dsh-upload-actions button.dsh-upload-preview{color:var(--dsw-alias-label-primary)}
|
|
878
|
+
.dsh-upload-dropzone{position:fixed;inset:0;z-index:1350;pointer-events:none;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--dsw-specific-input-major,#0f1720) 55%,transparent);backdrop-filter:blur(2px)}
|
|
879
|
+
.dsh-upload-dropzone-card{display:flex;align-items:center;gap:14px;padding:22px 30px;border:2px dashed var(--dsw-alias-state-business-primary,#3b82f6);border-radius:16px;background:color-mix(in srgb,var(--dsw-specific-input-major,#0f1720) 92%,transparent);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary)}
|
|
880
|
+
.dsh-upload-dropzone-icon{width:42px;height:42px;display:grid;place-items:center;border-radius:10px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-state-business-primary,#3b82f6)}
|
|
881
|
+
.dsh-upload-dropzone-icon svg{width:28px;height:28px;display:block}
|
|
882
|
+
.dsh-upload-dropzone-text{font-size:15px;line-height:22px;color:var(--dsw-alias-label-primary)}
|
|
883
|
+
.dsh-upload-preview-overlay{position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:16px}
|
|
884
|
+
.dsh-upload-preview-overlay-max{padding:0}
|
|
885
|
+
.dsh-upload-preview-card{box-sizing:border-box;background:var(--dsw-specific-input-major);border-radius:14px;max-width:min(560px,100%);max-height:90%;display:flex;flex-direction:column;overflow:hidden;box-shadow:var(--dsw-shadow-lv3)}
|
|
886
|
+
.dsh-upload-preview-card-max{width:100%;height:100%;max-width:none;max-height:none;border-radius:0}
|
|
887
|
+
.dsh-upload-preview-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid var(--dsw-alias-border-l2)}
|
|
888
|
+
.dsh-upload-preview-head strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}
|
|
889
|
+
.dsh-upload-preview-head button{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);padding:5px 12px;font:inherit;font-size:12px;cursor:pointer;white-space:nowrap;flex:none}
|
|
890
|
+
.dsh-upload-preview-open{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-state-business-primary);padding:5px 12px;font:inherit;font-size:12px;text-decoration:none;cursor:pointer;white-space:nowrap;flex:none}
|
|
891
|
+
.dsh-upload-preview-open:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
892
|
+
.dsh-upload-preview-img{max-width:100%;max-height:70vh;object-fit:contain;display:block}
|
|
893
|
+
.dsh-upload-preview-loading{display:flex;align-items:center;justify-content:center;flex:1;min-height:120px;color:var(--dsw-alias-label-secondary);font-size:13px}
|
|
894
|
+
`
|
|
895
|
+
|
|
896
|
+
const inject = ['slots', 'sessions', 'inputTriggers', 'conversation', 'timer']
|
|
897
|
+
|
|
898
|
+
function WorkspaceFilesSection() {
|
|
899
|
+
const [groups, setGroups] = React.useState(null)
|
|
900
|
+
const [error, setError] = React.useState(null)
|
|
901
|
+
const [preview, setPreview] = React.useState(null)
|
|
902
|
+
const [busy, setBusy] = React.useState(false)
|
|
903
|
+
const [collapsed, setCollapsed] = React.useState({})
|
|
904
|
+
const [copied, setCopied] = React.useState(false)
|
|
905
|
+
const [editing, setEditing] = React.useState(false)
|
|
906
|
+
const [edited, setEdited] = React.useState('')
|
|
907
|
+
const [savedFlash, setSavedFlash] = React.useState(false)
|
|
908
|
+
const [maximized, setMaximized] = React.useState(false)
|
|
909
|
+
const toggleFolder = (folder) => setCollapsed((prev) => ({ ...prev, [folder]: !prev[folder] }))
|
|
910
|
+
|
|
911
|
+
const load = React.useCallback(async () => {
|
|
912
|
+
try {
|
|
913
|
+
const res = await fetch('/api/dsh-uploads/workspace', { headers: { Accept: 'application/json' } })
|
|
914
|
+
if (!res.ok) { setError(`HTTP ${res.status}`); return }
|
|
915
|
+
const data = await res.json()
|
|
916
|
+
if (data.ok === true) { setGroups(data.groups); setError(null) }
|
|
917
|
+
else setError(data.error)
|
|
918
|
+
} catch (e) { setError(String((e && e.message) || e)) }
|
|
919
|
+
}, [])
|
|
920
|
+
React.useEffect(() => { load() }, [load])
|
|
921
|
+
|
|
922
|
+
// md 预览是 iframe 内嵌 workspace-preview 页面,页面自带「关闭」按钮,
|
|
923
|
+
// 通过 postMessage 通知本组件关闭预览窗(放大态下同样生效)。
|
|
924
|
+
// 仅当本组件预览窗开着且内容来自 iframe 时才响应,避免误关其它内联预览。
|
|
925
|
+
React.useEffect(() => {
|
|
926
|
+
const onMessage = (event) => {
|
|
927
|
+
if (event.origin !== window.location.origin) return
|
|
928
|
+
if (event.data && event.data.type === 'dsh-close-preview') {
|
|
929
|
+
setPreview((current) => (current !== null && current.url !== void 0 ? null : current))
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
window.addEventListener('message', onMessage)
|
|
933
|
+
return () => window.removeEventListener('message', onMessage)
|
|
934
|
+
}, [])
|
|
935
|
+
|
|
936
|
+
const openPreview = async (path) => {
|
|
937
|
+
const isOffice = /\.(docx|xlsx|pptx)$/i.test(path)
|
|
938
|
+
setPreview({ path, loading: true, officeLoading: isOffice })
|
|
939
|
+
try {
|
|
940
|
+
const res = await fetch('/api/dsh-uploads/workspace-file?path=' + encodeURIComponent(path), { headers: { Accept: 'application/json' } })
|
|
941
|
+
const data = await res.json()
|
|
942
|
+
if (data.ok === true) {
|
|
943
|
+
const isBinaryOther = data.binary === true && data.officeHtml === void 0
|
|
944
|
+
if (isBinaryOther) {
|
|
945
|
+
// 二进制且非 Office:PDF 等浏览器可直接渲染的类型 → 弹窗 iframe
|
|
946
|
+
// 直接嵌原始文件流(inline);否则(压缩包/程序等)直接下载。
|
|
947
|
+
if (isInlinePreviewable(path)) {
|
|
948
|
+
setPreview({ path, name: data.name, url: '/api/dsh-uploads/workspace-file?path=' + encodeURIComponent(path) + '&inline=1' })
|
|
949
|
+
} else {
|
|
950
|
+
setPreview(null)
|
|
951
|
+
triggerDownload('/api/dsh-uploads/workspace-file?path=' + encodeURIComponent(path) + '&download=1', data.name)
|
|
952
|
+
}
|
|
953
|
+
} else if (/\.(md|markdown)$/i.test(path)) {
|
|
954
|
+
// Markdown:用服务端渲染的 mdHtml(srcDoc 进外层 iframe),外层标题栏
|
|
955
|
+
// 完全控制(编辑/复制/删除/放大/关闭);放大=外层真正全屏,按钮不重复。
|
|
956
|
+
// content 保留源码供「编辑/复制」使用(mdHtml 只用于预览展示)。
|
|
957
|
+
setPreview({ path, name: data.name, mdHtml: data.mdHtml, content: data.content })
|
|
958
|
+
} else if (/\.docx$/i.test(path)) {
|
|
959
|
+
// docx:走 docx-preview 真实渲染页(浏览器端解析,所见即所得),
|
|
960
|
+
// 而非 mammoth 简化 HTML。url 指向 docx-preview 端点。
|
|
961
|
+
setPreview({ path, name: data.name, url: '/api/dsh-uploads/docx-preview?path=' + encodeURIComponent(path) })
|
|
962
|
+
} else if (/\.pptx$/i.test(path)) {
|
|
963
|
+
// pptx:走 PptxViewJS 真实渲染页(浏览器端 Canvas 渲染,所见即所得)。
|
|
964
|
+
setPreview({ path, name: data.name, url: '/api/dsh-uploads/pptx-preview?path=' + encodeURIComponent(path) })
|
|
965
|
+
} else {
|
|
966
|
+
setPreview(data)
|
|
967
|
+
}
|
|
968
|
+
} else {
|
|
969
|
+
setPreview({ path, error: data.error })
|
|
970
|
+
}
|
|
971
|
+
} catch (e) { setPreview({ path, error: String((e && e.message) || e) }) }
|
|
972
|
+
}
|
|
973
|
+
const doDelete = async (path) => {
|
|
974
|
+
if (!window.confirm(`确认删除该文件?\n${path}`)) return
|
|
975
|
+
setBusy(true)
|
|
976
|
+
try {
|
|
977
|
+
const res = await fetch('/api/dsh-uploads/workspace-file/delete', {
|
|
978
|
+
method: 'POST',
|
|
979
|
+
headers: { 'content-type': 'application/json' },
|
|
980
|
+
body: JSON.stringify({ path }),
|
|
981
|
+
})
|
|
982
|
+
const data = await res.json().catch(() => ({}))
|
|
983
|
+
if (data.ok === true) {
|
|
984
|
+
if (preview !== null && preview.path === path) setPreview(null)
|
|
985
|
+
load()
|
|
986
|
+
} else setError(data.error || `HTTP ${res.status}`)
|
|
987
|
+
} catch (e) { setError(String((e && e.message) || e)) }
|
|
988
|
+
setBusy(false)
|
|
989
|
+
}
|
|
990
|
+
const copyContent = async () => {
|
|
991
|
+
if (preview === null) return
|
|
992
|
+
const text = preview.content !== void 0 ? preview.content : preview.officeHtml !== void 0 ? preview.officeHtml.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() : void 0
|
|
993
|
+
if (text === void 0) return
|
|
994
|
+
try {
|
|
995
|
+
await navigator.clipboard.writeText(text)
|
|
996
|
+
setCopied(true)
|
|
997
|
+
setTimeout(() => setCopied(false), 1500)
|
|
998
|
+
} catch (e) {
|
|
999
|
+
try {
|
|
1000
|
+
const ta = document.createElement('textarea')
|
|
1001
|
+
ta.value = text
|
|
1002
|
+
ta.style.position = 'fixed'
|
|
1003
|
+
ta.style.opacity = '0'
|
|
1004
|
+
document.body.appendChild(ta)
|
|
1005
|
+
ta.select()
|
|
1006
|
+
document.execCommand('copy')
|
|
1007
|
+
document.body.removeChild(ta)
|
|
1008
|
+
setCopied(true)
|
|
1009
|
+
setTimeout(() => setCopied(false), 1500)
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
setError(String((err && err.message) || err))
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const doSave = async () => {
|
|
1017
|
+
if (preview === null) return
|
|
1018
|
+
setBusy(true)
|
|
1019
|
+
try {
|
|
1020
|
+
const res = await fetch('/api/dsh-uploads/workspace-file/save', {
|
|
1021
|
+
method: 'POST',
|
|
1022
|
+
headers: { 'content-type': 'application/json' },
|
|
1023
|
+
body: JSON.stringify({ path: preview.path, content: edited }),
|
|
1024
|
+
})
|
|
1025
|
+
const data = await res.json().catch(() => ({}))
|
|
1026
|
+
if (data.ok === true) {
|
|
1027
|
+
// 保存后:更新 content,并重新渲染 mdHtml(若为 markdown),保证预览同步。
|
|
1028
|
+
const updated = { ...preview, content: edited, truncated: false }
|
|
1029
|
+
if (preview.mdHtml !== void 0) {
|
|
1030
|
+
try {
|
|
1031
|
+
const res2 = await fetch('/api/dsh-uploads/workspace-file?path=' + encodeURIComponent(preview.path), { headers: { Accept: 'application/json' } })
|
|
1032
|
+
const d2 = await res2.json().catch(() => ({}))
|
|
1033
|
+
if (d2.ok === true && typeof d2.mdHtml === 'string') updated.mdHtml = d2.mdHtml
|
|
1034
|
+
} catch { /* 刷新失败则保留现状 */ }
|
|
1035
|
+
}
|
|
1036
|
+
setPreview(updated)
|
|
1037
|
+
setEditing(false)
|
|
1038
|
+
setSavedFlash(true)
|
|
1039
|
+
setTimeout(() => setSavedFlash(false), 1500)
|
|
1040
|
+
} else setError(data.error || `HTTP ${res.status}`)
|
|
1041
|
+
} catch (e) { setError(String((e && e.message) || e)) }
|
|
1042
|
+
setBusy(false)
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const rowStyle = {
|
|
1046
|
+
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px',
|
|
1047
|
+
borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2)',
|
|
1048
|
+
background: 'var(--dsw-alias-bg-module-platform, transparent)', fontSize: 13,
|
|
1049
|
+
}
|
|
1050
|
+
const nameStyle = { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--dsw-alias-label-primary)' }
|
|
1051
|
+
const metaStyle = { flex: 'none', color: 'var(--dsw-alias-label-tertiary)', fontSize: 12 }
|
|
1052
|
+
const btnStyle = { flex: 'none', cursor: 'pointer', border: 'none', borderRadius: 6, padding: '3px 8px', fontSize: 12, background: 'var(--dsw-alias-interactive-bg-hover)', color: 'var(--dsw-alias-label-secondary)' }
|
|
1053
|
+
const delStyle = { ...btnStyle, color: 'var(--dsw-alias-state-error-primary)' }
|
|
1054
|
+
const folderBtnStyle = {
|
|
1055
|
+
display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', border: 'none', background: 'transparent',
|
|
1056
|
+
padding: '6px 2px', fontSize: 13, fontWeight: 600, color: 'var(--dsw-alias-label-primary)',
|
|
1057
|
+
textAlign: 'left', borderRadius: 6, fontFamily: 'inherit',
|
|
1058
|
+
}
|
|
1059
|
+
const preStyle = {
|
|
1060
|
+
margin: 0, padding: 12, borderRadius: 0, border: 'none', flex: 1, minHeight: 0, overflow: 'auto',
|
|
1061
|
+
fontFamily: 'var(--dsw-font-family-mono, monospace)', fontSize: 12, lineHeight: '16px',
|
|
1062
|
+
whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: 'var(--dsw-alias-label-primary)',
|
|
1063
|
+
}
|
|
1064
|
+
const textareaStyle = {
|
|
1065
|
+
margin: 0, padding: 12, border: 'none', flex: 1, minHeight: 0, resize: 'none', outline: 'none',
|
|
1066
|
+
fontFamily: 'var(--dsw-font-family-mono, monospace)', fontSize: 12, lineHeight: '16px',
|
|
1067
|
+
whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: 'var(--dsw-alias-label-primary)',
|
|
1068
|
+
background: 'var(--dsw-alias-bg-module-platform, transparent)',
|
|
1069
|
+
}
|
|
1070
|
+
const overlayStyle = {
|
|
1071
|
+
position: 'fixed', inset: 0, zIndex: 1300, background: 'rgba(0,0,0,.55)',
|
|
1072
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: maximized ? 0 : 16,
|
|
1073
|
+
}
|
|
1074
|
+
const previewCardStyle = maximized
|
|
1075
|
+
? { boxSizing: 'border-box', background: 'var(--dsw-specific-input-major)', width: '100%', height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }
|
|
1076
|
+
: {
|
|
1077
|
+
boxSizing: 'border-box', background: 'var(--dsw-specific-input-major)', borderRadius: 14,
|
|
1078
|
+
maxWidth: 'min(560px, 100%)', width: '100%', height: 'min(70vh, 640px)',
|
|
1079
|
+
display: 'flex', flexDirection: 'column', overflow: 'hidden', boxShadow: 'var(--dsw-shadow-lv3)',
|
|
1080
|
+
}
|
|
1081
|
+
const previewHeadStyle = {
|
|
1082
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
|
1083
|
+
padding: '10px 14px', borderBottom: '1px solid var(--dsw-alias-border-l2)', flex: 'none',
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
return React.createElement(
|
|
1087
|
+
'div',
|
|
1088
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 } },
|
|
1089
|
+
React.createElement('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-tertiary)' } }, '工作区输出文件(按文件夹分类,预览 / 下载 / 删除)'),
|
|
1090
|
+
error !== null && React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)' } }, error),
|
|
1091
|
+
groups === null && error === null && React.createElement('div', { style: metaStyle }, '加载中…'),
|
|
1092
|
+
groups !== null && groups.length === 0 && React.createElement('div', { style: metaStyle }, '目录为空'),
|
|
1093
|
+
groups !== null && groups.map((group) => React.createElement(
|
|
1094
|
+
'div',
|
|
1095
|
+
{ key: group.folder, style: { display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 } },
|
|
1096
|
+
React.createElement(
|
|
1097
|
+
'button',
|
|
1098
|
+
{
|
|
1099
|
+
type: 'button',
|
|
1100
|
+
className: 'dsh-ws-folder',
|
|
1101
|
+
style: folderBtnStyle,
|
|
1102
|
+
'aria-expanded': !collapsed[group.folder],
|
|
1103
|
+
onClick: () => toggleFolder(group.folder),
|
|
1104
|
+
},
|
|
1105
|
+
`${collapsed[group.folder] ? '▸' : '▾'} ${group.folder} (${group.files.length})`,
|
|
1106
|
+
),
|
|
1107
|
+
!collapsed[group.folder] && group.files.map((f) => React.createElement(
|
|
1108
|
+
'div',
|
|
1109
|
+
{ key: f.path, className: 'dsh-ws-row', style: rowStyle },
|
|
1110
|
+
React.createElement(
|
|
1111
|
+
'span',
|
|
1112
|
+
{ className: 'dsh-ws-name', style: { ...nameStyle, display: 'flex', alignItems: 'center', gap: 6 }, title: f.path },
|
|
1113
|
+
React.createElement('span', { className: 'dsh-ws-file-label', style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, basenameWithoutExt(f.path)),
|
|
1114
|
+
React.createElement('span', { className: 'dsh-ws-file-type', style: { flex: 'none' } }, fileTypeLabel(f.path)),
|
|
1115
|
+
),
|
|
1116
|
+
React.createElement('span', { className: 'dsh-ws-meta', style: metaStyle }, `${sizeText(f.size)} · ${dateText(f.mtime)}`),
|
|
1117
|
+
React.createElement(
|
|
1118
|
+
'div',
|
|
1119
|
+
{ className: 'dsh-ws-actions', style: { display: 'flex', gap: 6, flex: 'none' } },
|
|
1120
|
+
React.createElement('button', { type: 'button', style: btnStyle, disabled: busy, onClick: () => openPreview(f.path) }, '预览'),
|
|
1121
|
+
React.createElement('a', { href: '/api/dsh-uploads/workspace-file?path=' + encodeURIComponent(f.path) + '&download=1', download: f.name, style: btnStyle }, '下载'),
|
|
1122
|
+
React.createElement('button', { type: 'button', style: delStyle, disabled: busy, onClick: () => doDelete(f.path) }, '删除'),
|
|
1123
|
+
),
|
|
1124
|
+
)),
|
|
1125
|
+
)),
|
|
1126
|
+
preview !== null && React.createElement(
|
|
1127
|
+
'div',
|
|
1128
|
+
{ className: 'dsh-ws-preview-overlay', style: overlayStyle, onClick: () => setPreview(null) },
|
|
1129
|
+
React.createElement(
|
|
1130
|
+
'div',
|
|
1131
|
+
{ className: 'dsh-ws-preview-card', style: previewCardStyle, onClick: (e) => e.stopPropagation() },
|
|
1132
|
+
React.createElement(
|
|
1133
|
+
'div',
|
|
1134
|
+
{ className: 'dsh-ws-preview-head', style: previewHeadStyle },
|
|
1135
|
+
React.createElement('strong', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 13 } }, `${preview.name ?? preview.path}`),
|
|
1136
|
+
preview.binary !== true && preview.loading !== true && React.createElement('button', { type: 'button', style: btnStyle, disabled: busy, onClick: () => { if (editing) { setEdited(preview.content !== void 0 ? preview.content : ''); setEditing(false); } else { setEdited(preview.content !== void 0 ? preview.content : ''); setEditing(true); } } }, editing ? '取消编辑' : '编辑'),
|
|
1137
|
+
editing && React.createElement('button', { type: 'button', style: btnStyle, disabled: busy, onClick: doSave }, savedFlash ? '已保存' : '保存'),
|
|
1138
|
+
React.createElement('button', { type: 'button', style: btnStyle, onClick: copyContent }, copied ? '已复制' : '复制全部'),
|
|
1139
|
+
(preview.url !== void 0 || preview.officeHtml !== void 0 || preview.mdHtml !== void 0) && preview.loading !== true && React.createElement('a', { href: '/api/dsh-uploads/workspace-file?path=' + encodeURIComponent(preview.path) + '&download=1', download: preview.name, style: { ...btnStyle, color: 'var(--dsw-alias-state-business-primary)' } }, '下载'),
|
|
1140
|
+
(preview.url !== void 0 || preview.officeHtml !== void 0 || preview.mdHtml !== void 0) && preview.loading !== true && React.createElement('a', { href: '/api/dsh-uploads/workspace-preview?path=' + encodeURIComponent(preview.path), target: '_blank', rel: 'noopener noreferrer', style: { ...btnStyle, color: 'var(--dsw-alias-state-business-primary)' } }, '打开'),
|
|
1141
|
+
React.createElement('button', { type: 'button', style: delStyle, disabled: busy, onClick: () => doDelete(preview.path) }, '删除'),
|
|
1142
|
+
React.createElement('button', { type: 'button', style: btnStyle, onClick: () => setMaximized((m) => !m) }, maximized ? '还原' : '放大'),
|
|
1143
|
+
React.createElement('button', { type: 'button', style: btnStyle, onClick: () => setPreview(null) }, '关闭'),
|
|
1144
|
+
),
|
|
1145
|
+
React.createElement(
|
|
1146
|
+
'div',
|
|
1147
|
+
{ style: { display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflow: 'auto' } },
|
|
1148
|
+
preview.loading === true && React.createElement('div', { style: { ...metaStyle, padding: 10 } }, '加载中…'),
|
|
1149
|
+
preview.error !== void 0 && preview.loading !== true && React.createElement('div', { style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary)', padding: 10 } }, preview.error),
|
|
1150
|
+
preview.officeLoading === true && preview.loading !== true && React.createElement('div', { style: { ...metaStyle, padding: 10 } }, '转换中…'),
|
|
1151
|
+
preview.url !== void 0 && preview.loading !== true && (preview.name && /\.pdf$/i.test(preview.name)
|
|
1152
|
+
? React.createElement('embed', { src: preview.url, type: 'application/pdf', title: preview.name ?? preview.path, style: { width: '100%', height: '70vh', minHeight: 0, border: 'none', background: '#fff', flex: 1 } })
|
|
1153
|
+
: React.createElement('iframe', { title: preview.name ?? preview.path, src: preview.url, style: { width: '100%', height: '70vh', minHeight: 0, border: 'none', background: '#fff', flex: 1 } })),
|
|
1154
|
+
preview.binary === true && preview.officeHtml === void 0 && preview.url === void 0 && React.createElement('div', { style: { ...metaStyle, padding: 10 } }, '二进制文件,无法预览,请下载后查看'),
|
|
1155
|
+
preview.officeHtml !== void 0 && React.createElement('iframe', { title: preview.name ?? preview.path, srcDoc: preview.officeHtml, style: { width: '100%', flex: 1, minHeight: 0, border: 'none', background: '#fff' } }),
|
|
1156
|
+
preview.mdHtml !== void 0 && editing !== true && React.createElement('iframe', { title: preview.name ?? preview.path, srcDoc: preview.mdHtml, style: { width: '100%', flex: 1, minHeight: 0, border: 'none', background: '#fff' } }),
|
|
1157
|
+
editing && preview.binary !== true && React.createElement('textarea', { style: textareaStyle, value: edited, onChange: (e) => setEdited(e.target.value), spellCheck: false }),
|
|
1158
|
+
!editing && preview.binary !== true && preview.content !== void 0 && preview.mdHtml === void 0 && React.createElement('pre', { style: preStyle }, preview.content),
|
|
1159
|
+
preview.truncated === true && React.createElement('div', { style: { ...metaStyle, padding: '4px 12px 10px' } }, '(内容过大,仅显示前 256KB)'),
|
|
1160
|
+
),
|
|
1161
|
+
),
|
|
1162
|
+
),
|
|
1163
|
+
)
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function apply(ctx) {
|
|
1167
|
+
const controller = new FileDraftController(ctx)
|
|
1168
|
+
ctx.effect(() => () => controller.dispose(), 'dsh-upload-manager: draft-file state')
|
|
1169
|
+
ctx.effect(() => ctx.inputTriggers.registerSource({
|
|
1170
|
+
trigger: '@',
|
|
1171
|
+
name: SOURCE,
|
|
1172
|
+
order: 10_000,
|
|
1173
|
+
candidates: async () => [],
|
|
1174
|
+
onPick: () => undefined,
|
|
1175
|
+
codec: {
|
|
1176
|
+
clipboardText: () => '',
|
|
1177
|
+
serialize: async (ref) => {
|
|
1178
|
+
const file = controller.fileForRef(ref)
|
|
1179
|
+
if (!file) throw new Error('待发送文件已失效,请重新上传')
|
|
1180
|
+
controller.markSerializing(ref)
|
|
1181
|
+
return serializedFile(file)
|
|
1182
|
+
},
|
|
1183
|
+
},
|
|
1184
|
+
}), 'dsh-upload-manager: hidden file reference codec')
|
|
1185
|
+
|
|
1186
|
+
ctx.effect(() => {
|
|
1187
|
+
const style = document.createElement('style')
|
|
1188
|
+
style.dataset.plugin = 'dsh-file-uploads'
|
|
1189
|
+
style.textContent = CSS
|
|
1190
|
+
document.head.appendChild(style)
|
|
1191
|
+
return () => style.remove()
|
|
1192
|
+
}, 'dsh-upload-manager: styles')
|
|
1193
|
+
|
|
1194
|
+
ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
|
|
1195
|
+
name: 'conversation.input.left',
|
|
1196
|
+
id: 'local-file-upload',
|
|
1197
|
+
order: -20,
|
|
1198
|
+
label: '上传文件',
|
|
1199
|
+
}, (props) => React.createElement(UploadControl, { ...props, controller })))
|
|
1200
|
+
|
|
1201
|
+
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
|
1202
|
+
name: 'conversation.input.dock',
|
|
1203
|
+
id: 'local-file-upload-rail',
|
|
1204
|
+
order: 80,
|
|
1205
|
+
label: '待发送文件',
|
|
1206
|
+
}, (props) => React.createElement(PendingFileRail, { ...props, controller })))
|
|
1207
|
+
|
|
1208
|
+
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
|
1209
|
+
name: 'conversation.input.dock',
|
|
1210
|
+
id: 'local-file-upload-dropzone',
|
|
1211
|
+
order: 90,
|
|
1212
|
+
label: '拖放上传',
|
|
1213
|
+
}, (props) => React.createElement(DragDropOverlay, { ...props, controller })))
|
|
1214
|
+
|
|
1215
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
1216
|
+
name: 'settings.section',
|
|
1217
|
+
id: 'uploaded-files',
|
|
1218
|
+
order: 30,
|
|
1219
|
+
label: '上传文件',
|
|
1220
|
+
}, UploadSettingsSection))
|
|
1221
|
+
|
|
1222
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
1223
|
+
name: 'settings.section',
|
|
1224
|
+
id: 'output-files',
|
|
1225
|
+
order: 35,
|
|
1226
|
+
label: '输出文件',
|
|
1227
|
+
}, WorkspaceFilesSection))
|
|
1228
|
+
}
|
|
1229
|
+
return { apply, inject }
|
|
1230
|
+
})()
|
|
1231
|
+
|
|
1232
|
+
// ===== dsh-skill-docs: 技能文档 section =====
|
|
1233
|
+
const skillDocsPlugin = (() => {
|
|
1234
|
+
|
|
1235
|
+
var module = { exports: {} };
|
|
1236
|
+
var exports = module.exports;
|
|
1237
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
1238
|
+
let react = require("react");
|
|
1239
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
1240
|
+
/** `skillDocs` namespace dictionaries. */
|
|
1241
|
+
const zh = {
|
|
1242
|
+
"nav": "技能文档",
|
|
1243
|
+
"files.hint": "技能文档目录(可折叠,点击预览可编辑)",
|
|
1244
|
+
"files.loading": "加载中…",
|
|
1245
|
+
"files.empty": "目录为空",
|
|
1246
|
+
"files.preview": "预览",
|
|
1247
|
+
"files.download": "下载",
|
|
1248
|
+
"files.binary": "二进制文件,无法编辑,请下载后查看",
|
|
1249
|
+
"files.truncated": "(内容过大,仅显示/编辑前 256KB)",
|
|
1250
|
+
"files.close": "关闭",
|
|
1251
|
+
"files.edit": "编辑",
|
|
1252
|
+
"files.cancelEdit": "取消编辑",
|
|
1253
|
+
"files.save": "保存",
|
|
1254
|
+
"files.saved": "已保存",
|
|
1255
|
+
"files.copy": "复制全部",
|
|
1256
|
+
"files.copied": "已复制",
|
|
1257
|
+
"files.maximize": "放大窗口",
|
|
1258
|
+
"files.restore": "还原窗口"
|
|
1259
|
+
};
|
|
1260
|
+
const en = {
|
|
1261
|
+
"nav": "Skill docs",
|
|
1262
|
+
"files.hint": "Skill docs (collapsible; preview opens an editor)",
|
|
1263
|
+
"files.loading": "Loading…",
|
|
1264
|
+
"files.empty": "Directory is empty",
|
|
1265
|
+
"files.preview": "Preview",
|
|
1266
|
+
"files.download": "Download",
|
|
1267
|
+
"files.binary": "Binary file — download to view",
|
|
1268
|
+
"files.truncated": "(Large file — showing/editing first 256KB)",
|
|
1269
|
+
"files.close": "Close",
|
|
1270
|
+
"files.edit": "Edit",
|
|
1271
|
+
"files.cancelEdit": "Cancel edit",
|
|
1272
|
+
"files.save": "Save",
|
|
1273
|
+
"files.saved": "Saved",
|
|
1274
|
+
"files.copy": "Copy all",
|
|
1275
|
+
"files.copied": "Copied",
|
|
1276
|
+
"files.maximize": "Maximize",
|
|
1277
|
+
"files.restore": "Restore"
|
|
1278
|
+
};
|
|
1279
|
+
function SkillsSection({ t }) {
|
|
1280
|
+
const [groups, setGroups] = react.useState(null);
|
|
1281
|
+
const [error, setError] = react.useState(null);
|
|
1282
|
+
const [preview, setPreview] = react.useState(null);
|
|
1283
|
+
const [collapsed, setCollapsed] = react.useState({});
|
|
1284
|
+
const [maximized, setMaximized] = react.useState(false);
|
|
1285
|
+
const [editing, setEditing] = react.useState(false);
|
|
1286
|
+
const [edited, setEdited] = react.useState("");
|
|
1287
|
+
const [busy, setBusy] = react.useState(false);
|
|
1288
|
+
const [savedFlash, setSavedFlash] = react.useState(false);
|
|
1289
|
+
const [copiedFlash, setCopiedFlash] = react.useState(false);
|
|
1290
|
+
const toggleFolder = (folder) => setCollapsed((prev) => ({ ...prev, [folder]: !prev[folder] }));
|
|
1291
|
+
|
|
1292
|
+
const load = react.useCallback(async () => {
|
|
1293
|
+
try {
|
|
1294
|
+
const res = await fetch("/dsh-skill-docs/skill-docs", { headers: { Accept: "application/json" } });
|
|
1295
|
+
if (!res.ok) { setError(`HTTP ${res.status}`); return; }
|
|
1296
|
+
const data = await res.json();
|
|
1297
|
+
if (data.ok === true) { setGroups(data.groups); setError(null); }
|
|
1298
|
+
else setError(data.error);
|
|
1299
|
+
}
|
|
1300
|
+
catch (e) { setError(String((e && e.message) || e)); }
|
|
1301
|
+
}, []);
|
|
1302
|
+
react.useEffect(() => { load(); }, [load]);
|
|
1303
|
+
|
|
1304
|
+
const openDoc = async (path) => {
|
|
1305
|
+
setPreview({ path, loading: true });
|
|
1306
|
+
setEditing(false);
|
|
1307
|
+
setMaximized(false);
|
|
1308
|
+
try {
|
|
1309
|
+
const res = await fetch("/dsh-skill-docs/skill-doc?path=" + encodeURIComponent(path), { headers: { Accept: "application/json" } });
|
|
1310
|
+
const data = await res.json();
|
|
1311
|
+
if (data.ok === true) {
|
|
1312
|
+
setPreview(data);
|
|
1313
|
+
setEdited(data.content !== void 0 ? data.content : "");
|
|
1314
|
+
}
|
|
1315
|
+
else setPreview({ path, error: data.error });
|
|
1316
|
+
}
|
|
1317
|
+
catch (e) { setPreview({ path, error: String((e && e.message) || e) }); }
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
const doSave = async () => {
|
|
1321
|
+
if (preview === null) return;
|
|
1322
|
+
setBusy(true);
|
|
1323
|
+
try {
|
|
1324
|
+
const res = await fetch("/dsh-skill-docs/skill-doc/save", {
|
|
1325
|
+
method: "POST",
|
|
1326
|
+
headers: { "content-type": "application/json" },
|
|
1327
|
+
body: JSON.stringify({ path: preview.path, content: edited })
|
|
1328
|
+
});
|
|
1329
|
+
const data = await res.json().catch(() => ({}));
|
|
1330
|
+
if (data.ok === true) {
|
|
1331
|
+
setPreview((prev) => prev === null ? prev : { ...prev, content: edited, truncated: false });
|
|
1332
|
+
setEditing(false);
|
|
1333
|
+
setSavedFlash(true);
|
|
1334
|
+
setTimeout(() => setSavedFlash(false), 1500);
|
|
1335
|
+
}
|
|
1336
|
+
else setError(data.error || `HTTP ${res.status}`);
|
|
1337
|
+
}
|
|
1338
|
+
catch (e) { setError(String((e && e.message) || e)); }
|
|
1339
|
+
setBusy(false);
|
|
1340
|
+
};
|
|
1341
|
+
|
|
1342
|
+
const copyAll = async () => {
|
|
1343
|
+
if (preview === null || edited === "") return;
|
|
1344
|
+
try {
|
|
1345
|
+
await navigator.clipboard.writeText(edited);
|
|
1346
|
+
setCopiedFlash(true);
|
|
1347
|
+
setTimeout(() => setCopiedFlash(false), 1500);
|
|
1348
|
+
}
|
|
1349
|
+
catch (e) {
|
|
1350
|
+
try {
|
|
1351
|
+
const ta = document.createElement("textarea");
|
|
1352
|
+
ta.value = edited;
|
|
1353
|
+
ta.style.position = "fixed";
|
|
1354
|
+
ta.style.opacity = "0";
|
|
1355
|
+
document.body.appendChild(ta);
|
|
1356
|
+
ta.select();
|
|
1357
|
+
document.execCommand("copy");
|
|
1358
|
+
document.body.removeChild(ta);
|
|
1359
|
+
setCopiedFlash(true);
|
|
1360
|
+
setTimeout(() => setCopiedFlash(false), 1500);
|
|
1361
|
+
}
|
|
1362
|
+
catch (err) { setError(String((err && err.message) || err)); }
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
|
|
1366
|
+
const rowStyle = {
|
|
1367
|
+
display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", borderRadius: 8,
|
|
1368
|
+
border: "1px solid var(--dsw-alias-border-l2)",
|
|
1369
|
+
background: "var(--dsw-alias-bg-module-platform, transparent)", fontSize: 13
|
|
1370
|
+
};
|
|
1371
|
+
const nameStyle = { flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--dsw-alias-label-primary)" };
|
|
1372
|
+
const metaStyle = { flex: "none", color: "var(--dsw-alias-label-tertiary)", fontSize: 12 };
|
|
1373
|
+
const btnStyle = {
|
|
1374
|
+
flex: "none", cursor: "pointer", border: "none", borderRadius: 6, padding: "3px 8px", fontSize: 12,
|
|
1375
|
+
background: "var(--dsw-alias-interactive-bg-hover)", color: "var(--dsw-alias-label-secondary)"
|
|
1376
|
+
};
|
|
1377
|
+
const folderBtnStyle = {
|
|
1378
|
+
display: "flex", alignItems: "center", gap: 6, cursor: "pointer", border: "none", background: "transparent",
|
|
1379
|
+
padding: "6px 2px", fontSize: 13, fontWeight: 600, color: "var(--dsw-alias-label-primary)",
|
|
1380
|
+
textAlign: "left", borderRadius: 6, fontFamily: "inherit"
|
|
1381
|
+
};
|
|
1382
|
+
const preStyle = {
|
|
1383
|
+
margin: 0, padding: 12, borderRadius: 0, border: "none", flex: 1, minHeight: 0, overflow: "auto",
|
|
1384
|
+
fontFamily: "var(--dsw-font-family-mono, monospace)", fontSize: 12, lineHeight: "16px",
|
|
1385
|
+
whiteSpace: "pre-wrap", wordBreak: "break-all", color: "var(--dsw-alias-label-primary)"
|
|
1386
|
+
};
|
|
1387
|
+
const textareaStyle = {
|
|
1388
|
+
margin: 0, padding: 12, border: "none", flex: 1, minHeight: 0, resize: "none", outline: "none",
|
|
1389
|
+
fontFamily: "var(--dsw-font-family-mono, monospace)", fontSize: 12, lineHeight: "16px",
|
|
1390
|
+
whiteSpace: "pre-wrap", wordBreak: "break-all", color: "var(--dsw-alias-label-primary)",
|
|
1391
|
+
background: "var(--dsw-alias-bg-module-platform, transparent)"
|
|
1392
|
+
};
|
|
1393
|
+
const overlayStyle = {
|
|
1394
|
+
position: "fixed", inset: 0, zIndex: 1300, background: "rgba(0,0,0,.55)",
|
|
1395
|
+
display: "flex", alignItems: "center", justifyContent: "center", padding: maximized ? 0 : 16
|
|
1396
|
+
};
|
|
1397
|
+
const cardStyle = maximized
|
|
1398
|
+
? { boxSizing: "border-box", background: "var(--dsw-specific-input-major)", width: "100%", height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }
|
|
1399
|
+
: {
|
|
1400
|
+
boxSizing: "border-box", background: "var(--dsw-specific-input-major)", borderRadius: 14,
|
|
1401
|
+
maxWidth: "min(560px, 100%)", width: "100%", height: "min(70vh, 640px)",
|
|
1402
|
+
display: "flex", flexDirection: "column", overflow: "hidden", boxShadow: "var(--dsw-shadow-lv3)"
|
|
1403
|
+
};
|
|
1404
|
+
const headStyle = {
|
|
1405
|
+
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8,
|
|
1406
|
+
padding: "8px 12px", borderBottom: "1px solid var(--dsw-alias-border-l2)", flex: "none", flexWrap: "wrap"
|
|
1407
|
+
};
|
|
1408
|
+
|
|
1409
|
+
return react_jsx_runtime.jsxs("div", {
|
|
1410
|
+
style: { display: "flex", flexDirection: "column", gap: 6, minWidth: 0 },
|
|
1411
|
+
children: [
|
|
1412
|
+
react_jsx_runtime.jsx("div", { style: { fontSize: 13, color: "var(--dsw-alias-label-tertiary)" }, children: t("files.hint") }),
|
|
1413
|
+
error !== null && react_jsx_runtime.jsx("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-error-primary)" }, children: error }),
|
|
1414
|
+
groups === null && error === null && react_jsx_runtime.jsx("div", { style: metaStyle, children: t("files.loading") }),
|
|
1415
|
+
groups !== null && groups.length === 0 && react_jsx_runtime.jsx("div", { style: metaStyle, children: t("files.empty") }),
|
|
1416
|
+
groups !== null && groups.map((group) => react_jsx_runtime.jsxs("div", {
|
|
1417
|
+
style: { display: "flex", flexDirection: "column", gap: 6, minWidth: 0 },
|
|
1418
|
+
children: [
|
|
1419
|
+
react_jsx_runtime.jsx("button", {
|
|
1420
|
+
type: "button",
|
|
1421
|
+
style: folderBtnStyle,
|
|
1422
|
+
"aria-expanded": !collapsed[group.folder],
|
|
1423
|
+
onClick: () => toggleFolder(group.folder),
|
|
1424
|
+
children: `${collapsed[group.folder] ? "▸" : "▾"} ${group.folder} (${group.files.length})`
|
|
1425
|
+
}),
|
|
1426
|
+
!collapsed[group.folder] && group.files.map((f) => react_jsx_runtime.jsxs("div", {
|
|
1427
|
+
style: rowStyle,
|
|
1428
|
+
children: [
|
|
1429
|
+
react_jsx_runtime.jsx("span", { style: nameStyle, title: f.path, children: f.path }),
|
|
1430
|
+
react_jsx_runtime.jsx("span", { style: metaStyle, children: `${f.size < 1024 ? `${f.size} B` : `${(f.size / 1024).toFixed(1)} KiB`}` }),
|
|
1431
|
+
react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, onClick: () => openDoc(f.path), children: t("files.preview") }),
|
|
1432
|
+
react_jsx_runtime.jsx("a", { href: "/dsh-skill-docs/skill-doc?path=" + encodeURIComponent(f.path) + "&download=1", download: f.name, style: btnStyle, children: t("files.download") })
|
|
1433
|
+
]
|
|
1434
|
+
}, f.path))
|
|
1435
|
+
]
|
|
1436
|
+
}, group.folder)),
|
|
1437
|
+
preview !== null && react_jsx_runtime.jsxs("div", {
|
|
1438
|
+
style: overlayStyle,
|
|
1439
|
+
onClick: () => setPreview(null),
|
|
1440
|
+
children: [
|
|
1441
|
+
react_jsx_runtime.jsxs("div", {
|
|
1442
|
+
style: cardStyle,
|
|
1443
|
+
onClick: (e) => e.stopPropagation(),
|
|
1444
|
+
children: [
|
|
1445
|
+
react_jsx_runtime.jsxs("div", {
|
|
1446
|
+
style: headStyle,
|
|
1447
|
+
children: [
|
|
1448
|
+
react_jsx_runtime.jsx("strong", { style: { flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontSize: 13 }, children: `${preview.name ?? preview.path}` }),
|
|
1449
|
+
preview.binary !== true && react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, disabled: busy, onClick: () => { if (editing) { setEdited(preview.content !== void 0 ? preview.content : ""); setEditing(false); } else setEditing(true); }, children: editing ? t("files.cancelEdit") : t("files.edit") }),
|
|
1450
|
+
editing && react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, disabled: busy, onClick: doSave, children: savedFlash ? t("files.saved") : t("files.save") }),
|
|
1451
|
+
react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, onClick: copyAll, children: copiedFlash ? t("files.copied") : t("files.copy") }),
|
|
1452
|
+
react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, onClick: () => setMaximized((m) => !m), children: maximized ? t("files.restore") : t("files.maximize") }),
|
|
1453
|
+
react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, onClick: () => setPreview(null), children: t("files.close") })
|
|
1454
|
+
]
|
|
1455
|
+
}),
|
|
1456
|
+
react_jsx_runtime.jsxs("div", {
|
|
1457
|
+
style: { display: "flex", flexDirection: "column", flex: 1, minHeight: 0, overflow: "auto" },
|
|
1458
|
+
children: [
|
|
1459
|
+
preview.loading === true && react_jsx_runtime.jsx("div", { style: { ...metaStyle, padding: 10 }, children: t("files.loading") }),
|
|
1460
|
+
preview.error !== void 0 && preview.loading !== true && react_jsx_runtime.jsx("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-error-primary)", padding: 10 }, children: preview.error }),
|
|
1461
|
+
preview.binary === true && react_jsx_runtime.jsx("div", { style: { ...metaStyle, padding: 10 }, children: t("files.binary") }),
|
|
1462
|
+
editing && preview.binary !== true && react_jsx_runtime.jsx("textarea", { style: textareaStyle, value: edited, onChange: (e) => setEdited(e.target.value), spellCheck: false }),
|
|
1463
|
+
!editing && preview.content !== void 0 && react_jsx_runtime.jsx("pre", { style: preStyle, children: preview.content }),
|
|
1464
|
+
preview.truncated === true && react_jsx_runtime.jsx("div", { style: { ...metaStyle, padding: "4px 12px 10px" }, children: t("files.truncated") })
|
|
1465
|
+
]
|
|
1466
|
+
})
|
|
1467
|
+
]
|
|
1468
|
+
})
|
|
1469
|
+
]
|
|
1470
|
+
})
|
|
1471
|
+
]
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
/** Dictionary namespace owned by this plugin. */
|
|
1475
|
+
const NS = "skillDocs";
|
|
1476
|
+
/** Services required by this client plugin. */
|
|
1477
|
+
const inject = ["slots", "locale"];
|
|
1478
|
+
/** Register the skill-docs settings section. */
|
|
1479
|
+
function apply(ctx) {
|
|
1480
|
+
ctx.effect(() => ctx.locale.register(NS, {
|
|
1481
|
+
zh,
|
|
1482
|
+
en
|
|
1483
|
+
}), "dsh-skill-docs: dictionaries");
|
|
1484
|
+
const t = ctx.locale.bind(NS);
|
|
1485
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
1486
|
+
name: "settings.section",
|
|
1487
|
+
id: "skill-docs",
|
|
1488
|
+
order: 15,
|
|
1489
|
+
label: () => t("nav"),
|
|
1490
|
+
locale: NS,
|
|
1491
|
+
inject: () => ({})
|
|
1492
|
+
}, SkillsSection));
|
|
1493
|
+
}
|
|
1494
|
+
return { apply, inject }
|
|
1495
|
+
})()
|
|
1496
|
+
|
|
1497
|
+
// ===== dsh-token-usage: balance chip + mobile CSS =====
|
|
1498
|
+
const tokenUsagePlugin = (() => {
|
|
1499
|
+
|
|
1500
|
+
var module = { exports: {} };
|
|
1501
|
+
var exports = module.exports;
|
|
1502
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
1503
|
+
let react = require("react");
|
|
1504
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
1505
|
+
/** Mobile CSS overrides for the dsh web shell (composer action row,
|
|
1506
|
+
* settings panel layout, theme picker) — see MOBILE_CSS. */
|
|
1507
|
+
const MOBILE_CSS = "@media (max-width: 767px){.uV2eYG_row{gap:4px;padding:2px 4px 6px}.uV2eYG_tools{gap:8px}.uV2eYG_modes{gap:4px;min-width:0}.uV2eYG_trailing{gap:4px}.Sh0Q9G_trigger{max-width:104px;padding:0 2px 0 6px;font-size:12px}._7KE1Ra_trigger{max-width:56px;gap:2px;padding:0 2px 0 4px}._7KE1Ra_triggerLabel{display:none}._7KE1Ra_triggerEffort{display:none}._7KE1Ra_trigger::before{content:'';flex:none;width:16px;height:12px;color:var(--dsw-alias-label-secondary);background:url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%2023.16%2017.04%22%3E%3Cpath%20fill%3D%22currentColor%22%20d%3D%22M22.9168%201.43018C22.6713%201.31018%2022.5658%201.53918%2022.4223%201.65519C22.3733%201.69269%2022.3318%201.74169%2022.2903%201.78669C21.9317%202.1697%2021.5127%202.42121%2020.9657%202.39121C20.1657%202.34621%2019.4827%202.59771%2018.8787%203.20973C18.7502%202.45521%2018.3236%202.0047%2017.6746%201.71569C17.3351%201.56568%2016.9916%201.41518%2016.7536%201.08867C16.5876%200.856163%2016.5421%200.597155%2016.4591%200.341647C16.4061%200.187643%2016.3536%200.0301382%2016.1761%200.00363739C15.9836%20-0.0263635%2015.9081%200.135141%2015.8326%200.270145C15.5306%200.822162%2015.4136%201.43018%2015.4251%202.0462C15.4516%203.43174%2016.0366%204.53527%2017.1991%205.3203C17.3311%205.4103%2017.3651%205.5003%2017.3236%205.63181C17.2441%205.90231%2017.1501%206.16482%2017.0671%206.43533C17.0141%206.60784%2016.9351%206.64584%2016.7501%206.57033C16.1121%206.30383%2015.5611%205.90931%2015.074%205.4328C14.2475%204.63328%2013.5%203.75075%2012.568%203.05973C12.349%202.89822%2012.13%202.74822%2011.9034%202.60522C10.9524%201.68169%2012.028%200.923165%2012.277%200.833162C12.5375%200.739159%2012.3675%200.41615%2011.5259%200.42015C10.6844%200.42365%209.91439%200.705658%208.93286%201.08117C8.78935%201.13767%208.63835%201.17867%208.48384%201.21267C7.59332%201.04367%206.66829%201.00617%205.70226%201.11517C3.88321%201.31768%202.43016%202.1777%201.36213%203.64575C0.0790928%205.4103%20-0.222916%207.41536%200.146595%209.50642C0.535106%2011.7105%201.66014%2013.535%203.38869%2014.9616C5.18125%2016.4406%207.24581%2017.1657%209.60138%2017.0266C11.0319%2016.9441%2012.6245%2016.7526%2014.421%2015.2321C14.874%2015.4576%2015.3496%2015.5476%2016.1381%2015.6151C16.7456%2015.6716%2017.3306%2015.5851%2017.7836%2015.4911C18.4931%2015.3411%2018.4441%2014.6841%2018.1876%2014.5636C16.1081%2013.595%2016.5646%2013.9891%2016.1496%2013.67C17.2061%2012.42%2018.8202%2010.1979%2019.3182%207.17235C19.3672%206.83834%2019.4297%206.36783%2019.4222%206.09732C19.4182%205.93231%2019.4562%205.86831%2019.6447%205.84931C20.1657%205.78931%2020.6712%205.64681%2021.1357%205.3913C22.4833%204.65528%2023.0268%203.44624%2023.1548%201.9972C23.1738%201.77569%2023.1508%201.54668%2022.9168%201.43018ZM11.1749%2014.4736C9.15936%2012.889%208.18184%2012.3675%207.77832%2012.39C7.40081%2012.4125%207.46881%2012.8445%207.55182%2013.126C7.63882%2013.404%207.75182%2013.5955%207.91033%2013.8396C8.01983%2014.0011%208.09533%2014.2411%207.80083%2014.4216C7.15181%2014.8231%206.02327%2014.2866%205.97027%2014.2601C4.65673%2013.4865%203.5587%2012.4655%202.78467%2011.069C2.03715%209.72493%201.60314%208.28289%201.53164%206.74384C1.51264%206.37233%201.62214%206.24082%201.99215%206.17332C2.47916%206.08332%202.98118%206.06432%203.46769%206.13582C5.52476%206.43633%207.27581%207.35586%208.74385%208.8129C9.58188%209.64243%2010.2159%2010.634%2010.8689%2011.6025C11.5634%2012.631%2012.3105%2013.611%2013.262%2014.4146C13.598%2014.6961%2013.866%2014.9101%2014.1225%2015.0681C13.349%2015.1546%2012.058%2015.1731%2011.1749%2014.4746L11.1749%2014.4736ZM12.141%208.25988C12.141%208.09488%2012.273%207.96338%2012.439%207.96338C12.4765%207.96338%2012.5105%207.97088%2012.541%207.98188C12.5825%207.99688%2012.6205%208.01938%2012.6505%208.05338C12.7035%208.10588%2012.7335%208.18088%2012.7335%208.25988C12.7335%208.42489%2012.6015%208.55639%2012.4355%208.55639C12.2695%208.55639%2012.141%208.42489%2012.141%208.25988ZM15.1415%209.79893C14.949%209.87793%2014.7565%209.94544%2014.5715%209.95294C14.2845%209.96794%2013.9715%209.85143%2013.8015%209.70893C13.5375%209.48742%2013.3485%209.36342%2013.2695%208.97691C13.2355%208.8119%2013.2545%208.55639%2013.2845%208.40989C13.3525%208.09438%2013.277%207.89187%2013.0545%207.70787C12.8735%207.55786%2012.643%207.51636%2012.39%207.51636C12.2955%207.51636%2012.209%207.47486%2012.1445%207.44136C12.039%207.38886%2011.9519%207.25735%2012.035%207.09585C12.0615%207.04335%2012.19%206.91584%2012.22%206.89334C12.5635%206.69784%2012.9595%206.76184%2013.326%206.90834C13.6655%207.04735%2013.9225%207.30236%2014.292%207.66287C14.6695%208.09838%2014.7375%208.21838%2014.9525%208.54539C15.1225%208.8009%2015.277%209.06341%2015.3831%209.36392C15.4471%209.55142%2015.3641%209.70493%2015.1415%209.79893Z%22/%3E%3C/svg%3E') no-repeat center/contain}.VOzbGW_panel{width:100%;max-width:calc(100vw - 24px);height:min(800px,calc(100vh - 24px));height:min(800px,calc(100svh - 24px));border-radius:20px;flex-direction:column}.VOzbGW_nav{flex-direction:row;align-items:center;width:100%;gap:8px;padding:10px 10px 0}.VOzbGW_navTitle{display:none}.VOzbGW_navList{flex-direction:row;gap:4px;flex:1;min-width:0;overflow-x:auto;padding-bottom:4px}.VOzbGW_navCell{height:36px;padding:6px 12px;white-space:nowrap}.VOzbGW_navLabel{white-space:nowrap}.VOzbGW_content{min-height:0}.VOzbGW_options{padding:0 12px 16px}._8HJdBW_cubeRow{gap:6px}._8HJdBW_themeCube{flex:1 1 calc(33.333% - 4px);padding:10px 2px;gap:2px;border-radius:12px;font-size:11px;line-height:15px}._8HJdBW_themeCube svg{width:18px;height:18px}}";
|
|
1508
|
+
function injectMobileCss() {
|
|
1509
|
+
const tagId = "dsh-token-usage/mobile";
|
|
1510
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
1511
|
+
const tag = document.createElement("style");
|
|
1512
|
+
tag.dataset.plugin = "dsh-token-usage";
|
|
1513
|
+
tag.dataset.pluginCss = tagId;
|
|
1514
|
+
tag.textContent = MOBILE_CSS;
|
|
1515
|
+
document.head.appendChild(tag);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
/** Format bytes as B / KB / MB. */
|
|
1519
|
+
function formatBytes(n) {
|
|
1520
|
+
if (n < 1024) return `${n} B`;
|
|
1521
|
+
if (n < 1048576) return `${Math.round(n / 1024)} KB`;
|
|
1522
|
+
return `${Math.round(n / 1048576 * 10) / 10} MB`;
|
|
1523
|
+
}
|
|
1524
|
+
/** Short local date/time for a mtime epoch (ms). */
|
|
1525
|
+
function formatDate(ms) {
|
|
1526
|
+
const d = new Date(ms);
|
|
1527
|
+
const pad = (v) => String(v).padStart(2, "0");
|
|
1528
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
1529
|
+
}
|
|
1530
|
+
/** `tokenUsage` namespace dictionaries (settings file panel copy). */
|
|
1531
|
+
const zh = {
|
|
1532
|
+
"nav": "输出文件",
|
|
1533
|
+
"files.hint": "工作区输出文件(按文件夹分类,预览 / 下载 / 删除)",
|
|
1534
|
+
"files.loading": "加载中…",
|
|
1535
|
+
"files.empty": "目录为空",
|
|
1536
|
+
"files.preview": "预览",
|
|
1537
|
+
"files.download": "下载",
|
|
1538
|
+
"files.delete": "删除",
|
|
1539
|
+
"files.confirmDelete": "确认删除该文件?",
|
|
1540
|
+
"files.binary": "二进制文件,无法预览,请下载后查看",
|
|
1541
|
+
"files.truncated": "(内容过大,仅显示前 256KB)",
|
|
1542
|
+
"files.close": "关闭预览",
|
|
1543
|
+
"skills.hint": "技能文档(各技能的 SKILL.md),可预览",
|
|
1544
|
+
"balance": "余额",
|
|
1545
|
+
"spend": "本会话约"
|
|
1546
|
+
};
|
|
1547
|
+
const en = {
|
|
1548
|
+
"nav": "Output files",
|
|
1549
|
+
"files.hint": "Workspace output files (grouped by folder; preview / download / delete)",
|
|
1550
|
+
"files.loading": "Loading…",
|
|
1551
|
+
"files.empty": "Directory is empty",
|
|
1552
|
+
"files.preview": "Preview",
|
|
1553
|
+
"files.download": "Download",
|
|
1554
|
+
"files.delete": "Delete",
|
|
1555
|
+
"files.confirmDelete": "Delete this file?",
|
|
1556
|
+
"files.binary": "Binary file — download to view",
|
|
1557
|
+
"files.truncated": "(Large file — showing first 256KB)",
|
|
1558
|
+
"files.close": "Close preview",
|
|
1559
|
+
"skills.hint": "Skill documents (per-skill SKILL.md), previewable",
|
|
1560
|
+
"balance": "Balance",
|
|
1561
|
+
"spend": "~session"
|
|
1562
|
+
};
|
|
1563
|
+
/** Settings section: list the plugin's files with preview / download / delete. */
|
|
1564
|
+
function FilesSection({ t }) {
|
|
1565
|
+
const [groups, setGroups] = react.useState(null);
|
|
1566
|
+
const [error, setError] = react.useState(null);
|
|
1567
|
+
const [preview, setPreview] = react.useState(null);
|
|
1568
|
+
const [busy, setBusy] = react.useState(false);
|
|
1569
|
+
const load = react.useCallback(async () => {
|
|
1570
|
+
try {
|
|
1571
|
+
const res = await fetch("/dsh-token-usage/files", { headers: { Accept: "application/json" } });
|
|
1572
|
+
if (!res.ok) { setError(`HTTP ${res.status}`); return; }
|
|
1573
|
+
const data = await res.json();
|
|
1574
|
+
if (data.ok === true) { setGroups(data.groups); setError(null); }
|
|
1575
|
+
else setError(data.error);
|
|
1576
|
+
}
|
|
1577
|
+
catch (e) {
|
|
1578
|
+
setError(String((e && e.message) || e));
|
|
1579
|
+
}
|
|
1580
|
+
}, []);
|
|
1581
|
+
react.useEffect(() => { load(); }, [load]);
|
|
1582
|
+
const openPreview = async (path) => {
|
|
1583
|
+
setPreview({ path, loading: true });
|
|
1584
|
+
try {
|
|
1585
|
+
const res = await fetch("/dsh-token-usage/file?path=" + encodeURIComponent(path), { headers: { Accept: "application/json" } });
|
|
1586
|
+
const data = await res.json();
|
|
1587
|
+
setPreview(data.ok === true ? data : { path, error: data.error });
|
|
1588
|
+
}
|
|
1589
|
+
catch (e) {
|
|
1590
|
+
setPreview({ path, error: String((e && e.message) || e) });
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
const doDelete = async (path) => {
|
|
1594
|
+
if (!window.confirm(`${t("files.confirmDelete")}\n${path}`)) return;
|
|
1595
|
+
setBusy(true);
|
|
1596
|
+
try {
|
|
1597
|
+
const res = await fetch("/dsh-token-usage/file/delete", {
|
|
1598
|
+
method: "POST",
|
|
1599
|
+
headers: { "content-type": "application/json" },
|
|
1600
|
+
body: JSON.stringify({ path })
|
|
1601
|
+
});
|
|
1602
|
+
const data = await res.json().catch(() => ({}));
|
|
1603
|
+
if (data.ok === true) {
|
|
1604
|
+
if (preview !== null && preview.path === path) setPreview(null);
|
|
1605
|
+
load();
|
|
1606
|
+
}
|
|
1607
|
+
else setError(data.error || `HTTP ${res.status}`);
|
|
1608
|
+
}
|
|
1609
|
+
catch (e) {
|
|
1610
|
+
setError(String((e && e.message) || e));
|
|
1611
|
+
}
|
|
1612
|
+
setBusy(false);
|
|
1613
|
+
};
|
|
1614
|
+
const rowStyle = {
|
|
1615
|
+
display: "flex",
|
|
1616
|
+
alignItems: "center",
|
|
1617
|
+
gap: 8,
|
|
1618
|
+
padding: "6px 8px",
|
|
1619
|
+
borderRadius: 8,
|
|
1620
|
+
border: "1px solid var(--dsw-alias-border-l2)",
|
|
1621
|
+
background: "var(--dsw-alias-bg-module-platform, transparent)",
|
|
1622
|
+
fontSize: 13
|
|
1623
|
+
};
|
|
1624
|
+
const nameStyle = { flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--dsw-alias-label-primary)" };
|
|
1625
|
+
const metaStyle = { flex: "none", color: "var(--dsw-alias-label-tertiary)", fontSize: 12 };
|
|
1626
|
+
const btnStyle = {
|
|
1627
|
+
flex: "none",
|
|
1628
|
+
cursor: "pointer",
|
|
1629
|
+
border: "none",
|
|
1630
|
+
borderRadius: 6,
|
|
1631
|
+
padding: "3px 8px",
|
|
1632
|
+
fontSize: 12,
|
|
1633
|
+
background: "var(--dsw-alias-interactive-bg-hover)",
|
|
1634
|
+
color: "var(--dsw-alias-label-secondary)"
|
|
1635
|
+
};
|
|
1636
|
+
const delStyle = { ...btnStyle, color: "var(--dsw-alias-state-error-primary)" };
|
|
1637
|
+
const preStyle = {
|
|
1638
|
+
margin: "8px 0 0",
|
|
1639
|
+
padding: 10,
|
|
1640
|
+
borderRadius: 8,
|
|
1641
|
+
border: "1px solid var(--dsw-alias-border-l2)",
|
|
1642
|
+
background: "var(--dsw-alias-bg-module-platform, transparent)",
|
|
1643
|
+
maxHeight: 320,
|
|
1644
|
+
overflow: "auto",
|
|
1645
|
+
fontFamily: "var(--dsw-font-family-mono, monospace)",
|
|
1646
|
+
fontSize: 12,
|
|
1647
|
+
lineHeight: "16px",
|
|
1648
|
+
whiteSpace: "pre-wrap",
|
|
1649
|
+
wordBreak: "break-all",
|
|
1650
|
+
color: "var(--dsw-alias-label-primary)"
|
|
1651
|
+
};
|
|
1652
|
+
return react_jsx_runtime.jsxs("div", {
|
|
1653
|
+
style: { display: "flex", flexDirection: "column", gap: 6, minWidth: 0 },
|
|
1654
|
+
children: [
|
|
1655
|
+
react_jsx_runtime.jsx("div", { style: { fontSize: 13, color: "var(--dsw-alias-label-tertiary)" }, children: t("files.hint") }),
|
|
1656
|
+
error !== null && react_jsx_runtime.jsx("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-error-primary)" }, children: error }),
|
|
1657
|
+
groups === null && error === null && react_jsx_runtime.jsx("div", { style: metaStyle, children: t("files.loading") }),
|
|
1658
|
+
groups !== null && groups.length === 0 && react_jsx_runtime.jsx("div", { style: metaStyle, children: t("files.empty") }),
|
|
1659
|
+
groups !== null && groups.map((group) => react_jsx_runtime.jsxs("div", {
|
|
1660
|
+
style: { display: "flex", flexDirection: "column", gap: 6, minWidth: 0 },
|
|
1661
|
+
children: [
|
|
1662
|
+
react_jsx_runtime.jsx("div", {
|
|
1663
|
+
style: { fontSize: 13, fontWeight: 600, color: "var(--dsw-alias-label-primary)", padding: "4px 2px 0" },
|
|
1664
|
+
children: `${group.folder} (${group.files.length})`
|
|
1665
|
+
}),
|
|
1666
|
+
group.files.map((f) => react_jsx_runtime.jsxs("div", {
|
|
1667
|
+
style: rowStyle,
|
|
1668
|
+
children: [
|
|
1669
|
+
react_jsx_runtime.jsx("span", { style: nameStyle, title: f.path, children: f.path }),
|
|
1670
|
+
react_jsx_runtime.jsx("span", { style: metaStyle, children: `${formatBytes(f.size)} · ${formatDate(f.mtime)}` }),
|
|
1671
|
+
react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, disabled: busy, onClick: () => openPreview(f.path), children: t("files.preview") }),
|
|
1672
|
+
react_jsx_runtime.jsx("a", { href: "/dsh-token-usage/file?path=" + encodeURIComponent(f.path) + "&download=1", download: f.name, style: btnStyle, children: t("files.download") }),
|
|
1673
|
+
react_jsx_runtime.jsx("button", { type: "button", style: delStyle, disabled: busy, onClick: () => doDelete(f.path), children: t("files.delete") })
|
|
1674
|
+
]
|
|
1675
|
+
}, f.path))
|
|
1676
|
+
]
|
|
1677
|
+
}, group.folder)),
|
|
1678
|
+
preview !== null && react_jsx_runtime.jsxs("div", {
|
|
1679
|
+
style: { display: "flex", flexDirection: "column", gap: 4, minWidth: 0 },
|
|
1680
|
+
children: [
|
|
1681
|
+
react_jsx_runtime.jsx("div", { style: { display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary)" }, children: `${preview.name ?? preview.path}` }),
|
|
1682
|
+
preview.loading === true && react_jsx_runtime.jsx("div", { style: metaStyle, children: t("files.loading") }),
|
|
1683
|
+
preview.error !== void 0 && preview.loading !== true && react_jsx_runtime.jsx("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-error-primary)" }, children: preview.error }),
|
|
1684
|
+
preview.binary === true && react_jsx_runtime.jsx("div", { style: metaStyle, children: t("files.binary") }),
|
|
1685
|
+
preview.content !== void 0 && react_jsx_runtime.jsx("pre", { style: preStyle, children: preview.content }),
|
|
1686
|
+
preview.truncated === true && react_jsx_runtime.jsx("div", { style: metaStyle, children: t("files.truncated") }),
|
|
1687
|
+
react_jsx_runtime.jsx("button", { type: "button", style: btnStyle, onClick: () => setPreview(null), children: t("files.close") })
|
|
1688
|
+
]
|
|
1689
|
+
})
|
|
1690
|
+
]
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
/** Settings section: list skill documents (SKILL.md) with inline preview. */
|
|
1694
|
+
function useApiBalance() {
|
|
1695
|
+
const [balance, setBalance] = react.useState(null);
|
|
1696
|
+
react.useEffect(() => {
|
|
1697
|
+
let cancelled = false;
|
|
1698
|
+
const load = async () => {
|
|
1699
|
+
try {
|
|
1700
|
+
const controller = new AbortController();
|
|
1701
|
+
const timer = setTimeout(() => controller.abort(), 8000);
|
|
1702
|
+
const response = await fetch("/dsh-token-usage/balance", {
|
|
1703
|
+
signal: controller.signal,
|
|
1704
|
+
headers: { Accept: "application/json" }
|
|
1705
|
+
});
|
|
1706
|
+
clearTimeout(timer);
|
|
1707
|
+
if (cancelled) return;
|
|
1708
|
+
if (!response.ok) {
|
|
1709
|
+
setBalance(null);
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
const data = await response.json();
|
|
1713
|
+
if (!cancelled) setBalance(data);
|
|
1714
|
+
}
|
|
1715
|
+
catch {
|
|
1716
|
+
if (!cancelled) setBalance(null);
|
|
1717
|
+
}
|
|
1718
|
+
};
|
|
1719
|
+
load();
|
|
1720
|
+
const interval = setInterval(load, 60000);
|
|
1721
|
+
return () => {
|
|
1722
|
+
cancelled = true;
|
|
1723
|
+
clearInterval(interval);
|
|
1724
|
+
};
|
|
1725
|
+
}, []);
|
|
1726
|
+
return balance;
|
|
1727
|
+
}
|
|
1728
|
+
/** Format a CNY figure compactly (up to 4 decimal places for small spends). */
|
|
1729
|
+
function formatCny(value) {
|
|
1730
|
+
if (value == null || !Number.isFinite(value)) return null;
|
|
1731
|
+
if (value >= 1) return `¥${Math.round(value)}`;
|
|
1732
|
+
if (value >= 0.01) return `¥${value.toFixed(2)}`;
|
|
1733
|
+
return `¥${value.toFixed(3)}`;
|
|
1734
|
+
}
|
|
1735
|
+
/** Poll the backend session-cost route (peak/off-peak aware, V4-Flash
|
|
1736
|
+
* official pricing) for the given session id. Returns the parsed JSON
|
|
1737
|
+
* `{tokens, cny:{peak,offPeak,total}}` or null while unavailable. */
|
|
1738
|
+
function useSessionCost(sessionId) {
|
|
1739
|
+
const [cost, setCost] = react.useState(null);
|
|
1740
|
+
react.useEffect(() => {
|
|
1741
|
+
let cancelled = false;
|
|
1742
|
+
if (!sessionId) { setCost(null); return void 0; }
|
|
1743
|
+
const load = async () => {
|
|
1744
|
+
try {
|
|
1745
|
+
const controller = new AbortController();
|
|
1746
|
+
const timer = setTimeout(() => controller.abort(), 8000);
|
|
1747
|
+
const response = await fetch("/dsh-token-usage/session-cost?session=" + encodeURIComponent(sessionId), {
|
|
1748
|
+
signal: controller.signal,
|
|
1749
|
+
headers: { Accept: "application/json" }
|
|
1750
|
+
});
|
|
1751
|
+
clearTimeout(timer);
|
|
1752
|
+
if (cancelled) return;
|
|
1753
|
+
if (!response.ok) { setCost(null); return; }
|
|
1754
|
+
const data = await response.json();
|
|
1755
|
+
if (!cancelled) setCost(data && data.ok === true ? data : null);
|
|
1756
|
+
}
|
|
1757
|
+
catch {
|
|
1758
|
+
if (!cancelled) setCost(null);
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
load();
|
|
1762
|
+
const interval = setInterval(load, 60000);
|
|
1763
|
+
return () => {
|
|
1764
|
+
cancelled = true;
|
|
1765
|
+
clearInterval(interval);
|
|
1766
|
+
};
|
|
1767
|
+
}, [sessionId]);
|
|
1768
|
+
return cost;
|
|
1769
|
+
}
|
|
1770
|
+
/** Composer-dock balance chip: one muted line under the input card,
|
|
1771
|
+
* rendered BEFORE the built-in stats footer (dock order -10).
|
|
1772
|
+
* Shows account balance, plus the current session's spend (computed
|
|
1773
|
+
* server-side with peak/off-peak V4-Flash pricing) on its right. */
|
|
1774
|
+
function BalanceChip({ useSession, t }) {
|
|
1775
|
+
const balance = useApiBalance();
|
|
1776
|
+
const text = balanceSummaryText(balance);
|
|
1777
|
+
const sessionId = useSession((s) => s.sessionId);
|
|
1778
|
+
const cost = useSessionCost(sessionId);
|
|
1779
|
+
const spendCny = cost && cost.cny && Number.isFinite(cost.cny.total) ? cost.cny.total : null;
|
|
1780
|
+
const spendText = spendCny === null || spendCny <= 0 ? null : `${t("spend")}${formatCny(spendCny)}`;
|
|
1781
|
+
if (text === void 0 && spendText === null) return null;
|
|
1782
|
+
return react_jsx_runtime.jsx("div", {
|
|
1783
|
+
style: {
|
|
1784
|
+
textAlign: "center",
|
|
1785
|
+
margin: "0 auto",
|
|
1786
|
+
padding: "2px 0 0",
|
|
1787
|
+
fontSize: 12,
|
|
1788
|
+
lineHeight: "18px",
|
|
1789
|
+
color: "var(--dsw-alias-label-tertiary)"
|
|
1790
|
+
},
|
|
1791
|
+
children: [text === void 0 ? null : `${t("balance")} ${text}`, spendText].filter(Boolean).join(" | ")
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
/** Reduce the DeepSeek /user/balance payload to a short display string. */
|
|
1795
|
+
function balanceSummaryText(balance) {
|
|
1796
|
+
if (balance == null || typeof balance !== "object") return void 0;
|
|
1797
|
+
if (balance.is_available === false || !Array.isArray(balance.balance_infos)) return void 0;
|
|
1798
|
+
const byCurrency = {};
|
|
1799
|
+
for (const info of balance.balance_infos) {
|
|
1800
|
+
const value = Number(info == null ? void 0 : info.total_balance);
|
|
1801
|
+
if (!Number.isFinite(value) || value <= 0) continue;
|
|
1802
|
+
const currency = String(info.currency ?? "CNY");
|
|
1803
|
+
byCurrency[currency] = (byCurrency[currency] ?? 0) + value;
|
|
1804
|
+
}
|
|
1805
|
+
const entries = Object.entries(byCurrency);
|
|
1806
|
+
if (entries.length === 0) return void 0;
|
|
1807
|
+
const symbol = (currency) => currency === "CNY" ? "¥" : currency === "USD" ? "$" : `${currency} `;
|
|
1808
|
+
return entries.map(([currency, value]) => `${symbol(currency)}${value.toFixed(2)}`).join(" · ");
|
|
1809
|
+
}
|
|
1810
|
+
/** Dictionary namespace owned by this plugin. */
|
|
1811
|
+
const NS = "tokenUsage";
|
|
1812
|
+
/** Services required by this client plugin. */
|
|
1813
|
+
const inject = ["slots", "locale"];
|
|
1814
|
+
/** Register the token-usage card into the sidebar footer. */
|
|
1815
|
+
function apply(ctx) {
|
|
1816
|
+
injectMobileCss();
|
|
1817
|
+
ctx.effect(() => ctx.locale.register(NS, {
|
|
1818
|
+
zh,
|
|
1819
|
+
en
|
|
1820
|
+
}), "dsh-token-usage: dictionaries");
|
|
1821
|
+
ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
|
|
1822
|
+
name: "conversation.composer.dock",
|
|
1823
|
+
id: "token-balance",
|
|
1824
|
+
order: -10,
|
|
1825
|
+
locale: NS,
|
|
1826
|
+
inject: () => ({})
|
|
1827
|
+
}, BalanceChip));
|
|
1828
|
+
}
|
|
1829
|
+
return { apply, inject }
|
|
1830
|
+
})()
|
|
1831
|
+
|
|
1832
|
+
|
|
1833
|
+
const React = require('react')
|
|
1834
|
+
|
|
1835
|
+
// ===== dsh-long-plugins: mobile hamburger (hide rail, overlay sidebar) =====
|
|
1836
|
+
const MOBILE_HAMBURGER_CSS = `
|
|
1837
|
+
@media (max-width: 767px){
|
|
1838
|
+
.pI_x6G_frame{grid-template-columns:0 minmax(0,1fr) 0!important}
|
|
1839
|
+
.pI_x6G_sidebarCol{grid-column:1;position:fixed!important;left:0;top:0;bottom:0;width:min(300px,85vw)!important;z-index:30;transform:translateX(-100%);transition:transform .22s var(--ds-ease-in-out);box-shadow:var(--dsw-shadow-lv3)}
|
|
1840
|
+
.pI_x6G_frame:not([data-sidebar-collapsed]) .pI_x6G_sidebarCol{transform:translateX(0)}
|
|
1841
|
+
.pI_x6G_centerCol{grid-column:2}
|
|
1842
|
+
.pI_x6G_detailsCol{grid-column:3}
|
|
1843
|
+
.pI_x6G_handle[data-side=sidebar]{display:none}
|
|
1844
|
+
.wSkVaW_header{padding-left:calc(env(safe-area-inset-left, 0px) + 56px)!important}
|
|
1845
|
+
.dsh-mobile-hamburger{pointer-events:auto;position:fixed;left:calc(env(safe-area-inset-left, 0px) + 10px);top:calc(env(safe-area-inset-top, 0px) + 10px);z-index:45;width:38px;height:38px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-input-major);box-shadow:var(--dsw-shadow-lv2);color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:center;font-size:20px;line-height:1;cursor:pointer;padding:0}
|
|
1846
|
+
.dsh-mobile-hamburger:active{transform:scale(.95)}
|
|
1847
|
+
.dsh-mobile-scrim{pointer-events:auto;position:fixed;inset:0;z-index:26;background:rgba(0,0,0,.35)}
|
|
1848
|
+
/* 手机端会话标题栏按钮压缩,避免遮挡标题 */
|
|
1849
|
+
.dsh-ws-files-btn{padding:4px 8px;font-size:12px}
|
|
1850
|
+
.dsh-ws-files-label{display:none}
|
|
1851
|
+
.nL4_yW_sessionLogButton{min-width:0!important;height:28px;padding:4px 8px}
|
|
1852
|
+
.nL4_yW_sessionLogButton span{display:none!important}
|
|
1853
|
+
/* 手机端设置面板:全屏 + 导航横排,内容区全宽(类名随 DSH 版本,升级后需核对) */
|
|
1854
|
+
.VOzbGW_overlay{padding:0}
|
|
1855
|
+
.VOzbGW_panel{width:100vw;max-width:100vw;height:100vh;max-height:100vh;border-radius:0}
|
|
1856
|
+
.VOzbGW_nav{width:100%;flex-direction:row;gap:6px;padding:10px 14px 0;overflow-x:auto;align-items:center;flex:none}
|
|
1857
|
+
.VOzbGW_navTitle{display:none}
|
|
1858
|
+
.VOzbGW_navList{flex-direction:row;gap:6px}
|
|
1859
|
+
.VOzbGW_navCell{height:36px;padding:8px 14px}
|
|
1860
|
+
.VOzbGW_header{height:auto;min-height:44px;padding:12px 14px 6px}
|
|
1861
|
+
.VOzbGW_options{padding:0 16px 24px}
|
|
1862
|
+
}
|
|
1863
|
+
@media (min-width: 768px){.dsh-mobile-hamburger,.dsh-mobile-scrim{display:none!important}}
|
|
1864
|
+
`
|
|
1865
|
+
|
|
1866
|
+
function MobileHamburger({ toggleSidebar }) {
|
|
1867
|
+
const [expanded, setExpanded] = React.useState(false)
|
|
1868
|
+
React.useEffect(() => {
|
|
1869
|
+
const overlay = document.querySelector('[data-shell-overlay]')
|
|
1870
|
+
const frame = overlay ? overlay.parentElement : null
|
|
1871
|
+
if (!frame) return
|
|
1872
|
+
const update = () => setExpanded(!frame.hasAttribute('data-sidebar-collapsed'))
|
|
1873
|
+
update()
|
|
1874
|
+
const mo = new MutationObserver(update)
|
|
1875
|
+
mo.observe(frame, { attributes: true, attributeFilter: ['data-sidebar-collapsed'] })
|
|
1876
|
+
return () => mo.disconnect()
|
|
1877
|
+
}, [])
|
|
1878
|
+
return React.createElement(
|
|
1879
|
+
React.Fragment,
|
|
1880
|
+
null,
|
|
1881
|
+
React.createElement('button', {
|
|
1882
|
+
type: 'button',
|
|
1883
|
+
className: 'dsh-mobile-hamburger',
|
|
1884
|
+
'aria-label': expanded ? '收起侧边栏' : '打开侧边栏',
|
|
1885
|
+
onClick: toggleSidebar,
|
|
1886
|
+
}, expanded ? '✕' : '☰'),
|
|
1887
|
+
expanded && React.createElement('div', { className: 'dsh-mobile-scrim', onClick: toggleSidebar }),
|
|
1888
|
+
)
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
const mobilePlugin = {
|
|
1892
|
+
inject: ['slots', 'layout'],
|
|
1893
|
+
apply(ctx) {
|
|
1894
|
+
ctx.effect(() => {
|
|
1895
|
+
const style = document.createElement('style')
|
|
1896
|
+
style.dataset.plugin = 'dsh-long-plugins'
|
|
1897
|
+
style.dataset.pluginCss = 'dsh-long-plugins/mobile-hamburger'
|
|
1898
|
+
style.textContent = MOBILE_HAMBURGER_CSS
|
|
1899
|
+
document.head.appendChild(style)
|
|
1900
|
+
return () => style.remove()
|
|
1901
|
+
}, 'dsh-long-plugins: mobile hamburger styles')
|
|
1902
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
1903
|
+
name: 'shell.overlay',
|
|
1904
|
+
id: 'dsh-mobile-hamburger',
|
|
1905
|
+
order: 100,
|
|
1906
|
+
inject: () => ({ toggleSidebar: () => ctx.layout.toggleSidebar() }),
|
|
1907
|
+
}, MobileHamburger))
|
|
1908
|
+
},
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// ===== dsh-long-plugins: workspace file browser + inline preview =====
|
|
1912
|
+
// 共享内联面板状态:标题栏「📂 文件」、消息文件徽章、工具卡片文件名共用
|
|
1913
|
+
// 关闭:从列表进入的预览(history 非空)→ 回到文件列表;列表根部 → 整个面板退出。
|
|
1914
|
+
const wsOverlay = {
|
|
1915
|
+
url: null, title: '', history: [],
|
|
1916
|
+
listeners: new Set(),
|
|
1917
|
+
open(url, title, keepHistory = false) {
|
|
1918
|
+
if (keepHistory && this.url !== null && this.url !== url) {
|
|
1919
|
+
this.history.push({ url: this.url, title: this.title })
|
|
1920
|
+
}
|
|
1921
|
+
this.url = url; this.title = title || ''; this.emit();
|
|
1922
|
+
},
|
|
1923
|
+
back() {
|
|
1924
|
+
const prev = this.history.pop()
|
|
1925
|
+
if (prev) { this.url = prev.url; this.title = prev.title; this.emit(); return true }
|
|
1926
|
+
return false
|
|
1927
|
+
},
|
|
1928
|
+
close() { this.url = null; this.title = ''; this.history = []; this.emit(); },
|
|
1929
|
+
emit() { this.listeners.forEach((fn) => fn()); },
|
|
1930
|
+
subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); },
|
|
1931
|
+
}
|
|
1932
|
+
const WORKSPACE_FILES_CSS = `
|
|
1933
|
+
.dsh-ws-files-btn{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--dsw-alias-border-l2,#2c3a47);background:transparent;color:var(--dsw-alias-label-primary,#e5e7eb);border-radius:8px;padding:5px 10px;font-size:13px;line-height:1;cursor:pointer;text-decoration:none}
|
|
1934
|
+
.dsh-ws-files-btn:hover{background:var(--dsw-alias-border-l2,#2c3a47)}
|
|
1935
|
+
.dsh-ws-files-overlay{position:fixed;inset:0;z-index:1200;background:rgba(5,10,16,.66);display:flex;align-items:center;justify-content:center;padding:24px;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
|
|
1936
|
+
.dsh-ws-files-panel{width:min(1080px,96vw);height:min(820px,92vh);background:var(--dsw-specific-input-major,#0f1720);border:1px solid var(--dsw-alias-border-l2,#2c3a47);border-radius:14px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 18px 60px rgba(0,0,0,.55)}
|
|
1937
|
+
.dsh-ws-files-panel-head{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--dsw-alias-bg-module-platform,#1a2530);border-bottom:1px solid var(--dsw-alias-border-l2,#2c3a47);flex:none;flex-wrap:nowrap}
|
|
1938
|
+
.dsh-ws-files-panel-head .t{flex:1;min-width:0;font-weight:600;font-size:14px;color:var(--dsw-alias-label-primary,#e5e7eb);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1939
|
+
.dsh-ws-files-panel-head .sp{display:none}
|
|
1940
|
+
.dsh-ws-files-close{flex:none;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--dsw-alias-border-l2,#2c3a47);background:transparent;color:var(--dsw-alias-label-primary,#e5e7eb);border-radius:8px;padding:5px 10px;font-size:12px;line-height:1;cursor:pointer;text-decoration:none;white-space:nowrap}
|
|
1941
|
+
.dsh-ws-files-close:hover{background:var(--dsw-alias-interactive-bg-hover,#2c3a47)}
|
|
1942
|
+
.dsh-ws-files-frame{flex:1;border:none;width:100%;background:var(--dsw-specific-input-major,#0f1720)}
|
|
1943
|
+
/* 最大化:面板全屏,iframe 撑满 */
|
|
1944
|
+
.dsh-ws-files-overlay-max{padding:0}
|
|
1945
|
+
.dsh-ws-files-overlay-max .dsh-ws-files-panel{width:100vw;height:100vh;max-width:none;max-height:none;border:none;border-radius:0}
|
|
1946
|
+
/* 浅色模式下即使主题变量缺失也保证跟随系统 */
|
|
1947
|
+
@media (prefers-color-scheme: light) {
|
|
1948
|
+
.dsh-ws-files-panel{background:#ffffff}
|
|
1949
|
+
.dsh-ws-files-panel-head{background:#f3f4f6;border-bottom-color:#e5e7eb}
|
|
1950
|
+
.dsh-ws-files-panel-head .t{color:#1f2937}
|
|
1951
|
+
.dsh-ws-files-close{color:#374151;border-color:#e5e7eb}
|
|
1952
|
+
.dsh-ws-files-close:hover{background:#e5e7eb}
|
|
1953
|
+
.dsh-ws-files-frame{background:#ffffff}
|
|
1954
|
+
}
|
|
1955
|
+
/* 会话 markdown 里的预览/下载图标:内联显示在文件名后面(默认 markdown 图片是 block 独占一行) */
|
|
1956
|
+
img[src*="/api/dsh-uploads/icons/"]{display:inline!important;width:14px!important;height:14px!important;vertical-align:-2px!important;border-radius:0!important;background:transparent!important;margin:0 1px!important}
|
|
1957
|
+
@media (max-width: 640px){
|
|
1958
|
+
.dsh-ws-files-overlay{padding:10px}
|
|
1959
|
+
.dsh-ws-files-panel-head{gap:6px;padding:8px 10px}
|
|
1960
|
+
.dsh-ws-files-close{padding:5px 8px;font-size:12px}
|
|
1961
|
+
.dsh-ws-files-panel-head .t{font-size:13px}
|
|
1962
|
+
}
|
|
1963
|
+
/* 输出文件预览弹窗头部:手机端按钮换行铺开 */
|
|
1964
|
+
.dsh-ws-preview-head{flex-wrap:nowrap}
|
|
1965
|
+
@media (max-width: 640px){
|
|
1966
|
+
.dsh-ws-preview-head{flex-wrap:wrap;gap:6px;padding:8px 10px}
|
|
1967
|
+
.dsh-ws-preview-head strong{flex-basis:100%}
|
|
1968
|
+
.dsh-ws-preview-head button,.dsh-ws-preview-head a{padding:4px 8px;font-size:12px;flex:1;text-align:center}
|
|
1969
|
+
}
|
|
1970
|
+
`
|
|
1971
|
+
const workspaceFilesPlugin = {
|
|
1972
|
+
inject: ['slots'],
|
|
1973
|
+
apply(ctx) {
|
|
1974
|
+
ctx.effect(() => {
|
|
1975
|
+
const style = document.createElement('style')
|
|
1976
|
+
style.dataset.plugin = 'dsh-long-plugins'
|
|
1977
|
+
style.dataset.pluginCss = 'dsh-long-plugins/workspace-files-btn'
|
|
1978
|
+
style.textContent = WORKSPACE_FILES_CSS
|
|
1979
|
+
document.head.appendChild(style)
|
|
1980
|
+
return () => style.remove()
|
|
1981
|
+
}, 'dsh-long-plugins: workspace files button styles')
|
|
1982
|
+
// 全局点击拦截:消息文件引用 chip(data-ref-chip)+ 工具卡片文件名 + 产物芯片 → 内联预览。
|
|
1983
|
+
// 类名随 DSH 版本变化,故用稳定的 data-ref-chip 语义属性匹配(旧版 _fileMention_*/o3BgMG_* 已失效);
|
|
1984
|
+
// 工作区根路径运行时从服务端获取(避免硬编码本机路径)
|
|
1985
|
+
let workspaceRootPromise = null
|
|
1986
|
+
const getWorkspaceRoot = () => {
|
|
1987
|
+
workspaceRootPromise ??= fetch('/api/dsh-uploads/workspace', { headers: { Accept: 'application/json' } })
|
|
1988
|
+
.then((r) => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
|
|
1989
|
+
.then((d) => (d && d.ok && typeof d.root === 'string') ? stripTrailingSlash(normPath(d.root)) : '')
|
|
1990
|
+
.catch(() => '')
|
|
1991
|
+
return workspaceRootPromise
|
|
1992
|
+
}
|
|
1993
|
+
let lastCwd = ''
|
|
1994
|
+
// 跨平台路径工具:Windows 用反斜杠 \,Unix 用正斜杠 /。统一规范化为正斜杠,
|
|
1995
|
+
// 并识别两种系统的绝对路径(Unix「/」开头;Windows「X:\」盘符或「\\」UNC),
|
|
1996
|
+
// 避免 Windows 下把绝对路径误当相对路径去拼 lastCwd(导致双重路径、500)。
|
|
1997
|
+
const normPath = (p) => String(p == null ? '' : p).replace(/\\/g, '/').replace(/\/+/g, '/')
|
|
1998
|
+
const isAbsPath = (p) => /^(\/|[A-Za-z]:\/|\/\/)/.test(p)
|
|
1999
|
+
const stripTrailingSlash = (p) => String(p || '').replace(/\/+$/, '')
|
|
2000
|
+
const baseName = (p) => {
|
|
2001
|
+
const s = String(p == null ? '' : p).replace(/[\\/]+$/, '')
|
|
2002
|
+
return s.split(/[\\/]/).pop() || ''
|
|
2003
|
+
}
|
|
2004
|
+
const onFileClick = (event) => {
|
|
2005
|
+
const target = event.target
|
|
2006
|
+
// 匹配 DSH 消息文件引用 chip 的稳定语义属性(data-ref-chip),
|
|
2007
|
+
// 而非随 DSH 版本变化的 CSS 哈希类名(旧版 _fileMention_* / o3BgMG_* 已失效)。
|
|
2008
|
+
let el = target && target.closest
|
|
2009
|
+
? target.closest('[data-ref-chip], .o3BgMG_fileLink, .P4kPIW_file')
|
|
2010
|
+
: null
|
|
2011
|
+
// 兜底:匹配看起来像文件路径的可点击元素。
|
|
2012
|
+
// 通用判定,不硬编码本机目录(如 /volume1/、workspace/,换机器会失效):
|
|
2013
|
+
// 绝对路径(title 以 / 开头)或以常见文档/办公扩展名结尾。
|
|
2014
|
+
// 「是否真的在可打开工作区内」交给下方 rel 解析 + 运行时 workspaceRoot 判定。
|
|
2015
|
+
if (!el && target) {
|
|
2016
|
+
const t = target.closest
|
|
2017
|
+
? target.closest('[title^="/"], [title$=".docx"], [title$=".md"]')
|
|
2018
|
+
: null
|
|
2019
|
+
if (t) el = t
|
|
2020
|
+
}
|
|
2021
|
+
if (!el) return
|
|
2022
|
+
// chip 的 title 是完整引用 label(含路径),是获取路径的稳定来源;
|
|
2023
|
+
// 否则退回 displayLabel 文字(可能只有文件名)。
|
|
2024
|
+
const t = el.getAttribute ? el.getAttribute('title') : null
|
|
2025
|
+
const text = (el.textContent || '').trim().replace(/^[📁📄🗂\u200b]/, '')
|
|
2026
|
+
const raw = (t && t.length > 0) ? t.trim() : text
|
|
2027
|
+
if (!raw) return
|
|
2028
|
+
// 兜底匹配到的元素需像文件路径才拦截,避免误伤含 "/" 的工具提示/面包屑。
|
|
2029
|
+
const rawNorm = normPath(raw)
|
|
2030
|
+
if (!/\.(docx?|md|txt|pdf|xlsx?|pptx?|png|jpe?g|gif|webp|json|ya?ml|html?|css|js|ts|py|sh)$/i.test(rawNorm) && !(isAbsPath(rawNorm) && rawNorm.includes('/'))) return
|
|
2031
|
+
event.preventDefault()
|
|
2032
|
+
event.stopImmediatePropagation()
|
|
2033
|
+
event.stopPropagation()
|
|
2034
|
+
// 绝对路径(Windows 盘符或 Unix / 开头)不再拼 lastCwd,避免生成「双重路径」。
|
|
2035
|
+
const abs = isAbsPath(rawNorm)
|
|
2036
|
+
? rawNorm
|
|
2037
|
+
: (lastCwd ? stripTrailingSlash(normPath(lastCwd)) + '/' + rawNorm.replace(/^\/+/, '') : rawNorm)
|
|
2038
|
+
getWorkspaceRoot().then((root) => {
|
|
2039
|
+
const rawName = baseName(rawNorm) || text || raw
|
|
2040
|
+
// 优先直接用 root 定位;若 raw 本身已含 root 前缀则剥离;否则回退 lastCwd。
|
|
2041
|
+
let rel
|
|
2042
|
+
if (root) {
|
|
2043
|
+
if (rawNorm.startsWith(root + '/')) rel = rawNorm.slice(root.length + 1).replace(/^\/+/, '')
|
|
2044
|
+
else if (isAbsPath(rawNorm)) rel = rawNorm.replace(/^\/+/, '')
|
|
2045
|
+
else if (abs.startsWith(root + '/')) rel = abs.slice(root.length + 1).replace(/^\/+/, '')
|
|
2046
|
+
else rel = abs.replace(/^\/+/, '')
|
|
2047
|
+
} else {
|
|
2048
|
+
rel = abs.replace(/^\/+/, '')
|
|
2049
|
+
}
|
|
2050
|
+
const isPdf = /\.pdf$/i.test(rawName)
|
|
2051
|
+
// 标题用剥离后的真实文件名(rel 的 basename)而非 chip 的 title/文本,
|
|
2052
|
+
// 避免工具卡片「预览」按钮被误当成文件名(出现「预览 · 预览」)。
|
|
2053
|
+
const titleName = baseName(rel) || rawName || '文件'
|
|
2054
|
+
const url = isPdf
|
|
2055
|
+
? '/api/dsh-uploads/workspace-file?path=' + encodeURIComponent(rel) + '&inline=1'
|
|
2056
|
+
: '/api/dsh-uploads/workspace-preview?path=' + encodeURIComponent(rel)
|
|
2057
|
+
wsOverlay.open(url, titleName)
|
|
2058
|
+
})
|
|
2059
|
+
}
|
|
2060
|
+
ctx.effect(() => {
|
|
2061
|
+
document.addEventListener('click', onFileClick, true)
|
|
2062
|
+
return () => document.removeEventListener('click', onFileClick, true)
|
|
2063
|
+
}, 'dsh-long-plugins: file mention preview interceptor')
|
|
2064
|
+
// 预览页在 iframe 里点「关闭」时,通过 postMessage 关闭内联面板;
|
|
2065
|
+
// 浏览页点「预览」时,通过 postMessage 让父窗口打开(embed/iframe 渲染)。
|
|
2066
|
+
ctx.effect(() => {
|
|
2067
|
+
const onMessage = (event) => {
|
|
2068
|
+
if (event.origin !== window.location.origin) return
|
|
2069
|
+
if (event.data && event.data.type === 'dsh-close-preview') {
|
|
2070
|
+
// 渲染页里的「关闭」:从列表进入的预览 → 回到文件列表;否则整面板退出。
|
|
2071
|
+
if (wsOverlay.history.length > 0) wsOverlay.back(); else wsOverlay.close()
|
|
2072
|
+
}
|
|
2073
|
+
if (event.data && event.data.type === 'dsh-open-preview' && typeof event.data.url === 'string') {
|
|
2074
|
+
wsOverlay.open(event.data.url, event.data.title || '文件', true)
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
window.addEventListener('message', onMessage)
|
|
2078
|
+
return () => window.removeEventListener('message', onMessage)
|
|
2079
|
+
}, 'dsh-long-plugins: preview close message listener')
|
|
2080
|
+
const useOverlay = () => {
|
|
2081
|
+
const [state, setState] = React.useState({ url: wsOverlay.url, title: wsOverlay.title, canBack: wsOverlay.history.length > 0 })
|
|
2082
|
+
React.useEffect(() => wsOverlay.subscribe(() => setState({ url: wsOverlay.url, title: wsOverlay.title, canBack: wsOverlay.history.length > 0 })), [])
|
|
2083
|
+
return state
|
|
2084
|
+
}
|
|
2085
|
+
const WorkspaceFilesOverlay = () => {
|
|
2086
|
+
const { url, title, canBack } = useOverlay()
|
|
2087
|
+
const [maximized, setMaximized] = React.useState(false)
|
|
2088
|
+
// 预览某一文件时(history 非空):「关闭」回到文件列表;否则整个面板退出。
|
|
2089
|
+
const closeOrBack = () => { if (canBack) wsOverlay.back(); else wsOverlay.close() }
|
|
2090
|
+
React.useEffect(() => {
|
|
2091
|
+
if (!url) return undefined
|
|
2092
|
+
const onKey = (event) => { if (event.key === 'Escape') { if (maximized) setMaximized(false); else closeOrBack() } }
|
|
2093
|
+
window.addEventListener('keydown', onKey)
|
|
2094
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
2095
|
+
}, [url, maximized, canBack])
|
|
2096
|
+
if (!url) return null
|
|
2097
|
+
// inline 预览(PDF 原生查看器):头部补 打开/下载;其它渲染页自带按钮。
|
|
2098
|
+
const isInline = url.indexOf('&inline=1') !== -1 || url.indexOf('?inline=1') !== -1
|
|
2099
|
+
return React.createElement('div', { className: 'dsh-ws-files-overlay' + (maximized ? ' dsh-ws-files-overlay-max' : '') },
|
|
2100
|
+
React.createElement('div', { className: 'dsh-ws-files-panel' },
|
|
2101
|
+
React.createElement('div', { className: 'dsh-ws-files-panel-head' },
|
|
2102
|
+
React.createElement('span', { className: 't' }, title || '工作区文件'),
|
|
2103
|
+
React.createElement('span', { className: 'sp' }),
|
|
2104
|
+
isInline && React.createElement('a', { className: 'dsh-ws-files-close', href: url, target: '_blank', rel: 'noopener noreferrer' }, '打开'),
|
|
2105
|
+
isInline && React.createElement('a', { className: 'dsh-ws-files-close', href: url.replace(/[?&]inline=1/, '') + (url.indexOf('?') !== -1 ? '&download=1' : '?download=1'), download: true }, '下载'),
|
|
2106
|
+
canBack && React.createElement('button', { type: 'button', className: 'dsh-ws-files-close', onClick: () => wsOverlay.back() }, '← 返回'),
|
|
2107
|
+
React.createElement('button', { type: 'button', className: 'dsh-ws-files-close', onClick: () => setMaximized((m) => !m) }, maximized ? '还原' : '放大'),
|
|
2108
|
+
React.createElement('button', { type: 'button', className: 'dsh-ws-files-close', onClick: closeOrBack }, '✕ 关闭'),
|
|
2109
|
+
),
|
|
2110
|
+
isInline
|
|
2111
|
+
? React.createElement('embed', { className: 'dsh-ws-files-frame', src: url, type: 'application/pdf', title: '文件预览' })
|
|
2112
|
+
: React.createElement('iframe', { className: 'dsh-ws-files-frame', src: url, title: '文件预览' }),
|
|
2113
|
+
),
|
|
2114
|
+
)
|
|
2115
|
+
}
|
|
2116
|
+
const WorkspaceFilesButton = ({ sessionId, useSessions }) => {
|
|
2117
|
+
const cur = useSessions((s) => (sessionId === void 0 ? void 0 : s.byId[sessionId]?.cwd))
|
|
2118
|
+
React.useEffect(() => { if (cur) lastCwd = normPath(cur) }, [cur])
|
|
2119
|
+
// Windows 下 cwd 是反斜杠路径(C:\...\jacky),按 / 与 \ 都能切,取末段文件夹名。
|
|
2120
|
+
const ws = cur ? normPath(cur).split('/').filter(Boolean).pop() : ''
|
|
2121
|
+
return React.createElement(
|
|
2122
|
+
React.Fragment,
|
|
2123
|
+
null,
|
|
2124
|
+
React.createElement('button', {
|
|
2125
|
+
type: 'button',
|
|
2126
|
+
className: 'dsh-ws-files-btn',
|
|
2127
|
+
title: '工作区文件浏览' + (ws ? `(当前:${ws},可切换总文件)` : '(所有工作区文件,可预览/下载)'),
|
|
2128
|
+
'aria-label': '工作区文件浏览',
|
|
2129
|
+
onClick: () => wsOverlay.open('/api/dsh-uploads/workspace-browse' + (ws ? `?ws=${encodeURIComponent(ws)}` : ''), `工作区文件${ws ? ` · ${ws}` : ''}`),
|
|
2130
|
+
},
|
|
2131
|
+
React.createElement('svg', {
|
|
2132
|
+
className: 'dsh-ws-files-icon',
|
|
2133
|
+
viewBox: '0 0 24 24',
|
|
2134
|
+
fill: 'currentColor',
|
|
2135
|
+
width: '14',
|
|
2136
|
+
height: '14',
|
|
2137
|
+
'aria-hidden': true,
|
|
2138
|
+
},
|
|
2139
|
+
React.createElement('path', { d: 'M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z' }),
|
|
2140
|
+
),
|
|
2141
|
+
React.createElement('span', { className: 'dsh-ws-files-label' }, '文件'),
|
|
2142
|
+
),
|
|
2143
|
+
React.createElement(WorkspaceFilesOverlay, null),
|
|
2144
|
+
)
|
|
2145
|
+
}
|
|
2146
|
+
ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
|
|
2147
|
+
name: 'conversation.session.header.actions',
|
|
2148
|
+
id: 'dsh-workspace-files',
|
|
2149
|
+
order: 10,
|
|
2150
|
+
label: '文件',
|
|
2151
|
+
}, WorkspaceFilesButton))
|
|
2152
|
+
},
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
// ===== dsh-long-plugins: turn ruler (会话右侧轮次刻度,点击跳转提问) =====
|
|
2157
|
+
const TURN_RULER_CSS = `
|
|
2158
|
+
.dsh-turn-ruler{position:fixed;right:14px;top:50%;transform:translateY(-50%);z-index:1300;pointer-events:auto;display:flex;flex-direction:column;align-items:center;gap:14px;padding:14px 7px;border-radius:14px;background:color-mix(in srgb,var(--dsw-specific-input-major,#0f1720) 88%,transparent);border:1px solid var(--dsw-alias-border-l2,#2c3a47);box-shadow:var(--dsw-shadow-lv2);backdrop-filter:blur(6px)}
|
|
2159
|
+
.dsh-turn-ruler-dot{width:9px;height:9px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2,#2c3a47);background:var(--dsw-alias-bg-module-platform,#1a2530);cursor:pointer;padding:0;flex:none;transition:all .15s}
|
|
2160
|
+
.dsh-turn-ruler-dot:hover{transform:scale(1.45);border-color:var(--dsw-static-deepseek-500,#4d6bfe)}
|
|
2161
|
+
.dsh-turn-ruler-dot.active{background:var(--dsw-static-deepseek-500,#4d6bfe);border-color:var(--dsw-static-deepseek-500,#4d6bfe);transform:scale(1.3)}
|
|
2162
|
+
|
|
2163
|
+
/* 轮次列表浮窗:每行一轮的提问摘要,滚轮选择刻度,点击定位会话 */
|
|
2164
|
+
.dsh-turn-preview{position:fixed;z-index:1300;pointer-events:auto;width:min(340px,46vw);height:min(420px,60vh);touch-action:none;overflow:hidden;display:flex;flex-direction:column;background:color-mix(in srgb,var(--dsw-specific-input-major,#0f1720) 97%,transparent);border:1px solid var(--dsw-alias-border-l2,#2c3a47);border-radius:12px;box-shadow:var(--dsw-shadow-lv3);backdrop-filter:blur(10px);font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;opacity:0;visibility:hidden;transition:opacity .12s ease,visibility .12s}
|
|
2165
|
+
.dsh-turn-preview.open{opacity:1;visibility:visible}
|
|
2166
|
+
.dsh-turn-preview-head{display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid var(--dsw-alias-border-l2,#2c3a47);background:var(--dsw-alias-bg-module-platform,#141d27);flex:none}
|
|
2167
|
+
.dsh-turn-preview-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,#e5e7eb);flex:1}
|
|
2168
|
+
.dsh-turn-preview-count{font-size:11px;color:var(--dsw-alias-label-tertiary,#8b98a5);flex:none;font-variant-numeric:tabular-nums}
|
|
2169
|
+
.dsh-turn-preview-body{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior:contain;padding:4px 0;scrollbar-width:thin;scrollbar-color:var(--dsw-alias-label-tertiary,#8b98a5) transparent}
|
|
2170
|
+
.dsh-turn-preview-body::-webkit-scrollbar{width:4px}
|
|
2171
|
+
.dsh-turn-preview-body::-webkit-scrollbar-track{background:transparent}
|
|
2172
|
+
.dsh-turn-preview-body::-webkit-scrollbar-thumb{background:var(--dsw-alias-label-tertiary,#8b98a5);border-radius:4px;min-height:24px}
|
|
2173
|
+
.dsh-turn-preview-row{display:flex;gap:8px;align-items:center;padding:6px 12px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c2cad4);cursor:pointer;transition:background .15s ease,color .15s ease}
|
|
2174
|
+
/* 光标悬停 → 该行点亮(其他行不变) */
|
|
2175
|
+
.dsh-turn-preview-row:hover{background:color-mix(in srgb,var(--dsw-alias-interactive-bg-hover,#2c3a47) 80%,transparent);color:var(--dsw-alias-label-primary,#e5e7eb)}
|
|
2176
|
+
.dsh-turn-preview-row .n{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary,#8b98a5);font-variant-numeric:tabular-nums;width:18px;text-align:center}
|
|
2177
|
+
.dsh-turn-preview-row .t{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
2178
|
+
.dsh-turn-preview-close{flex:none;width:24px;height:24px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2,#2c3a47);background:transparent;color:var(--dsw-alias-label-secondary,#c2cad4);cursor:pointer;padding:0;display:flex;align-items:center;justify-content:center;font-size:13px;line-height:1;transition:all .15s}
|
|
2179
|
+
.dsh-turn-preview-close:hover{color:var(--dsw-alias-label-primary,#e5e7eb);border-color:var(--dsw-static-deepseek-500,#4d6bfe)}
|
|
2180
|
+
/* 手机/窄屏:右边缘竖向把手(半透明、不挡内容),点击打开预览窗 */
|
|
2181
|
+
.dsh-turn-phone-tab{position:fixed;right:0;top:50%;transform:translateY(-50%);z-index:1300;pointer-events:auto;display:none;flex-direction:column;align-items:center;gap:10px;padding:14px 7px;border-radius:12px 0 0 12px;background:color-mix(in srgb,var(--dsw-specific-input-major,#0f1720) 82%,transparent);border:1px solid var(--dsw-alias-border-l2,#2c3a47);border-right:none;backdrop-filter:blur(6px);cursor:pointer;box-shadow:var(--dsw-shadow-lv2)}
|
|
2182
|
+
.dsh-turn-phone-tab-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-label-tertiary,#8b98a5);flex:none;transition:all .2s}
|
|
2183
|
+
.dsh-turn-phone-tab:active{opacity:.7}
|
|
2184
|
+
.dsh-turn-preview-loading{display:flex;align-items:center;justify-content:center;padding:5px 12px;font-size:11px;color:var(--dsw-alias-label-tertiary,#8b98a5);pointer-events:none}
|
|
2185
|
+
@media (max-width:1024px){
|
|
2186
|
+
.dsh-turn-ruler{display:none!important}
|
|
2187
|
+
.dsh-turn-phone-tab{display:flex}
|
|
2188
|
+
.dsh-turn-preview{width:min(360px,88vw);height:min(70vh,520px);top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important}
|
|
2189
|
+
|
|
2190
|
+
}
|
|
2191
|
+
`
|
|
2192
|
+
const turnRulerPlugin = {
|
|
2193
|
+
inject: [],
|
|
2194
|
+
apply(ctx) {
|
|
2195
|
+
ctx.effect(() => {
|
|
2196
|
+
const style = document.createElement('style')
|
|
2197
|
+
style.dataset.plugin = 'dsh-long-plugins'
|
|
2198
|
+
style.dataset.pluginCss = 'dsh-long-plugins/turn-ruler'
|
|
2199
|
+
style.textContent = TURN_RULER_CSS
|
|
2200
|
+
document.head.appendChild(style)
|
|
2201
|
+
return () => style.remove()
|
|
2202
|
+
}, 'dsh-long-plugins: turn ruler styles')
|
|
2203
|
+
|
|
2204
|
+
ctx.effect(() => {
|
|
2205
|
+
let ruler = null
|
|
2206
|
+
let preview = null
|
|
2207
|
+
let turns = [] // turns[i] = { userNode, summary }
|
|
2208
|
+
let scrollEl = null
|
|
2209
|
+
let observer = null
|
|
2210
|
+
let raf = 0
|
|
2211
|
+
let rendering = false
|
|
2212
|
+
let curIndex = -1
|
|
2213
|
+
let hideTimer = 0
|
|
2214
|
+
let touchActive = false
|
|
2215
|
+
let phoneTab = null
|
|
2216
|
+
let turnsPrevCount = -1
|
|
2217
|
+
let loadingOlder = false
|
|
2218
|
+
let scrollAnim = 0
|
|
2219
|
+
let scrollTarget = -1
|
|
2220
|
+
let scrollFrom = 0
|
|
2221
|
+
|
|
2222
|
+
const ensureRuler = () => {
|
|
2223
|
+
if (ruler && ruler.isConnected) return ruler
|
|
2224
|
+
ruler = document.createElement('div')
|
|
2225
|
+
ruler.className = 'dsh-turn-ruler'
|
|
2226
|
+
ruler.setAttribute('aria-label', '轮次导航')
|
|
2227
|
+
document.body.appendChild(ruler)
|
|
2228
|
+
return ruler
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
const ensurePhoneTab = () => {
|
|
2232
|
+
if (phoneTab && phoneTab.isConnected) return phoneTab
|
|
2233
|
+
phoneTab = document.createElement('button')
|
|
2234
|
+
phoneTab.type = 'button'
|
|
2235
|
+
phoneTab.className = 'dsh-turn-phone-tab'
|
|
2236
|
+
phoneTab.setAttribute('aria-label', '轮次导航')
|
|
2237
|
+
for (let i = 0; i < 3; i++) {
|
|
2238
|
+
const d = document.createElement('span')
|
|
2239
|
+
d.className = 'dsh-turn-phone-tab-dot'
|
|
2240
|
+
phoneTab.appendChild(d)
|
|
2241
|
+
}
|
|
2242
|
+
document.body.appendChild(phoneTab)
|
|
2243
|
+
return phoneTab
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
const ensurePreview = () => {
|
|
2247
|
+
if (preview && preview.isConnected) return preview
|
|
2248
|
+
preview = document.createElement('div')
|
|
2249
|
+
preview.className = 'dsh-turn-preview'
|
|
2250
|
+
preview.innerHTML = '<div class="dsh-turn-preview-head"><span class="dsh-turn-preview-title">历史提问</span><span class="dsh-turn-preview-count"></span><button type="button" class="dsh-turn-preview-close" aria-label="关闭">✕</button></div><div class="dsh-turn-preview-body"></div>'
|
|
2251
|
+
document.body.appendChild(preview)
|
|
2252
|
+
return preview
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
const findScrollEl = (node) => {
|
|
2256
|
+
let el = node && node.parentElement
|
|
2257
|
+
while (el && el !== document.body) {
|
|
2258
|
+
const cs = getComputedStyle(el)
|
|
2259
|
+
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') return el
|
|
2260
|
+
el = el.parentElement
|
|
2261
|
+
}
|
|
2262
|
+
return null
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
const jumpTo = (node) => {
|
|
2266
|
+
if (!node || !node.isConnected) return
|
|
2267
|
+
const host = scrollEl
|
|
2268
|
+
if (host && host.isConnected) {
|
|
2269
|
+
const r = node.getBoundingClientRect()
|
|
2270
|
+
const sr = host.getBoundingClientRect()
|
|
2271
|
+
const targetTop = host.scrollTop + (r.top - sr.top) - 12
|
|
2272
|
+
try {
|
|
2273
|
+
host.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' })
|
|
2274
|
+
} catch {
|
|
2275
|
+
host.scrollTop = Math.max(0, targetTop)
|
|
2276
|
+
}
|
|
2277
|
+
} else {
|
|
2278
|
+
node.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
// 提取一轮的摘要:取用户提问节点的文本(去图标/按钮文字)
|
|
2283
|
+
const summaryOf = (node) => {
|
|
2284
|
+
if (!node || !node.isConnected) return ''
|
|
2285
|
+
const bubble = node.querySelector('[class*="_bubble"]')
|
|
2286
|
+
let t = (bubble ? bubble.textContent : node.textContent || '').replace(/\s+/g, ' ').trim()
|
|
2287
|
+
t = t.replace(/(复制|下载|编辑|删除|预览|重试|点赞|点踩)$/i, '').trim()
|
|
2288
|
+
return t.slice(0, 120)
|
|
2289
|
+
}
|
|
2290
|
+
// 完整文本:用于搜索匹配(不截断)。优先取正文气泡(排除引用摘要/按钮杂讯),
|
|
2291
|
+
// 类名带 hash(gdEzaW_ 等),用 [class*="_bubble"] 通配;失败回退整个节点文本
|
|
2292
|
+
const fullTextOf = (node) => {
|
|
2293
|
+
if (!node || !node.isConnected) return ''
|
|
2294
|
+
const bubble = node.querySelector('[class*="_bubble"]')
|
|
2295
|
+
if (bubble) {
|
|
2296
|
+
const t = (bubble.textContent || '').replace(/\s+/g, ' ').trim()
|
|
2297
|
+
if (t) return t
|
|
2298
|
+
}
|
|
2299
|
+
return (node.textContent || '').replace(/\s+/g, ' ').trim()
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// 主会话滚动:只更新 3 个刻度点的高亮。预览窗滚动位置 100% 由用户操作控制,
|
|
2303
|
+
// 绝不因主会话滚动/加载而移动(否则加载更早历史后焦点会跑到主会话位置)。
|
|
2304
|
+
const updateActive = () => {
|
|
2305
|
+
if (!scrollEl || turns.length === 0 || !ruler) return
|
|
2306
|
+
const viewTop = scrollEl.scrollTop
|
|
2307
|
+
let active = 0
|
|
2308
|
+
for (let i = 0; i < turns.length; i++) {
|
|
2309
|
+
const n = turns[i].userNode
|
|
2310
|
+
if (!n.isConnected) continue
|
|
2311
|
+
const r = n.getBoundingClientRect()
|
|
2312
|
+
const sr = scrollEl.getBoundingClientRect()
|
|
2313
|
+
const top = r.top - sr.top + scrollEl.scrollTop
|
|
2314
|
+
if (top <= viewTop + 90) active = i
|
|
2315
|
+
}
|
|
2316
|
+
// 3 个刻度点按当前轮次在总轮数中的位置高亮
|
|
2317
|
+
const ratio = turns.length <= 1 ? 0 : active / (turns.length - 1)
|
|
2318
|
+
const dotIdx = ratioToDot(ratio)
|
|
2319
|
+
const dots = ruler.querySelectorAll('.dsh-turn-ruler-dot')
|
|
2320
|
+
dots.forEach((d, i) => d.classList.toggle('active', i === dotIdx))
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
// 构建轮次:每个 user 节点一轮,摘要取该用户提问文本
|
|
2324
|
+
const buildTurns = () => {
|
|
2325
|
+
const result = []
|
|
2326
|
+
const users = document.querySelectorAll('[data-chat-flow-kind="user"]')
|
|
2327
|
+
users.forEach((node) => {
|
|
2328
|
+
const summary = summaryOf(node)
|
|
2329
|
+
if (summary) result.push({ userNode: node, summary, fullText: fullTextOf(node) })
|
|
2330
|
+
})
|
|
2331
|
+
return result
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// 重建列表行(turns 集合变化时调用)
|
|
2335
|
+
// 焦点行 = 中心行:scrollTop=0 时第 0 行在焦点;scrollTop=(n-1)*rowH 时最后一行在焦点
|
|
2336
|
+
// 基于实际行 offsetTop 计算当前 scrollTop 对应的行(比估算行高精确)
|
|
2337
|
+
const focusIndex = () => {
|
|
2338
|
+
const body = preview && preview.querySelector('.dsh-turn-preview-body')
|
|
2339
|
+
if (!body) return 0
|
|
2340
|
+
const rows = body.querySelectorAll('.dsh-turn-preview-row')
|
|
2341
|
+
if (rows.length === 0) return 0
|
|
2342
|
+
const st = body.scrollTop
|
|
2343
|
+
for (let i = 0; i < rows.length; i++) {
|
|
2344
|
+
if (rows[i].offsetTop + rows[i].offsetHeight > st) return i
|
|
2345
|
+
}
|
|
2346
|
+
return rows.length - 1
|
|
2347
|
+
}
|
|
2348
|
+
const buildRows = () => {
|
|
2349
|
+
const el = ensurePreview()
|
|
2350
|
+
const body = el.querySelector('.dsh-turn-preview-body')
|
|
2351
|
+
// 重建前:停止动画;记录当前 scrollTop(用户操作的位置)
|
|
2352
|
+
cancelAnimationFrame(scrollAnim)
|
|
2353
|
+
scrollTarget = -1
|
|
2354
|
+
const scrollBefore = body ? body.scrollTop : 0
|
|
2355
|
+
body.innerHTML = ''
|
|
2356
|
+
turns.forEach((t, index) => {
|
|
2357
|
+
const row = document.createElement('div')
|
|
2358
|
+
row.className = 'dsh-turn-preview-row'
|
|
2359
|
+
row.dataset.index = String(index)
|
|
2360
|
+
const n = document.createElement('span')
|
|
2361
|
+
n.className = 'n'
|
|
2362
|
+
n.textContent = String(index + 1)
|
|
2363
|
+
const txt = document.createElement('span')
|
|
2364
|
+
txt.className = 't'
|
|
2365
|
+
txt.textContent = t.summary
|
|
2366
|
+
row.appendChild(n)
|
|
2367
|
+
row.appendChild(txt)
|
|
2368
|
+
body.appendChild(row)
|
|
2369
|
+
})
|
|
2370
|
+
el.querySelector('.dsh-turn-preview-count').textContent = `${turns.length} 轮`
|
|
2371
|
+
cachedRowH = -1
|
|
2372
|
+
rowHeightOf()
|
|
2373
|
+
// 列表上下加 padding:第一行和最后一行都能滚到中心焦点位
|
|
2374
|
+
applyFocusPadding()
|
|
2375
|
+
// 原则:预览窗位置只由用户操作控制。重建后保持原 scrollTop 完全不变
|
|
2376
|
+
// (加载更早历史 = 新行插在顶部,用户不操作,位置就不动;需要看新内容自己往上滚)
|
|
2377
|
+
if (body) {
|
|
2378
|
+
const max = Math.max(0, (turns.length - 1) * rowHeightOf())
|
|
2379
|
+
body.scrollTop = Math.max(0, Math.min(scrollBefore, max))
|
|
2380
|
+
}
|
|
2381
|
+
updateRowVisuals()
|
|
2382
|
+
}
|
|
2383
|
+
// 上下 padding = (视口高 - 行高)/2:首行 scrollTop=0 时中心恰在焦点线,末行同理
|
|
2384
|
+
const applyFocusPadding = () => {
|
|
2385
|
+
const body = preview && preview.querySelector('.dsh-turn-preview-body')
|
|
2386
|
+
if (!body) return
|
|
2387
|
+
const h = rowHeightOf()
|
|
2388
|
+
const pad = Math.max(0, (body.clientHeight - h) / 4)
|
|
2389
|
+
body.style.paddingTop = pad + 'px'
|
|
2390
|
+
body.style.paddingBottom = pad + 'px'
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
let cachedRowH = 30
|
|
2394
|
+
const rowHeightOf = () => {
|
|
2395
|
+
if (cachedRowH > 0) return cachedRowH
|
|
2396
|
+
if (!preview) return 30
|
|
2397
|
+
const first = preview.querySelector('.dsh-turn-preview-row')
|
|
2398
|
+
cachedRowH = first ? first.offsetHeight || 30 : 30
|
|
2399
|
+
return cachedRowH
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
// 视觉:active 行(点击切换的当前会话轮次)蓝色标记,其余不变
|
|
2403
|
+
|
|
2404
|
+
|
|
2405
|
+
// 列表滚动到某行到焦点位(无动画直接定位)
|
|
2406
|
+
const scrollListTo = (index) => {
|
|
2407
|
+
const body = preview && preview.querySelector('.dsh-turn-preview-body')
|
|
2408
|
+
if (!body) return
|
|
2409
|
+
const h = rowHeightOf()
|
|
2410
|
+
const max = Math.max(0, (turns.length - 1) * h)
|
|
2411
|
+
body.scrollTop = Math.max(0, Math.min(max, index * h))
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// 选中某行:滚到该行真实 offsetTop + 直接高亮目标行(不依赖 scrollTop 反推,
|
|
2415
|
+
// 避免行高缓存偏差导致高亮错位)
|
|
2416
|
+
const selectRow = (index) => {
|
|
2417
|
+
if (turns.length === 0) return
|
|
2418
|
+
curIndex = Math.max(0, Math.min(turns.length - 1, index))
|
|
2419
|
+
const body = preview && preview.querySelector('.dsh-turn-preview-body')
|
|
2420
|
+
if (body) {
|
|
2421
|
+
const target = body.querySelector('.dsh-turn-preview-row[data-index="' + curIndex + '"]')
|
|
2422
|
+
if (target) {
|
|
2423
|
+
body.scrollTop = Math.max(0, target.offsetTop)
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
updateRowVisuals(true)
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
// 高亮功能已移除:只维护 curIndex(供点击/滚动定位使用),不加任何 active 样式
|
|
2430
|
+
const updateRowVisuals = (explicitIndex) => {
|
|
2431
|
+
if (explicitIndex !== undefined) {
|
|
2432
|
+
curIndex = Math.max(0, Math.min((turns.length || 1) - 1, explicitIndex))
|
|
2433
|
+
} else if (preview) {
|
|
2434
|
+
const body = preview.querySelector('.dsh-turn-preview-body')
|
|
2435
|
+
const rows = preview.querySelectorAll('.dsh-turn-preview-row')
|
|
2436
|
+
const st = body ? body.scrollTop : 0
|
|
2437
|
+
curIndex = 0
|
|
2438
|
+
for (let i = 0; i < rows.length; i++) {
|
|
2439
|
+
if (rows[i].offsetTop + rows[i].offsetHeight > st) { curIndex = i; break }
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
const syncPreviewHighlight = (index) => {
|
|
2445
|
+
selectRow(index)
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
// 显示浮窗并选中 index:仅滚动浮窗列表到该行,主会话不跟随
|
|
2449
|
+
const showPreview = (index) => {
|
|
2450
|
+
const el = ensurePreview()
|
|
2451
|
+
if (turns.length === 0) { el.classList.remove('open'); return }
|
|
2452
|
+
el.classList.add('open')
|
|
2453
|
+
applyFocusPadding()
|
|
2454
|
+
selectRow(index)
|
|
2455
|
+
const pr = el.getBoundingClientRect()
|
|
2456
|
+
const rulerRect = ruler.getBoundingClientRect()
|
|
2457
|
+
const left = rulerRect.left - pr.width - 10
|
|
2458
|
+
let top = rulerRect.top + rulerRect.height / 2 - pr.height / 2
|
|
2459
|
+
top = Math.max(10, Math.min(top, window.innerHeight - pr.height - 10))
|
|
2460
|
+
el.style.left = Math.max(8, left) + 'px'
|
|
2461
|
+
el.style.top = top + 'px'
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
const hidePreview = () => {
|
|
2465
|
+
if (preview) preview.classList.remove('open')
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
// 按轮次比例计算三个刻度点的归属:0=最早 1=中间 2=最新
|
|
2469
|
+
const ratioToDot = (ratio) => {
|
|
2470
|
+
if (ratio < 0.34) return 0
|
|
2471
|
+
if (ratio < 0.67) return 1
|
|
2472
|
+
return 2
|
|
2473
|
+
}
|
|
2474
|
+
const buildRulerStatic = () => {
|
|
2475
|
+
const el = ensureRuler()
|
|
2476
|
+
el.innerHTML = ''
|
|
2477
|
+
for (let i = 0; i < 3; i++) {
|
|
2478
|
+
const dot = document.createElement('button')
|
|
2479
|
+
dot.type = 'button'
|
|
2480
|
+
dot.className = 'dsh-turn-ruler-dot'
|
|
2481
|
+
dot.dataset.dot = String(i)
|
|
2482
|
+
dot.setAttribute('aria-label', ['最早轮次', '中间轮次', '最新轮次'][i])
|
|
2483
|
+
el.appendChild(dot)
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
}
|
|
2487
|
+
const render = () => {
|
|
2488
|
+
if (rendering) return
|
|
2489
|
+
rendering = true
|
|
2490
|
+
try {
|
|
2491
|
+
const el = ensureRuler()
|
|
2492
|
+
const nextTurns = buildTurns()
|
|
2493
|
+
const countChanged = turns.length !== nextTurns.length
|
|
2494
|
+
turnsPrevCount = turns.length
|
|
2495
|
+
turns = nextTurns
|
|
2496
|
+
if (turns.length === 0) { el.style.display = 'none'; hidePreview(); return }
|
|
2497
|
+
el.style.display = 'flex'
|
|
2498
|
+
scrollEl = findScrollEl(turns[0].userNode) || scrollEl
|
|
2499
|
+
if (countChanged) {
|
|
2500
|
+
buildRulerStatic()
|
|
2501
|
+
buildRows()
|
|
2502
|
+
}
|
|
2503
|
+
updateActive()
|
|
2504
|
+
} finally {
|
|
2505
|
+
rendering = false
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
const schedule = () => {
|
|
2510
|
+
cancelAnimationFrame(raf)
|
|
2511
|
+
raf = requestAnimationFrame(render)
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
// 事件委托:预览行点击 → 定位;3 刻度点 → 按比例定位;回到最后 → 滚到会话底部
|
|
2515
|
+
const onClick = (event) => {
|
|
2516
|
+
const t = event.target
|
|
2517
|
+
const closeBtn = t && t.closest ? t.closest('.dsh-turn-preview-close') : null
|
|
2518
|
+
if (closeBtn) {
|
|
2519
|
+
event.preventDefault()
|
|
2520
|
+
hidePreview()
|
|
2521
|
+
return
|
|
2522
|
+
}
|
|
2523
|
+
const row = t && t.closest ? t.closest('.dsh-turn-preview-row') : null
|
|
2524
|
+
if (row) {
|
|
2525
|
+
const index = Number(row.dataset.index)
|
|
2526
|
+
if (Number.isFinite(index) && turns[index]) {
|
|
2527
|
+
event.preventDefault()
|
|
2528
|
+
selectRow(index)
|
|
2529
|
+
jumpTo(turns[index].userNode)
|
|
2530
|
+
// 手机端:定位后关闭预览窗,方便看到会话跳转
|
|
2531
|
+
if (window.innerWidth <= 1024) hidePreview()
|
|
2532
|
+
}
|
|
2533
|
+
return
|
|
2534
|
+
}
|
|
2535
|
+
const tabBtn = t && t.closest ? t.closest('.dsh-turn-phone-tab') : null
|
|
2536
|
+
if (tabBtn) {
|
|
2537
|
+
event.preventDefault()
|
|
2538
|
+
const index = curIndex >= 0 ? curIndex : 0
|
|
2539
|
+
showPreview(index)
|
|
2540
|
+
return
|
|
2541
|
+
}
|
|
2542
|
+
const dot = t && t.closest ? t.closest('.dsh-turn-ruler-dot') : null
|
|
2543
|
+
if (dot) {
|
|
2544
|
+
event.preventDefault()
|
|
2545
|
+
if (turns.length === 0) return
|
|
2546
|
+
const dotIdx = Number(dot.dataset.dot)
|
|
2547
|
+
const ratio = [0, 0.5, 1][dotIdx] ?? 0
|
|
2548
|
+
const index = Math.round(ratio * (turns.length - 1))
|
|
2549
|
+
if (turns[index]) {
|
|
2550
|
+
selectRow(index)
|
|
2551
|
+
jumpTo(turns[index].userNode)
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
// 悬停刻度 → 打开浮窗并按比例选中对应轮次
|
|
2557
|
+
const onMouseOver = (event) => {
|
|
2558
|
+
const dot = event.target && event.target.closest
|
|
2559
|
+
? event.target.closest('.dsh-turn-ruler-dot')
|
|
2560
|
+
: null
|
|
2561
|
+
if (dot && turns.length > 0) {
|
|
2562
|
+
const dotIdx = Number(dot.dataset.dot)
|
|
2563
|
+
const ratio = [0, 0.5, 1][dotIdx] ?? 0
|
|
2564
|
+
const index = Math.round(ratio * (turns.length - 1))
|
|
2565
|
+
showPreview(index)
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
// 全局鼠标跟踪:进入刻度条/浮窗 → 取消延迟;移出两者 → 延迟 200ms 隐藏,
|
|
2570
|
+
// 保证鼠标从刻度条跨空白进入浮窗期间浮窗不消失。
|
|
2571
|
+
const onDocMouseOver = (event) => {
|
|
2572
|
+
const t = event.target
|
|
2573
|
+
const inRuler = t && t.closest ? t.closest('.dsh-turn-ruler') : null
|
|
2574
|
+
const inPreview = t && t.closest ? t.closest('.dsh-turn-preview') : null
|
|
2575
|
+
if (touchActive) return
|
|
2576
|
+
if (inRuler || inPreview) {
|
|
2577
|
+
if (hideTimer) { clearTimeout(hideTimer); hideTimer = 0 }
|
|
2578
|
+
} else if (!hideTimer) {
|
|
2579
|
+
hideTimer = setTimeout(() => { hideTimer = 0; hidePreview() }, 200)
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// 触摸滚动:预览窗 touch-action:none,手指滑动只驱动列表,页面不动
|
|
2584
|
+
let touchStartY = 0
|
|
2585
|
+
let touchBodyTop = 0
|
|
2586
|
+
const onPreviewTouchStart = (event) => {
|
|
2587
|
+
touchActive = true
|
|
2588
|
+
if (hideTimer) { clearTimeout(hideTimer); hideTimer = 0 }
|
|
2589
|
+
const body = preview && preview.querySelector('.dsh-turn-preview-body')
|
|
2590
|
+
if (!body || !preview.classList.contains('open') || !event.touches) return
|
|
2591
|
+
cancelAnimationFrame(scrollAnim)
|
|
2592
|
+
touchStartY = event.touches[0].clientY
|
|
2593
|
+
touchBodyTop = body.scrollTop
|
|
2594
|
+
}
|
|
2595
|
+
let touchFrame = 0
|
|
2596
|
+
const onPreviewTouchMove = (event) => {
|
|
2597
|
+
const body = preview && preview.querySelector('.dsh-turn-preview-body')
|
|
2598
|
+
if (!body || !preview.classList.contains('open') || !event.touches || turns.length === 0) return
|
|
2599
|
+
if (loadingOlder) return
|
|
2600
|
+
event.preventDefault()
|
|
2601
|
+
// 跟手滚动:只改 scrollTop(布局原生滚动,不触发视觉重算),避免每帧遍历卡顿
|
|
2602
|
+
const dy = touchStartY - event.touches[0].clientY
|
|
2603
|
+
const max = Math.max(0, (turns.length - 1) * rowHeightOf())
|
|
2604
|
+
body.scrollTop = Math.max(0, Math.min(max, touchBodyTop + dy))
|
|
2605
|
+
// rAF 节流更新视觉(每秒最多 ~60 次,且只 toggle 少量 class)
|
|
2606
|
+
if (!touchFrame) {
|
|
2607
|
+
touchFrame = requestAnimationFrame(() => {
|
|
2608
|
+
touchFrame = 0
|
|
2609
|
+
updateRowVisuals()
|
|
2610
|
+
})
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
// 浮窗内滚轮 → 原生滚动列表浏览轮次标题;滚到顶部且主会话还有更早历史时自动加载
|
|
2614
|
+
let loadChain = 0
|
|
2615
|
+
const tryLoadOlder = () => {
|
|
2616
|
+
if (loadingOlder) return true
|
|
2617
|
+
cancelAnimationFrame(scrollAnim)
|
|
2618
|
+
scrollTarget = -1
|
|
2619
|
+
const flow = document.querySelector('[data-chat-flow]')
|
|
2620
|
+
const btn = flow && flow.querySelector('.Md3f7G_older button, [class*="_older"] button')
|
|
2621
|
+
if (!btn) return false
|
|
2622
|
+
if (btn.disabled) {
|
|
2623
|
+
// 正在加载:等待恢复后若仍接近顶部则继续加载下一页
|
|
2624
|
+
const chain = ++loadChain
|
|
2625
|
+
setTimeout(() => {
|
|
2626
|
+
if (chain === loadChain && preview && preview.classList.contains('open')) {
|
|
2627
|
+
const body = preview.querySelector('.dsh-turn-preview-body')
|
|
2628
|
+
if (body && body.scrollTop <= rowHeightOf() * 6) tryLoadOlder()
|
|
2629
|
+
}
|
|
2630
|
+
}, 120)
|
|
2631
|
+
return true
|
|
2632
|
+
}
|
|
2633
|
+
// 锁定:加载期间忽略新的滚动动画,避免与重建冲突
|
|
2634
|
+
loadingOlder = true
|
|
2635
|
+
// 列表顶部立即显示加载占位(消除等待期间的空白感)
|
|
2636
|
+
const pvBody = preview && preview.querySelector('.dsh-turn-preview-body')
|
|
2637
|
+
if (pvBody && !pvBody.querySelector('.dsh-turn-preview-loading')) {
|
|
2638
|
+
const ph = document.createElement('div')
|
|
2639
|
+
ph.className = 'dsh-turn-preview-loading'
|
|
2640
|
+
ph.textContent = '加载中…'
|
|
2641
|
+
pvBody.insertBefore(ph, pvBody.firstChild)
|
|
2642
|
+
}
|
|
2643
|
+
btn.click()
|
|
2644
|
+
// 等按钮先变 disabled(DSH 开始加载)再恢复可用(加载完成)后解锁
|
|
2645
|
+
let sawBusy = false
|
|
2646
|
+
const probe = () => {
|
|
2647
|
+
const b2 = document.querySelector('[data-chat-flow] [class*="_older"] button')
|
|
2648
|
+
if (b2) {
|
|
2649
|
+
if (b2.disabled) sawBusy = true
|
|
2650
|
+
else if (sawBusy) {
|
|
2651
|
+
// 加载完成:延迟释放,让主会话加载后的滚动尘埃落定,避免覆盖预览焦点
|
|
2652
|
+
setTimeout(() => { loadingOlder = false }, 100)
|
|
2653
|
+
return
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
setTimeout(probe, 15)
|
|
2657
|
+
}
|
|
2658
|
+
probe()
|
|
2659
|
+
return true
|
|
2660
|
+
}
|
|
2661
|
+
// 阻尼滚动 scrollTop(焦点固定、内容移动的 picker 手感)
|
|
2662
|
+
const dampedScrollTo = (target) => {
|
|
2663
|
+
const body = preview && preview.querySelector('.dsh-turn-preview-body')
|
|
2664
|
+
if (!body) return
|
|
2665
|
+
cancelAnimationFrame(scrollAnim)
|
|
2666
|
+
scrollFrom = body.scrollTop
|
|
2667
|
+
scrollTarget = Math.max(0, Math.min(target, Math.max(0, (turns.length - 1) * rowHeightOf())))
|
|
2668
|
+
if (Math.abs(scrollTarget - scrollFrom) < 0.5) { scrollTarget = -1; return }
|
|
2669
|
+
const step = () => {
|
|
2670
|
+
const body2 = preview && preview.querySelector('.dsh-turn-preview-body')
|
|
2671
|
+
if (!body2 || scrollTarget < 0) { scrollTarget = -1; return }
|
|
2672
|
+
const delta = (scrollTarget - body2.scrollTop) * 0.38
|
|
2673
|
+
if (Math.abs(delta) < 0.5) {
|
|
2674
|
+
body2.scrollTop = scrollTarget
|
|
2675
|
+
scrollTarget = -1
|
|
2676
|
+
updateRowVisuals()
|
|
2677
|
+
return
|
|
2678
|
+
}
|
|
2679
|
+
body2.scrollTop += delta
|
|
2680
|
+
updateRowVisuals()
|
|
2681
|
+
scrollAnim = requestAnimationFrame(step)
|
|
2682
|
+
}
|
|
2683
|
+
scrollAnim = requestAnimationFrame(step)
|
|
2684
|
+
}
|
|
2685
|
+
const onPreviewWheel = (event) => {
|
|
2686
|
+
if (!preview || !preview.classList.contains('open') || turns.length === 0) return
|
|
2687
|
+
event.preventDefault()
|
|
2688
|
+
const body = preview.querySelector('.dsh-turn-preview-body')
|
|
2689
|
+
if (!body) return
|
|
2690
|
+
// 加载更早历史期间锁定滚动,等重建完成后由锚点恢复焦点(避免震动)
|
|
2691
|
+
if (loadingOlder) return
|
|
2692
|
+
dampedScrollTo(body.scrollTop + event.deltaY * 0.5)
|
|
2693
|
+
// 接近顶部(1.5 行内)且继续向上滚 → 提前自动加载更早历史
|
|
2694
|
+
if (body.scrollTop <= rowHeightOf() * 6 && event.deltaY < 0) tryLoadOlder()
|
|
2695
|
+
// 已到列表顶部:尝试在主会话触发「加载更早」(按钮在 [data-chat-flow] 内、消息之前)
|
|
2696
|
+
if (body.scrollTop <= 2) tryLoadOlder()
|
|
2697
|
+
}
|
|
2698
|
+
// 触摸滚动后也检查顶部 → 自动加载
|
|
2699
|
+
const onPreviewTouchEnd = () => {
|
|
2700
|
+
// 触摸结束后短暂保持抑制,等合成的 mouse 事件过去
|
|
2701
|
+
setTimeout(() => { touchActive = false }, 300)
|
|
2702
|
+
const body = preview && preview.querySelector('.dsh-turn-preview-body')
|
|
2703
|
+
if (body && body.scrollTop <= rowHeightOf() * 6) tryLoadOlder()
|
|
2704
|
+
}
|
|
2705
|
+
// 列表 scroll 事件:仅用于滚到顶部自动加载(视觉不随滚动变化)
|
|
2706
|
+
const onBodyScroll = () => {
|
|
2707
|
+
if (rendering) return
|
|
2708
|
+
updateRowVisuals()
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// 浮窗行 hover:纯 CSS 点亮,不滚动列表、不改变选中(滚动/切换仅通过滚动与点击)
|
|
2712
|
+
const onRowOver = () => {
|
|
2713
|
+
// no-op: hover styling handled entirely by CSS
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
observer = new MutationObserver((mutations) => {
|
|
2717
|
+
if (rendering) return
|
|
2718
|
+
const relevant = mutations.some((m) => {
|
|
2719
|
+
if (m.type !== 'childList') return false
|
|
2720
|
+
const t = m.target
|
|
2721
|
+
// 刻度条/浮窗自身的变更忽略(它们由我们维护,不反映会话变化)
|
|
2722
|
+
if (ruler && (t === ruler || ruler.contains(t))) return false
|
|
2723
|
+
if (preview && (t === preview || preview.contains(t))) return false
|
|
2724
|
+
// 其余任何 DOM 增删都可能带来新轮次(新消息常加在会话容器里,
|
|
2725
|
+
// target 不是 [data-chat-flow-kind] 后代,故不再做精确匹配)
|
|
2726
|
+
return true
|
|
2727
|
+
})
|
|
2728
|
+
if (relevant) schedule()
|
|
2729
|
+
})
|
|
2730
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
2731
|
+
|
|
2732
|
+
const root = ensureRuler()
|
|
2733
|
+
root.addEventListener('click', onClick)
|
|
2734
|
+
root.addEventListener('mouseover', onMouseOver)
|
|
2735
|
+
document.addEventListener('mouseover', onDocMouseOver)
|
|
2736
|
+
const pv = ensurePreview()
|
|
2737
|
+
pv.addEventListener('click', onClick)
|
|
2738
|
+
pv.addEventListener('wheel', onPreviewWheel, { passive: false })
|
|
2739
|
+
pv.addEventListener('touchstart', onPreviewTouchStart, { passive: true })
|
|
2740
|
+
pv.addEventListener('touchmove', onPreviewTouchMove, { passive: false })
|
|
2741
|
+
pv.addEventListener('touchend', onPreviewTouchEnd, { passive: true })
|
|
2742
|
+
pv.addEventListener('mouseover', onRowOver)
|
|
2743
|
+
const tab = ensurePhoneTab()
|
|
2744
|
+
tab.addEventListener('click', onClick)
|
|
2745
|
+
|
|
2746
|
+
// 列表滚动(含惯性)时同步选中视觉
|
|
2747
|
+
const pvBody = pv.querySelector('.dsh-turn-preview-body')
|
|
2748
|
+
if (pvBody) pvBody.addEventListener('scroll', onBodyScroll, { passive: true })
|
|
2749
|
+
render()
|
|
2750
|
+
// 主会话滚动 → 同步刻度高亮;但加载更早历史期间及完成后短暂窗口内,
|
|
2751
|
+
// 不滚动预览窗(否则 DSH 加载后主会话自身滚动会覆盖用户焦点)
|
|
2752
|
+
const onSessionScroll = () => {
|
|
2753
|
+
if (loadingOlder) return
|
|
2754
|
+
updateActive()
|
|
2755
|
+
}
|
|
2756
|
+
if (scrollEl) scrollEl.addEventListener('scroll', onSessionScroll, { passive: true })
|
|
2757
|
+
window.addEventListener('resize', () => {
|
|
2758
|
+
cachedRowH = -1
|
|
2759
|
+
rowHeightOf()
|
|
2760
|
+
applyFocusPadding()
|
|
2761
|
+
updateActive()
|
|
2762
|
+
updateRowVisuals()
|
|
2763
|
+
})
|
|
2764
|
+
|
|
2765
|
+
return () => {
|
|
2766
|
+
cancelAnimationFrame(raf)
|
|
2767
|
+
cancelAnimationFrame(scrollAnim)
|
|
2768
|
+
if (hideTimer) clearTimeout(hideTimer)
|
|
2769
|
+
if (observer) observer.disconnect()
|
|
2770
|
+
root.removeEventListener('click', onClick)
|
|
2771
|
+
root.removeEventListener('mouseover', onMouseOver)
|
|
2772
|
+
document.removeEventListener('mouseover', onDocMouseOver)
|
|
2773
|
+
pv.removeEventListener('click', onClick)
|
|
2774
|
+
pv.removeEventListener('wheel', onPreviewWheel)
|
|
2775
|
+
const _pvBody = pv.querySelector('.dsh-turn-preview-body')
|
|
2776
|
+
if (_pvBody) _pvBody.removeEventListener('scroll', onBodyScroll)
|
|
2777
|
+
pv.removeEventListener('touchstart', onPreviewTouchStart)
|
|
2778
|
+
pv.removeEventListener('touchmove', onPreviewTouchMove)
|
|
2779
|
+
pv.removeEventListener('touchend', onPreviewTouchEnd)
|
|
2780
|
+
pv.removeEventListener('mouseover', onRowOver)
|
|
2781
|
+
|
|
2782
|
+
if (scrollEl) scrollEl.removeEventListener('scroll', onSessionScroll)
|
|
2783
|
+
window.removeEventListener('resize', updateActive)
|
|
2784
|
+
if (ruler) ruler.remove()
|
|
2785
|
+
if (phoneTab) phoneTab.remove()
|
|
2786
|
+
if (preview) preview.remove()
|
|
2787
|
+
}
|
|
2788
|
+
}, 'dsh-long-plugins: turn ruler')
|
|
2789
|
+
},
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
const inject = Array.from(new Set([
|
|
2793
|
+
...uploadPlugin.inject,
|
|
2794
|
+
...skillDocsPlugin.inject,
|
|
2795
|
+
...tokenUsagePlugin.inject,
|
|
2796
|
+
...mobilePlugin.inject,
|
|
2797
|
+
...workspaceFilesPlugin.inject,
|
|
2798
|
+
...turnRulerPlugin.inject,
|
|
2799
|
+
]))
|
|
2800
|
+
|
|
2801
|
+
function apply(ctx) {
|
|
2802
|
+
const safeApply = (label, fn) => {
|
|
2803
|
+
try {
|
|
2804
|
+
fn(ctx)
|
|
2805
|
+
} catch (error) {
|
|
2806
|
+
console.error('[dsh-long-plugins] ' + label + ' apply failed:', error)
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
safeApply('upload', (c) => uploadPlugin.apply(c))
|
|
2810
|
+
safeApply('skill-docs', (c) => skillDocsPlugin.apply(c))
|
|
2811
|
+
safeApply('token-usage', (c) => tokenUsagePlugin.apply(c))
|
|
2812
|
+
safeApply('mobile-hamburger', (c) => mobilePlugin.apply(c))
|
|
2813
|
+
safeApply('workspace-files', (c) => workspaceFilesPlugin.apply(c))
|
|
2814
|
+
safeApply('turn-ruler', (c) => turnRulerPlugin.apply(c))
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
exports.apply = apply
|
|
2818
|
+
exports.inject = inject
|
|
2819
|
+
return module.exports
|
|
2820
|
+
},
|
|
2821
|
+
})
|