dsh-superinput 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,698 @@
1
+ /* dsh-superinput · prompts 模块客户端:模板库入口 + 面板。 */
2
+ function createPromptsClient(require) {
3
+ const React = require('react')
4
+ const { createPortal } = require('react-dom')
5
+ const h = React.createElement
6
+
7
+ const STYLE_ID = 'dsh-superinput-prompts-style'
8
+ const ENDPOINT = '/api/dsh-superinput/prompts'
9
+
10
+ const CSS = `
11
+ .dsh-cp-btn {
12
+ display: inline-flex;
13
+ align-items: center;
14
+ border: none !important;
15
+ background: transparent !important;
16
+ box-shadow: none !important;
17
+ color: var(--dsw-alias-label-secondary, rgba(232, 238, 250, 0.72)) !important;
18
+ font-size: 12px;
19
+ line-height: 20px;
20
+ padding: 1px 8px;
21
+ border-radius: var(--dsw-radius-sm, 6px);
22
+ cursor: default;
23
+ user-select: none;
24
+ }
25
+ .dsh-cp-btn:hover { background: var(--dsw-alias-interactive-bg-hover) !important; color: var(--dsw-alias-label-primary) !important; }
26
+ .dsh-cp-panel {
27
+ position: fixed;
28
+ z-index: 1100;
29
+ box-sizing: border-box;
30
+ border: 0;
31
+ border-radius: var(--dsw-radius-lg, 12px);
32
+ background: var(--dsw-specific-menu, rgba(67, 69, 74, 0.45));
33
+ backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
34
+ -webkit-backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
35
+ width: min(380px, 100vw - 24px);
36
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
37
+ box-shadow: var(--dsw-elevation-prominent, 0 0 0 0.5px var(--dsw-elevation-stroke-color), 0 3px 8px 0 rgba(0, 0, 0, 0.04), 0 0 20px 0 rgba(0, 0, 0, 0.05));
38
+ padding: 10px;
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: 8px;
42
+ }
43
+ .dsh-cp-head { display: flex; gap: 6px; align-items: center; }
44
+ .dsh-cp-search {
45
+ flex: 1 1 auto; min-width: 0; box-sizing: border-box;
46
+ padding: 6px 10px; font-size: 12px; line-height: 18px;
47
+ color: var(--dsw-alias-label-primary);
48
+ background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.06));
49
+ border: 0.5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
50
+ border-radius: var(--dsw-radius-sm, 6px);
51
+ outline: none;
52
+ }
53
+ .dsh-cp-search:focus { border-color: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.2)); }
54
+ .dsh-cp-new {
55
+ flex: 0 0 auto; width: 26px; height: 26px;
56
+ display: inline-flex; align-items: center; justify-content: center;
57
+ font-size: 16px; line-height: 1;
58
+ color: var(--dsw-alias-label-secondary);
59
+ background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.06));
60
+ border: 0.5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
61
+ border-radius: var(--dsw-radius-sm, 6px);
62
+ cursor: pointer;
63
+ }
64
+ .dsh-cp-new:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
65
+ .dsh-cp-list { display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow-y: auto; }
66
+ .dsh-cp-group { font-size: 11px; line-height: 18px; color: var(--dsw-alias-label-tertiary); padding: 4px 6px 2px; }
67
+ .dsh-cp-row { display: flex; align-items: flex-start; gap: 6px; padding: 6px; border-radius: var(--dsw-radius-sm, 6px); }
68
+ .dsh-cp-row:hover { background: var(--dsw-alias-interactive-bg-hover); }
69
+ .dsh-cp-row-main { flex: 1 1 auto; min-width: 0; cursor: pointer; display: flex; flex-direction: column; gap: 2px; }
70
+ .dsh-cp-title { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
71
+ .dsh-cp-uses { color: var(--dsw-alias-label-tertiary); font-size: 10px; margin-left: 4px; }
72
+ .dsh-cp-preview { font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
73
+ .dsh-cp-vars { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
74
+ .dsh-cp-var { font-size: 10px; line-height: 14px; padding: 0 4px; border-radius: 3px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.08)); }
75
+ .dsh-cp-row-actions { flex: 0 0 auto; display: flex; gap: 2px; }
76
+ .dsh-cp-mini {
77
+ width: 20px; height: 20px; padding: 0;
78
+ display: inline-flex; align-items: center; justify-content: center;
79
+ font-size: 11px; line-height: 1;
80
+ color: var(--dsw-alias-label-tertiary);
81
+ background: transparent; border: none; border-radius: 4px; cursor: pointer;
82
+ }
83
+ .dsh-cp-mini:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.08)); }
84
+ .dsh-cp-mini.danger:hover { color: #f87171; }
85
+ .dsh-cp-mini.active { color: #3a83f7; }
86
+ .dsh-cp-empty { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); padding: 12px 6px; text-align: center; }
87
+ .dsh-cp-editor { display: flex; flex-direction: column; gap: 6px; }
88
+ .dsh-cp-input, .dsh-cp-textarea {
89
+ box-sizing: border-box; width: 100%;
90
+ padding: 6px 10px; font-size: 12px; line-height: 18px;
91
+ color: var(--dsw-alias-label-primary);
92
+ background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.06));
93
+ border: 0.5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
94
+ border-radius: var(--dsw-radius-sm, 6px);
95
+ outline: none; font-family: inherit; resize: vertical;
96
+ }
97
+ .dsh-cp-textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
98
+ .dsh-cp-actions { display: flex; justify-content: flex-end; gap: 6px; }
99
+ .dsh-cp-action {
100
+ padding: 4px 12px; font-size: 12px; line-height: 18px;
101
+ color: var(--dsw-alias-label-secondary);
102
+ background: var(--dsw-alias-bg-layer-1, rgba(255, 255, 255, 0.06));
103
+ border: 0.5px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, 0.12));
104
+ border-radius: var(--dsw-radius-sm, 6px);
105
+ cursor: pointer;
106
+ }
107
+ .dsh-cp-action:hover { color: var(--dsw-alias-label-primary); }
108
+ .dsh-cp-action.primary { color: #fff; background: #3a83f7; border-color: transparent; }
109
+ .dsh-cp-action.primary:hover { background: #4f8ef9; }
110
+ .dsh-cp-selmenu { display: flex; flex-direction: row; align-items: center; gap: 2px; padding: 3px; border-radius: var(--dsw-radius-lg, 12px);
111
+ background: var(--dsw-specific-menu, rgba(67, 69, 74, 0.85));
112
+ backdrop-filter: var(--dsw-menu-backdrop-filter, blur(40px) saturate(150%));
113
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
114
+ box-shadow: var(--dsw-elevation-prominent, 0 0 0 0.5px var(--dsw-elevation-stroke-color), 0 3px 8px 0 rgba(0, 0, 0, 0.1));
115
+ }
116
+ .dsh-cp-selitem { border: none; background: transparent; text-align: left; padding: 5px 10px; font-size: 12px; line-height: 18px;
117
+ color: var(--dsw-alias-label-primary, rgba(232, 238, 250, 0.92)); border-radius: var(--dsw-radius-sm, 6px); cursor: pointer; white-space: nowrap; }
118
+ .dsh-cp-selitem:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.07)); }
119
+ .dsh-cp-toast { padding: 5px 12px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-primary, #fff);
120
+ background: rgba(50, 50, 54, 0.95); border-radius: 8px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); }
121
+ `
122
+
123
+ function ensureStyle() {
124
+ if (typeof document === 'undefined') return
125
+ if (document.getElementById(STYLE_ID)) return
126
+ const style = document.createElement('style')
127
+ style.id = STYLE_ID
128
+ style.textContent = CSS
129
+ document.head.appendChild(style)
130
+ }
131
+
132
+ async function api(path, body) {
133
+ try {
134
+ const res = await fetch(`${ENDPOINT}${path}`, body
135
+ ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
136
+ : { cache: 'no-store' })
137
+ return res.ok ? res.json() : null
138
+ } catch { return null }
139
+ }
140
+
141
+ /** 异常留痕:走 prompts /diag(0.4.0 起套件统一通道,落 superinput-debug.log)。 */
142
+ function report(message) {
143
+ try {
144
+ void fetch('/api/dsh-superinput/prompts/diag', {
145
+ method: 'POST',
146
+ headers: { 'content-type': 'application/json' },
147
+ body: JSON.stringify({ message: `prompts: ${String(message).slice(0, 300)}` }),
148
+ }).catch(() => {})
149
+ } catch { /* ignore */ }
150
+ }
151
+
152
+ /** 找出正文里的 {{变量}},去重保序。 */
153
+ function varsOf(text) {
154
+ const found = []
155
+ const re = /\{\{\s*([a-zA-Z_$][\w$]*)\s*\}\}/g
156
+ let match
157
+ while ((match = re.exec(String(text ?? '')))) {
158
+ if (!found.includes(match[1])) found.push(match[1])
159
+ }
160
+ return found
161
+ }
162
+
163
+ /** 内置动态变量:同步可取的自动填值;取不到的(如剪贴板)留空手填/异步预填。 */
164
+ function autoValue(name) {
165
+ const now = new Date()
166
+ if (name === '日期') return now.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
167
+ if (name === '时间') return now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
168
+ if (name === '星期') return now.toLocaleDateString('zh-CN', { weekday: 'long' })
169
+ return null
170
+ }
171
+
172
+ /** / 命令插入路径:内置动态变量自动取值,手填变量保留 {{名}} 原样由用户行内改。 */
173
+ function insertViaBridge(item) {
174
+ try {
175
+ void api('/use', { id: item.id })
176
+ let out = String(item.body ?? '')
177
+ for (const name of varsOf(out)) {
178
+ const auto = autoValue(name)
179
+ if (auto !== null) out = out.replace(new RegExp(`\\{\\{\\s*${name}\\s*\\}\\}`, 'g'), auto)
180
+ }
181
+ const set = window.__dshSuperinputSetDraft
182
+ if (!set) {
183
+ report('slash insert skipped: draft bridge missing')
184
+ return
185
+ }
186
+ // action 执行时草稿里还留着刚敲的命令行(/e2e),官方壳清 token 与我们的
187
+ // 写入有时序竞争——首行是无空格的 /xxx 就剥掉,两种时序下都只留正文。
188
+ const current = String(window.__dshSuperinputDraft?.() ?? '')
189
+ let base = current
190
+ const firstLine = current.split('\n')[0] ?? ''
191
+ if (current && firstLine.startsWith('/') && !firstLine.includes(' ')) {
192
+ base = current.slice(firstLine.length).replace(/^\n+/, '')
193
+ }
194
+ set(base ? `${base}\n${out}` : out)
195
+ } catch (error) {
196
+ report(`slash insert failed: ${error?.stack ?? error}`)
197
+ }
198
+ }
199
+
200
+ /** 划选正文文字浮出「存」按钮 → 存为模板 / 存为片段(两个库的收集入口)。 */
201
+ function installSelectionSaver() {
202
+ if (typeof document === 'undefined') return
203
+ // 安装守卫:宿主重挂载时 apply 可能跑第二次,重复监听会造出多个浮层。
204
+ if (document.documentElement.dataset.dshSelectionSaver) return
205
+ document.documentElement.dataset.dshSelectionSaver = '1'
206
+ let host = null
207
+ let hideTimer = 0
208
+
209
+ const ensureHost = () => {
210
+ // 文档级单例:无论模块实例化几次,全页只留一个浮层容器。
211
+ const existing = document.querySelectorAll('[data-dsh-superinput-portal="selection"]')
212
+ for (let i = 1; i < existing.length; i += 1) existing[i].remove()
213
+ if (existing[0]?.isConnected) {
214
+ host = existing[0]
215
+ return host
216
+ }
217
+ host = document.createElement('div')
218
+ host.setAttribute('data-dsh-superinput-portal', 'selection')
219
+ host.style.cssText = 'position:fixed;z-index:1150;display:none;'
220
+ document.body.appendChild(host)
221
+ return host
222
+ }
223
+ const hide = () => {
224
+ if (hideTimer) { clearTimeout(hideTimer); hideTimer = 0 }
225
+ if (host) host.style.display = 'none'
226
+ }
227
+ const toast = (text) => {
228
+ const el = ensureHost()
229
+ const div = document.createElement('div')
230
+ div.className = 'dsh-cp-toast'
231
+ div.textContent = text
232
+ el.replaceChildren(div) // 原生 DOM——h() 产物是 React 对象,replaceChildren 会把它强转成 "[object Object]"
233
+ el.style.left = '50%'
234
+ el.style.top = '72px'
235
+ el.style.transform = 'translateX(-50%)'
236
+ el.style.display = 'block'
237
+ if (hideTimer) clearTimeout(hideTimer)
238
+ hideTimer = setTimeout(hide, 1800)
239
+ }
240
+ const copySel = async (text) => {
241
+ hide()
242
+ try {
243
+ await navigator.clipboard?.writeText(text)
244
+ toast('已复制')
245
+ } catch {
246
+ toast('复制失败')
247
+ }
248
+ }
249
+
250
+ /** 把选中文字追加进输入框草稿(草稿桥是 enhancer 客户端挂的全局)。 */
251
+ const addToDraft = (text) => {
252
+ hide()
253
+ try {
254
+ const set = window.__dshSuperinputSetDraft
255
+ if (!set) { toast('草稿桥未就绪,刷新后重试'); return }
256
+ const current = String(window.__dshSuperinputDraft?.() ?? '')
257
+ set(current ? `${current}\n${text}` : text)
258
+ toast('已添加到输入框')
259
+ } catch {
260
+ toast('添加失败')
261
+ }
262
+ }
263
+
264
+ const save = async (text) => {
265
+ const title = text.replace(/\s+/g, ' ').trim().slice(0, 40)
266
+ hide()
267
+ try {
268
+ const res = await fetch('/api/dsh-superinput/prompts/save', {
269
+ method: 'POST',
270
+ headers: { 'content-type': 'application/json' },
271
+ body: JSON.stringify({ title, body: text }),
272
+ })
273
+ const data = res.ok ? await res.json() : null
274
+ toast(data?.ok ? '已存为模板' : '保存失败')
275
+ } catch {
276
+ toast('保存失败')
277
+ }
278
+ }
279
+
280
+ // 选区变化驱动(dblclick 选词在 mouseup 之后才成形,selectionchange 才是可靠信号)
281
+ let showTimer = 0
282
+ document.addEventListener('selectionchange', () => {
283
+ try {
284
+ if (showTimer) { clearTimeout(showTimer); showTimer = 0 }
285
+ const selection = window.getSelection?.()
286
+ const text = String(selection ?? '').trim()
287
+ if (!text || text.length < 4 || !selection.rangeCount) { hide(); return }
288
+ showTimer = setTimeout(() => {
289
+ try {
290
+ const sel = window.getSelection?.()
291
+ const current = String(sel ?? '').trim()
292
+ if (!current || current.length < 4 || !sel.rangeCount) { hide(); return }
293
+ const node = sel.anchorNode
294
+ const el = node instanceof Element ? node : node?.parentElement
295
+ // 只收对话正文:跳过输入区和我们自己的弹层
296
+ if (!el || el.closest('[data-dsh-superinput-portal]') || el.closest('[contenteditable="true"], textarea, input')) { hide(); return }
297
+ const rect = sel.getRangeAt(0).getBoundingClientRect()
298
+ if (!rect || (rect.width === 0 && rect.height === 0)) { hide(); return }
299
+ const container = ensureHost()
300
+ // 横排菜单(复制 / 添加到对话 / 存为模板 ≈210px):左缘对齐选区,右侧防溢出
301
+ container.style.left = `${Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - 226))}px`
302
+ container.style.top = `${Math.max(8, rect.top - 44)}px`
303
+ container.style.transform = ''
304
+ container.style.display = 'block'
305
+ const menu = document.createElement('div')
306
+ menu.className = 'dsh-cp-selmenu'
307
+ menu.addEventListener('mousedown', (e) => e.stopPropagation())
308
+ const mkItem = (label, action) => {
309
+ const b = document.createElement('button')
310
+ b.className = 'dsh-cp-selitem'
311
+ b.textContent = label
312
+ b.addEventListener('click', () => { void action(current) })
313
+ return b
314
+ }
315
+ menu.append(
316
+ mkItem('复制', copySel),
317
+ mkItem('添加到对话', addToDraft),
318
+ mkItem('存为模板', save))
319
+ container.replaceChildren(menu)
320
+ } catch { /* ignore */ }
321
+ }, 250)
322
+ } catch { /* ignore */ }
323
+ })
324
+ document.addEventListener('mousedown', (e) => {
325
+ if (host?.style.display === 'block' && !e.target.closest?.('[data-dsh-superinput-portal="selection"]')) hide()
326
+ }, true)
327
+ window.addEventListener('scroll', hide, true)
328
+ document.addEventListener('selectionchange', () => {
329
+ if (!String(window.getSelection?.() ?? '').trim()) hide()
330
+ })
331
+ }
332
+
333
+ function PromptsPanel({ anchor, onClose, onSyncSlash }) {
334
+ const [items, setItems] = React.useState(null)
335
+ const [query, setQuery] = React.useState('')
336
+ const [draft, setDraft] = React.useState(null)
337
+ const [fill, setFill] = React.useState(null)
338
+ const [flash, setFlash] = React.useState(null)
339
+ const inputRef = React.useRef(null)
340
+
341
+ const reload = React.useCallback(async () => {
342
+ const data = await api('/list')
343
+ if (data && Array.isArray(data.items)) setItems(data.items)
344
+ onSyncSlash?.() // 模板增删改后重新对齐 / 命令注册
345
+ }, [onSyncSlash])
346
+ React.useEffect(() => { void reload() }, [reload])
347
+
348
+ // 编辑/填变量中不响应 Esc / 点外,避免丢失刚输入的内容。
349
+ React.useEffect(() => {
350
+ const onKey = (event) => { if (event.key === 'Escape' && !draft && !fill) onClose() }
351
+ const onClick = (event) => {
352
+ if (draft || fill) return
353
+ if (!event.target.closest?.('.dsh-cp-panel') && !event.target.closest?.('.dsh-cp-btn')) onClose()
354
+ }
355
+ document.addEventListener('keydown', onKey)
356
+ document.addEventListener('mousedown', onClick, true)
357
+ return () => {
358
+ document.removeEventListener('keydown', onKey)
359
+ document.removeEventListener('mousedown', onClick, true)
360
+ }
361
+ }, [onClose, draft, fill])
362
+
363
+ React.useEffect(() => {
364
+ if (!flash) return undefined
365
+ const timer = setTimeout(() => setFlash(null), 3500)
366
+ return () => clearTimeout(timer)
367
+ }, [flash])
368
+
369
+ const [pos, setPos] = React.useState(null)
370
+ React.useEffect(() => {
371
+ if (!anchor) return
372
+ const rect = anchor.getBoundingClientRect()
373
+ const width = Math.min(380, window.innerWidth - 24)
374
+ setPos({
375
+ left: Math.max(8, Math.min(rect.left, window.innerWidth - width - 8)),
376
+ top: Math.max(8, rect.top - 8),
377
+ transform: 'translateY(-100%)',
378
+ })
379
+ }, [anchor])
380
+
381
+ const insert = (item, values) => {
382
+ try {
383
+ void api('/use', { id: item.id }) // 使用计数:列表按最近使用排序
384
+ let out = String(item.body ?? '')
385
+ for (const [name, value] of Object.entries(values ?? {})) {
386
+ out = out.replace(new RegExp(`\\{\\{\\s*${name}\\s*\\}\\}`, 'g'), value ?? '')
387
+ }
388
+ if (!window.__dshSuperinputSetDraft) {
389
+ setFlash('草稿桥未就绪,请关掉面板重开再试')
390
+ report('insert skipped: draft bridge missing')
391
+ return
392
+ }
393
+ const current = String(window.__dshSuperinputDraft?.() ?? '')
394
+ window.__dshSuperinputSetDraft(current ? `${current}\n${out}` : out)
395
+ onClose()
396
+ } catch (error) {
397
+ report(`insert failed: ${error?.stack ?? error}`)
398
+ setFlash(`插入失败:${String(error?.message ?? error)}`)
399
+ }
400
+ }
401
+
402
+ const remove = async (id) => { await api('/remove', { id }); void reload() }
403
+ const pin = async (item) => { await api('/pin', { id: item.id, pinned: !item.pinned }); void reload() }
404
+ const toggleSlash = async (item) => { await api('/slash', { id: item.id, slash: !item.slash }); void reload() }
405
+
406
+ /** 导出:拉全量 JSON,走浏览器下载落到系统下载目录。 */
407
+ const doExport = async () => {
408
+ try {
409
+ const res = await fetch(`${ENDPOINT}/export`, { cache: 'no-store' })
410
+ if (!res.ok) throw new Error(`export ${res.status}`)
411
+ const blob = new Blob([await res.text()], { type: 'application/json' })
412
+ const url = URL.createObjectURL(blob)
413
+ const name = `dsh-prompts-${new Date().toISOString().slice(0, 10)}.json`
414
+ const a = document.createElement('a')
415
+ a.href = url
416
+ a.download = name
417
+ a.click()
418
+ setTimeout(() => URL.revokeObjectURL(url), 5000)
419
+ setFlash(`已导出到下载目录:${name}`)
420
+ } catch (error) {
421
+ report(`export failed: ${error?.stack ?? error}`)
422
+ setFlash(`导出失败:${String(error?.message ?? error)}`)
423
+ }
424
+ }
425
+
426
+ /** 导入:临时 file input 走系统文件选择框,JSON 解析后交给 /import 按 id 合并。 */
427
+ const doImport = () => {
428
+ const input = document.createElement('input')
429
+ input.type = 'file'
430
+ input.accept = '.json,application/json'
431
+ input.onchange = async () => {
432
+ const file = input.files?.[0]
433
+ if (!file) return
434
+ try {
435
+ const payload = JSON.parse(await file.text())
436
+ const res = await api('/import', payload)
437
+ if (!res?.ok) throw new Error(res?.error ?? 'import rejected')
438
+ setFlash(`导入完成:新增 ${res.added ?? 0},更新 ${res.updated ?? 0}`)
439
+ void reload()
440
+ } catch (error) {
441
+ report(`import failed: ${error?.stack ?? error}`)
442
+ setFlash(`导入失败:${String(error?.message ?? error)}`)
443
+ }
444
+ }
445
+ input.click()
446
+ }
447
+
448
+ const save = async () => {
449
+ if (!draft || !String(draft.body ?? '').trim()) return
450
+ await api('/save', {
451
+ id: draft.id,
452
+ title: draft.title,
453
+ body: draft.body,
454
+ tags: String(draft.tagsText ?? '').split(/[,,]/).map((tag) => tag.trim()).filter(Boolean),
455
+ })
456
+ setDraft(null)
457
+ void reload()
458
+ }
459
+
460
+ function row(item) {
461
+ const vars = varsOf(item.body)
462
+ return h('div', { className: 'dsh-cp-row', key: item.id },
463
+ h('div', {
464
+ className: 'dsh-cp-row-main',
465
+ onClick: () => {
466
+ const vars = varsOf(item.body)
467
+ if (vars.length === 0) { insert(item); return }
468
+ // 内置动态变量(日期/时间/星期)自动取值;全部能自动取就一键直插。
469
+ const values = {}
470
+ let pending = false
471
+ for (const name of vars) {
472
+ const auto = autoValue(name)
473
+ if (auto !== null) values[name] = auto
474
+ else pending = true
475
+ }
476
+ if (!pending) { insert(item, values); return }
477
+ setFill({ item, names: vars, values })
478
+ // {{剪贴板}} 异步预填:读不到就留空手填。
479
+ if (vars.includes('剪贴板')) {
480
+ void navigator.clipboard?.readText?.()
481
+ .then((text) => {
482
+ if (typeof text === 'string' && text) {
483
+ setFill((f) => (f && f.names.includes('剪贴板') && f.values['剪贴板'] === undefined
484
+ ? { ...f, values: { ...f.values, 剪贴板: text } }
485
+ : f))
486
+ }
487
+ })
488
+ .catch(() => {})
489
+ }
490
+ },
491
+ },
492
+ h('div', { className: 'dsh-cp-title' },
493
+ item.title,
494
+ item.uses > 0 ? h('span', { className: 'dsh-cp-uses' }, `${item.uses}次`) : null),
495
+ h('div', { className: 'dsh-cp-preview' }, String(item.body).split('\n')[0].slice(0, 100)),
496
+ vars.length > 0
497
+ ? h('div', { className: 'dsh-cp-vars' }, vars.map((name) => h('span', { className: 'dsh-cp-var', key: name }, `{{${name}}}`)))
498
+ : null),
499
+ h('div', { className: 'dsh-cp-row-actions' },
500
+ h('button', { className: 'dsh-cp-mini', title: item.pinned ? '取消收藏' : '收藏', onClick: () => { void pin(item) } }, item.pinned ? '★' : '☆'),
501
+ h('button', {
502
+ className: `dsh-cp-mini${item.slash ? ' active' : ''}`,
503
+ title: item.slash ? '取消 / 命令注册' : '注册为 / 命令(输入 / 按标题搜到)',
504
+ onClick: () => { void toggleSlash(item) },
505
+ }, '/'),
506
+ h('button', {
507
+ className: 'dsh-cp-mini',
508
+ title: '编辑',
509
+ onClick: () => setDraft({ id: item.id, title: item.title, body: item.body, tagsText: (item.tags ?? []).join(', ') }),
510
+ }, '✎'),
511
+ h('button', { className: 'dsh-cp-mini danger', title: '删除', onClick: () => { void remove(item.id) } }, '×')))
512
+ }
513
+
514
+ const q = String(query).trim().toLowerCase()
515
+ const all = items ?? []
516
+ const match = (item) => !q || String(item.title).toLowerCase().includes(q) || String(item.body).toLowerCase().includes(q)
517
+ // 组内按最近使用排序:没插过的退回创建时间,新模板仍在顶部
518
+ const byLastUsed = (a, b) => (b.usedAt ?? b.at ?? 0) - (a.usedAt ?? a.at ?? 0)
519
+ const pinned = all.filter((item) => item.pinned && match(item)).sort(byLastUsed)
520
+ const recent = all.filter((item) => !item.pinned && match(item)).sort(byLastUsed)
521
+
522
+ const editor = draft
523
+ ? h('div', { className: 'dsh-cp-editor' },
524
+ h('input', {
525
+ className: 'dsh-cp-input',
526
+ placeholder: '标题(留空取正文前 40 字)',
527
+ value: draft.title,
528
+ onInput: (event) => setDraft({ ...draft, title: event.target.value }),
529
+ }),
530
+ h('textarea', {
531
+ className: 'dsh-cp-textarea',
532
+ placeholder: '模板正文,可用 {{变量}} 占位符',
533
+ value: draft.body,
534
+ rows: 8,
535
+ onInput: (event) => setDraft({ ...draft, body: event.target.value }),
536
+ }),
537
+ h('input', {
538
+ className: 'dsh-cp-input',
539
+ placeholder: '标签,逗号分隔',
540
+ value: draft.tagsText ?? '',
541
+ onInput: (event) => setDraft({ ...draft, tagsText: event.target.value }),
542
+ }),
543
+ h('div', { className: 'dsh-cp-actions' },
544
+ h('button', { className: 'dsh-cp-action', onClick: () => setDraft(null) }, '取消'),
545
+ h('button', { className: 'dsh-cp-action primary', onClick: () => { void save() } }, '保存')))
546
+ : null
547
+
548
+ const fillForm = fill
549
+ ? h('div', { className: 'dsh-cp-editor' },
550
+ h('div', { className: 'dsh-cp-group' }, `填写「${fill.item.title || '模板'}」的变量`),
551
+ ...fill.names.map((name) => h('input', {
552
+ key: name,
553
+ className: 'dsh-cp-input',
554
+ placeholder: `{{${name}}}`,
555
+ value: fill.values[name] ?? '',
556
+ onInput: (event) => setFill({ ...fill, values: { ...fill.values, [name]: event.target.value } }),
557
+ })),
558
+ h('div', { className: 'dsh-cp-actions' },
559
+ h('button', { className: 'dsh-cp-action', onClick: () => setFill(null) }, '取消'),
560
+ h('button', {
561
+ className: 'dsh-cp-action primary',
562
+ onClick: () => { const f = fill; setFill(null); insert(f.item, f.values) },
563
+ }, '插入')))
564
+ : null
565
+
566
+ // portal 容器:自建 div 挂 body——不再直接用 document.body(rc.5 环境里
567
+ // 出现过 React #200 "Target container is not a DOM element",自建元素
568
+ // 结构上必然是 Element,绕开该判定的任何环境怪癖)。
569
+ const portalHost = React.useRef(null)
570
+ if (!portalHost.current && typeof document !== 'undefined' && document.body) {
571
+ try {
572
+ portalHost.current = document.createElement('div')
573
+ portalHost.current.setAttribute('data-dsh-superinput-portal', 'prompts')
574
+ document.body.appendChild(portalHost.current)
575
+ } catch (error) {
576
+ report(`portal host create failed: ${error?.stack ?? error}`)
577
+ portalHost.current = null
578
+ }
579
+ }
580
+ React.useEffect(() => () => { portalHost.current?.remove() }, [])
581
+
582
+ return portalHost.current
583
+ ? createPortal(h('div', { className: 'dsh-cp-panel', style: pos ?? { display: 'none' } },
584
+ h('div', { className: 'dsh-cp-head' },
585
+ h('input', {
586
+ ref: inputRef,
587
+ className: 'dsh-cp-search',
588
+ placeholder: '搜索模板…',
589
+ value: query,
590
+ onInput: (event) => setQuery(event.target.value),
591
+ }),
592
+ h('button', { className: 'dsh-cp-new', title: '导出全部模板(JSON)', onClick: () => { void doExport() } }, '↓'),
593
+ h('button', { className: 'dsh-cp-new', title: '从 JSON 备份导入', onClick: () => doImport() }, '↑'),
594
+ h('button', {
595
+ className: 'dsh-cp-new',
596
+ title: '新建模板',
597
+ onClick: () => setDraft({ title: '', body: '', tagsText: '' }),
598
+ }, '+')),
599
+ editor,
600
+ fillForm,
601
+ (draft || fill) ? null : h('div', { className: 'dsh-cp-list' },
602
+ !items
603
+ ? h('div', { className: 'dsh-cp-empty' }, '加载中…')
604
+ : (pinned.length === 0 && recent.length === 0)
605
+ ? h('div', { className: 'dsh-cp-empty' }, q ? '没有匹配的模板' : '点右上角 + 新建第一个模板')
606
+ : [
607
+ pinned.length > 0 ? h('div', { className: 'dsh-cp-group', key: 'pg' }, '已收藏') : null,
608
+ ...pinned.map(row),
609
+ pinned.length > 0 && recent.length > 0 ? h('div', { className: 'dsh-cp-group', key: 'rg' }, '全部') : null,
610
+ ...recent.map(row),
611
+ ]),
612
+ flash ? h('div', { className: 'dsh-cp-empty' }, flash) : null), portalHost.current)
613
+ : null
614
+ }
615
+
616
+ /** 错误边界:面板渲染崩溃时留痕(走 prompts /diag)而不是无声消失。 */
617
+ class PromptsBoundary extends React.Component {
618
+ constructor(props) { super(props); this.state = { crashed: false } }
619
+ static getDerivedStateFromError() { return { crashed: true } }
620
+ componentDidCatch(error, info) {
621
+ report(`boundary: ${String(error?.message ?? error).slice(0, 200)} | stack: ${String(error?.stack ?? '').slice(0, 400)}`)
622
+ }
623
+ render() { return this.state.crashed ? null : this.props.children }
624
+ }
625
+
626
+ function PromptsButton({ onSyncSlash }) {
627
+ const [open, setOpen] = React.useState(false)
628
+ const ref = React.useRef(null)
629
+ return h('span', { ref, style: { display: 'inline-flex' } },
630
+ h('button', {
631
+ className: 'dsh-cp-btn',
632
+ title: 'Prompt(提示词模板)',
633
+ onClick: () => setOpen((value) => !value),
634
+ }, 'Prompt'),
635
+ open ? h(PromptsPanel, { anchor: ref.current, onClose: () => setOpen(false), onSyncSlash }) : null)
636
+ }
637
+
638
+ return {
639
+ inject: ['slots'],
640
+ apply(ctx) {
641
+ ensureStyle()
642
+ installSelectionSaver()
643
+
644
+ /* / 斜杠命令同步:标记了 slash 的模板注册进官方 / 菜单(action 命令)。
645
+ * commandUi 是官方 ui-commands 的客户端服务;旧宿主没有它时 inject 不解析,整块跳过。
646
+ * 按模板 opt-in 而不是全量注册,避免几十条模板淹没 / 菜单;同名冲突 register 会
647
+ * 抛错(官方约定 fail loud),这里 try/catch 吞掉并留痕,不影响其它模板。 */
648
+ const slashDisposers = new Map() // id -> { dispose, fp }
649
+ let commandUi = null
650
+ const slashName = (item) => {
651
+ const slug = String(item.title ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24)
652
+ return `tpl-${slug || String(item.id ?? '').slice(0, 8)}`
653
+ }
654
+ const slashFp = (item) => `${slashName(item)}|${item.title}`
655
+ const syncSlash = async () => {
656
+ if (!commandUi) return
657
+ let items = []
658
+ try {
659
+ const data = await api('/list')
660
+ items = Array.isArray(data?.items) ? data.items : []
661
+ } catch { return }
662
+ const wanted = new Map(items.filter((item) => item.slash === true).map((item) => [item.id, item]))
663
+ for (const [id, entry] of slashDisposers) {
664
+ const fresh = wanted.get(id)
665
+ if (!fresh || entry.fp !== slashFp(fresh)) {
666
+ entry.dispose?.()
667
+ slashDisposers.delete(id)
668
+ }
669
+ }
670
+ for (const [id, item] of wanted) {
671
+ if (slashDisposers.has(id)) continue
672
+ try {
673
+ const dispose = commandUi.register({
674
+ name: slashName(item),
675
+ label: () => item.title,
676
+ description: () => '插入提示词模板',
677
+ available: () => true,
678
+ ui: { kind: 'action', run: () => insertViaBridge(item) },
679
+ })
680
+ slashDisposers.set(id, { dispose: typeof dispose === 'function' ? dispose : null, fp: slashFp(item) })
681
+ } catch (error) {
682
+ report(`slash register "${item.title}": ${error?.message ?? error}`)
683
+ }
684
+ }
685
+ }
686
+ ctx.inject(['commandUi'], (scope) => {
687
+ commandUi = scope.get('commandUi')
688
+ void syncSlash()
689
+ })
690
+
691
+ ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
692
+ name: 'conversation.composer.dock',
693
+ id: 'dsh-superinput-prompts-btn',
694
+ order: 4,
695
+ }, (props) => h(PromptsBoundary, null, h(PromptsButton, { ...props, onSyncSlash: syncSlash }))))
696
+ },
697
+ }
698
+ }