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.
- package/CHANGELOG.md +140 -0
- package/README.md +38 -2
- package/client.js +5292 -0
- package/cordis.patch.yml +6 -0
- package/index.js +34 -0
- package/modules/cost.js +1346 -0
- package/modules/enhancer.js +726 -0
- package/modules/followup.js +718 -0
- package/modules/hub.client.js +218 -0
- package/modules/hub.js +78 -0
- package/modules/picker.js +133 -0
- package/modules/prompts.client.js +698 -0
- package/modules/prompts.js +305 -0
- package/modules/voice.js +17 -0
- package/package.json +61 -4
- package/scripts/build-suite.mjs +315 -0
- package/scripts/smoke.mjs +121 -0
- package/server.js +3428 -0
|
@@ -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
|
+
}
|