dsh-prompt-refine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.iife.js +518 -0
- package/lib/index.mjs +272 -0
- package/package.json +57 -0
- package/scripts/install.mjs +97 -0
- package/src/client.ts +387 -0
- package/src/dsh-prompt-refine.ts +24 -0
- package/src/host.ts +239 -0
- package/src/shared/prompt.ts +73 -0
- package/src/shared/types.ts +38 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// client.ts —— 浏览器侧 client 半入口
|
|
2
|
+
//
|
|
3
|
+
// 只有一个注册点:`conversation.input.right`(list slot,发送按钮左侧)。
|
|
4
|
+
// 该组件自己负责:✨ 按钮 + 弹窗(用 React Portal 挂到 document.body)。
|
|
5
|
+
//
|
|
6
|
+
// ⚠️ 关键教训:不要注册 `conversation` 这类 single slot —— 会被
|
|
7
|
+
// ui-conversation 占用,重复注册会让整个 client 插件加载失败
|
|
8
|
+
// ("single slot already has a registration")。
|
|
9
|
+
//
|
|
10
|
+
// 交互:
|
|
11
|
+
// 点 ✨ → 读草稿 + 最近历史 → POST /dsh-prompt-refine/suggest
|
|
12
|
+
// → 弹窗列 3 条建议(可勾选)→ 采纳 → inputActions.setDraft(新草稿)
|
|
13
|
+
// → 关弹窗,用户自己按发送键(不抢发送权)
|
|
14
|
+
|
|
15
|
+
declare global {
|
|
16
|
+
interface Window {
|
|
17
|
+
__ModuleLoader__: {
|
|
18
|
+
load(info: {
|
|
19
|
+
id: string
|
|
20
|
+
factory: (
|
|
21
|
+
require: (mod: string) => unknown,
|
|
22
|
+
) => {
|
|
23
|
+
name: string
|
|
24
|
+
inject: string[]
|
|
25
|
+
apply: (ctx: unknown) => void
|
|
26
|
+
}
|
|
27
|
+
}): void
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeFactory(): (require: (mod: string) => unknown) => unknown {
|
|
33
|
+
return (require) => {
|
|
34
|
+
const React = require('react') as {
|
|
35
|
+
createElement: (type: unknown, props?: Record<string, unknown> | null, ...children: unknown[]) => unknown
|
|
36
|
+
useState: <T>(init: T | (() => T)) => [T, (v: T | ((p: T) => T)) => void]
|
|
37
|
+
useEffect: (fn: () => void | (() => void), deps?: unknown[]) => void
|
|
38
|
+
Fragment: unknown
|
|
39
|
+
}
|
|
40
|
+
const h = React.createElement
|
|
41
|
+
// ⚠️ createPortal 在 react-dom(不是 react-dom/client —— 那里只有 createRoot)
|
|
42
|
+
const { createPortal } = require('react-dom') as {
|
|
43
|
+
createPortal: (node: unknown, container: Element) => unknown
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const name = 'dsh-prompt-refine'
|
|
47
|
+
const inject: string[] = ['slots']
|
|
48
|
+
|
|
49
|
+
// ===== Portal 宿主(模块级,只创建一次)=====
|
|
50
|
+
let portalHost: HTMLElement | null = null
|
|
51
|
+
function getPortalHost(): HTMLElement {
|
|
52
|
+
if (portalHost === null || !portalHost.isConnected) {
|
|
53
|
+
portalHost = document.createElement('div')
|
|
54
|
+
portalHost.id = 'dsh-prompt-refine-portal'
|
|
55
|
+
document.body.appendChild(portalHost)
|
|
56
|
+
}
|
|
57
|
+
return portalHost
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ===== 样式(全部用 DSH 语义 token,暗色自动适配)=====
|
|
61
|
+
const S = {
|
|
62
|
+
btn: {
|
|
63
|
+
padding: 0, width: '32px', height: '32px', borderRadius: '8px',
|
|
64
|
+
border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent',
|
|
65
|
+
color: 'var(--dsw-alias-label-secondary)', cursor: 'pointer',
|
|
66
|
+
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
67
|
+
fontSize: '15px', lineHeight: 1,
|
|
68
|
+
},
|
|
69
|
+
overlay: {
|
|
70
|
+
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)',
|
|
71
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
72
|
+
zIndex: 9999, padding: '20px',
|
|
73
|
+
},
|
|
74
|
+
modal: {
|
|
75
|
+
background: 'var(--dsw-alias-bg-layer-1)', borderRadius: '14px',
|
|
76
|
+
width: '100%', maxWidth: '560px', maxHeight: '86vh', overflowY: 'auto',
|
|
77
|
+
boxShadow: '0 20px 60px rgba(0,0,0,0.28)',
|
|
78
|
+
border: '1px solid var(--dsw-alias-border-l2)', color: 'var(--dsw-alias-label-primary)',
|
|
79
|
+
},
|
|
80
|
+
header: { padding: '18px 22px 14px', borderBottom: '1px solid var(--dsw-alias-border-l2)' },
|
|
81
|
+
title: { fontSize: '16px', fontWeight: 600, margin: '0 0 4px' },
|
|
82
|
+
subtitle: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)', margin: 0 },
|
|
83
|
+
original: {
|
|
84
|
+
margin: '14px 22px 0', padding: '10px 12px',
|
|
85
|
+
background: 'var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.04))', borderRadius: '8px',
|
|
86
|
+
fontSize: '13px', color: 'var(--dsw-alias-label-secondary)',
|
|
87
|
+
borderLeft: '3px solid var(--dsw-alias-label-tertiary)',
|
|
88
|
+
},
|
|
89
|
+
originalLabel: {
|
|
90
|
+
fontSize: '10px', color: 'var(--dsw-alias-label-tertiary)',
|
|
91
|
+
textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '4px',
|
|
92
|
+
},
|
|
93
|
+
list: { padding: '14px 22px' },
|
|
94
|
+
row: {
|
|
95
|
+
display: 'flex', gap: '12px', padding: '12px',
|
|
96
|
+
border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '10px',
|
|
97
|
+
marginBottom: '8px', cursor: 'pointer',
|
|
98
|
+
},
|
|
99
|
+
rowOn: {
|
|
100
|
+
borderColor: 'var(--dsw-alias-state-business-primary)',
|
|
101
|
+
background: 'var(--dsw-alias-interactive-bg-active, rgba(37,99,235,0.08))',
|
|
102
|
+
},
|
|
103
|
+
rowBody: { flex: 1, minWidth: 0 },
|
|
104
|
+
tag: {
|
|
105
|
+
display: 'inline-block', fontSize: '11px', padding: '2px 7px',
|
|
106
|
+
borderRadius: '4px', fontWeight: 500, marginBottom: '6px',
|
|
107
|
+
},
|
|
108
|
+
tagScope: { background: 'var(--dsw-alias-state-error-secondary)', color: 'var(--dsw-alias-state-error-primary)' },
|
|
109
|
+
tagFormat: { background: 'rgba(217,119,6,0.14)', color: '#d97706' },
|
|
110
|
+
tagConstraint: { background: 'rgba(37,99,235,0.14)', color: 'var(--dsw-alias-state-business-primary)' },
|
|
111
|
+
tagClarity: { background: 'rgba(5,150,105,0.16)', color: 'var(--dsw-alias-state-success-primary)' },
|
|
112
|
+
tagOther: { background: 'rgba(128,128,128,0.16)', color: 'var(--dsw-alias-label-secondary)' },
|
|
113
|
+
issue: { fontSize: '13.5px', margin: '0 0 8px', lineHeight: 1.45 },
|
|
114
|
+
patch: {
|
|
115
|
+
fontSize: '12.5px', color: 'var(--dsw-alias-label-secondary)',
|
|
116
|
+
background: 'rgba(128,128,128,0.08)', padding: '7px 10px', borderRadius: '6px', margin: 0,
|
|
117
|
+
fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace',
|
|
118
|
+
border: '1px dashed var(--dsw-alias-border-l2)',
|
|
119
|
+
},
|
|
120
|
+
preview: {
|
|
121
|
+
margin: '0 22px 14px', padding: '10px 12px',
|
|
122
|
+
background: 'rgba(128,128,128,0.08)', borderRadius: '8px', fontSize: '13px',
|
|
123
|
+
},
|
|
124
|
+
previewLabel: {
|
|
125
|
+
fontSize: '10px', color: 'var(--dsw-alias-label-tertiary)',
|
|
126
|
+
textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '6px',
|
|
127
|
+
display: 'flex', justifyContent: 'space-between',
|
|
128
|
+
},
|
|
129
|
+
previewCount: { color: 'var(--dsw-alias-state-business-primary)', fontWeight: 600 },
|
|
130
|
+
previewText: { lineHeight: 1.6 },
|
|
131
|
+
added: {
|
|
132
|
+
background: 'rgba(5,150,105,0.16)', color: 'var(--dsw-alias-state-success-primary)',
|
|
133
|
+
padding: '1px 5px', borderRadius: '3px', margin: '0 2px', fontWeight: 500,
|
|
134
|
+
},
|
|
135
|
+
footer: {
|
|
136
|
+
padding: '14px 22px', borderTop: '1px solid var(--dsw-alias-border-l2)',
|
|
137
|
+
display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px',
|
|
138
|
+
},
|
|
139
|
+
hint: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' },
|
|
140
|
+
btnSec: {
|
|
141
|
+
padding: '8px 14px', borderRadius: '8px',
|
|
142
|
+
border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent',
|
|
143
|
+
color: 'var(--dsw-alias-label-primary)', cursor: 'pointer', fontSize: '14px',
|
|
144
|
+
},
|
|
145
|
+
btnPri: {
|
|
146
|
+
padding: '8px 14px', borderRadius: '8px', border: 'none',
|
|
147
|
+
background: 'var(--dsw-alias-state-business-primary)',
|
|
148
|
+
color: 'var(--dsw-alias-button-info-fill, #fff)', cursor: 'pointer',
|
|
149
|
+
fontSize: '14px', fontWeight: 600,
|
|
150
|
+
},
|
|
151
|
+
skel: {
|
|
152
|
+
height: '58px', background: 'rgba(128,128,128,0.14)',
|
|
153
|
+
borderRadius: '10px', marginBottom: '8px',
|
|
154
|
+
},
|
|
155
|
+
tip: { textAlign: 'center', fontSize: '13px', color: 'var(--dsw-alias-label-secondary)', padding: '8px 0' },
|
|
156
|
+
err: { padding: '16px 22px', fontSize: '13px', color: 'var(--dsw-alias-state-error-primary)' },
|
|
157
|
+
warn: { padding: '0 22px 10px', fontSize: '12px', color: 'var(--dsw-alias-state-warn-primary, #d97706)' },
|
|
158
|
+
} as const
|
|
159
|
+
|
|
160
|
+
function tagStyle(tag: string): Record<string, string> {
|
|
161
|
+
switch (tag) {
|
|
162
|
+
case 'scope': return S.tagScope as unknown as Record<string, string>
|
|
163
|
+
case 'format': return S.tagFormat as unknown as Record<string, string>
|
|
164
|
+
case 'constraint': return S.tagConstraint as unknown as Record<string, string>
|
|
165
|
+
case 'clarity': return S.tagClarity as unknown as Record<string, string>
|
|
166
|
+
default: return S.tagOther as unknown as Record<string, string>
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface Item { tag: string; issue: string; patch: string }
|
|
171
|
+
|
|
172
|
+
// ===== 主组件:按钮 + 弹窗 =====
|
|
173
|
+
function PromptRefineControl(props: Record<string, unknown>): unknown {
|
|
174
|
+
const useInput = props.useInput as ((sel: (s: { draft: string }) => unknown) => unknown) | undefined
|
|
175
|
+
const useChat = props.useChat as ((sel: (s: { legacy: { nodes: unknown[] } }) => unknown) => unknown) | undefined
|
|
176
|
+
const inputActions = props.inputActions as { setDraft?: (s: string) => void } | undefined
|
|
177
|
+
|
|
178
|
+
// hooks 必须无条件调用(顺序稳定)
|
|
179
|
+
const draftNow = ((useInput ? useInput(s => s.draft) : '') as string) ?? ''
|
|
180
|
+
const nodes = ((useChat ? useChat(s => s.legacy.nodes) : []) as unknown[]) ?? []
|
|
181
|
+
|
|
182
|
+
const [host] = React.useState<HTMLElement>(() => getPortalHost())
|
|
183
|
+
const [open, setOpen] = React.useState(false)
|
|
184
|
+
const [phase, setPhase] = React.useState<'loading' | 'result' | 'error' | 'empty'>('loading')
|
|
185
|
+
const [reqDraft, setReqDraft] = React.useState('')
|
|
186
|
+
const [items, setItems] = React.useState<Item[]>([])
|
|
187
|
+
const [checked, setChecked] = React.useState<boolean[]>([])
|
|
188
|
+
const [errMsg, setErrMsg] = React.useState('')
|
|
189
|
+
const [usedModel, setUsedModel] = React.useState('')
|
|
190
|
+
|
|
191
|
+
// 从最近消息里提取历史(user: content[].type='text';assistant: blocks[].kind='text')
|
|
192
|
+
const collectHistory = (): Array<{ role: 'user' | 'assistant'; content: string }> => {
|
|
193
|
+
const out: Array<{ role: 'user' | 'assistant'; content: string }> = []
|
|
194
|
+
for (let i = nodes.length - 1; i >= 0 && out.length < 6; i--) {
|
|
195
|
+
const n = nodes[i] as {
|
|
196
|
+
kind?: string
|
|
197
|
+
content?: Array<{ type?: string; text?: string }>
|
|
198
|
+
blocks?: Array<{ kind?: string; text?: string }>
|
|
199
|
+
} | undefined
|
|
200
|
+
if (!n) continue
|
|
201
|
+
if (n.kind === 'user') {
|
|
202
|
+
const text = (n.content ?? []).filter(b => b && b.type === 'text').map(b => b.text ?? '').join('').trim()
|
|
203
|
+
if (text) out.unshift({ role: 'user', content: text.slice(0, 500) })
|
|
204
|
+
} else if (n.kind === 'assistant') {
|
|
205
|
+
const text = (n.blocks ?? []).filter(b => b && b.kind === 'text').map(b => b.text ?? '').join('').trim()
|
|
206
|
+
if (text) out.unshift({ role: 'assistant', content: text.slice(0, 500) })
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const request = (): void => {
|
|
213
|
+
const d = draftNow.trim()
|
|
214
|
+
if (!d) {
|
|
215
|
+
setReqDraft(''); setPhase('empty'); setOpen(true)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
setReqDraft(d); setItems([]); setChecked([]); setErrMsg(''); setUsedModel('')
|
|
219
|
+
setPhase('loading'); setOpen(true)
|
|
220
|
+
|
|
221
|
+
const history = collectHistory()
|
|
222
|
+
fetch('/dsh-prompt-refine/suggest', {
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers: { 'Content-Type': 'application/json' },
|
|
225
|
+
body: JSON.stringify({ draft: d, history }),
|
|
226
|
+
})
|
|
227
|
+
.then(r => r.json())
|
|
228
|
+
.then((data: { ok?: boolean; suggestions?: Item[]; error?: string; usedModel?: string }) => {
|
|
229
|
+
const list = Array.isArray(data?.suggestions) ? data.suggestions.slice(0, 3) : []
|
|
230
|
+
if (data?.ok && list.length > 0) {
|
|
231
|
+
setItems(list)
|
|
232
|
+
// 默认勾选前两条(第 3 条留给用户判断)
|
|
233
|
+
setChecked(list.map((_, i) => i < 2))
|
|
234
|
+
setUsedModel(data.usedModel ?? '')
|
|
235
|
+
setErrMsg(data.error ?? '')
|
|
236
|
+
setPhase('result')
|
|
237
|
+
} else {
|
|
238
|
+
setErrMsg(data?.error ?? '未知错误')
|
|
239
|
+
setPhase('error')
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
.catch((e: unknown) => {
|
|
243
|
+
setErrMsg('网络错误: ' + (e instanceof Error ? e.message : String(e)))
|
|
244
|
+
setPhase('error')
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Esc 关闭
|
|
249
|
+
React.useEffect(() => {
|
|
250
|
+
if (!open) return
|
|
251
|
+
const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') setOpen(false) }
|
|
252
|
+
window.addEventListener('keydown', onKey)
|
|
253
|
+
return () => { window.removeEventListener('keydown', onKey) }
|
|
254
|
+
}, [open])
|
|
255
|
+
|
|
256
|
+
const close = (): void => setOpen(false)
|
|
257
|
+
const apply = (): void => {
|
|
258
|
+
const patches: string[] = []
|
|
259
|
+
for (let i = 0; i < items.length; i++) {
|
|
260
|
+
if (checked[i] && items[i]?.patch) patches.push(items[i].patch)
|
|
261
|
+
}
|
|
262
|
+
const next = reqDraft + patches.join('')
|
|
263
|
+
if (inputActions?.setDraft) inputActions.setDraft(next)
|
|
264
|
+
setOpen(false)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const button = h('button', {
|
|
268
|
+
type: 'button',
|
|
269
|
+
title: '优化提示词',
|
|
270
|
+
'aria-label': '优化提示词',
|
|
271
|
+
style: S.btn as unknown as Record<string, string>,
|
|
272
|
+
onClick: (e: { stopPropagation: () => void; preventDefault: () => void }) => {
|
|
273
|
+
e.stopPropagation(); e.preventDefault(); request()
|
|
274
|
+
},
|
|
275
|
+
onMouseDown: (e: { preventDefault: () => void }) => e.preventDefault(),
|
|
276
|
+
}, '✨')
|
|
277
|
+
|
|
278
|
+
if (!open || !host) return button
|
|
279
|
+
|
|
280
|
+
// ---- 弹窗内容 ----
|
|
281
|
+
const selected: string[] = []
|
|
282
|
+
for (let i = 0; i < items.length; i++) if (checked[i] && items[i]?.patch) selected.push(items[i].patch)
|
|
283
|
+
const selectedCount = checked.filter(Boolean).length
|
|
284
|
+
|
|
285
|
+
let body: unknown
|
|
286
|
+
if (phase === 'empty') {
|
|
287
|
+
body = h('div', { style: S.err, children: '请先在输入框写点内容,再点 ✨ 优化。' })
|
|
288
|
+
} else if (phase === 'loading') {
|
|
289
|
+
body = h('div', { style: { padding: '18px 22px' }, children: [
|
|
290
|
+
h('div', { style: S.skel }), h('div', { style: S.skel }), h('div', { style: S.skel }),
|
|
291
|
+
h('div', { style: S.tip, children: '正在结合上下文分析…' }),
|
|
292
|
+
] })
|
|
293
|
+
} else if (phase === 'error') {
|
|
294
|
+
body = h('div', { style: S.err, children: '⚠ ' + (errMsg || '出错了') })
|
|
295
|
+
} else {
|
|
296
|
+
// 建议行(抽成变量,避免深层嵌套写错括号)
|
|
297
|
+
const rows = items.map((s, i) => h('label', {
|
|
298
|
+
key: i,
|
|
299
|
+
style: { ...(S.row as object), ...(checked[i] ? (S.rowOn as object) : {}) } as Record<string, string>,
|
|
300
|
+
children: [
|
|
301
|
+
h('input', {
|
|
302
|
+
type: 'checkbox', checked: !!checked[i],
|
|
303
|
+
onChange: (e: { target: { checked: boolean } }) => {
|
|
304
|
+
setChecked(prev => { const n = [...prev]; n[i] = e.target.checked; return n })
|
|
305
|
+
},
|
|
306
|
+
style: { marginTop: '2px', width: '16px', height: '16px', accentColor: 'var(--dsw-alias-state-business-primary)', cursor: 'pointer' },
|
|
307
|
+
}),
|
|
308
|
+
h('div', { style: S.rowBody, children: [
|
|
309
|
+
h('span', { style: tagStyle(s.tag) as unknown as Record<string, string>, children: s.tag }),
|
|
310
|
+
h('p', { style: S.issue, children: s.issue }),
|
|
311
|
+
s.patch ? h('p', { style: S.patch, children: s.patch }) : null,
|
|
312
|
+
] }),
|
|
313
|
+
],
|
|
314
|
+
}))
|
|
315
|
+
|
|
316
|
+
// 预览行(原草稿 + 绿色高亮的补丁)
|
|
317
|
+
const previewChildren: unknown[] = [h('span', { key: 'draft', children: reqDraft })]
|
|
318
|
+
selected.forEach((p, i) => {
|
|
319
|
+
previewChildren.push(h('span', { key: 'p' + i, style: S.added as unknown as Record<string, string>, children: p }))
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
body = h(React.Fragment, null, [
|
|
323
|
+
h('div', { style: S.original, children: [
|
|
324
|
+
h('div', { style: S.originalLabel, children: '原文' }),
|
|
325
|
+
h('div', { children: reqDraft }),
|
|
326
|
+
] }),
|
|
327
|
+
h('div', { style: S.list, children: rows }),
|
|
328
|
+
h('div', { style: S.preview, children: [
|
|
329
|
+
h('div', { style: S.previewLabel, children: [
|
|
330
|
+
h('span', { children: '采纳后预览' }),
|
|
331
|
+
h('span', { style: S.previewCount, children: `已选 ${selectedCount} / ${items.length}` }),
|
|
332
|
+
] }),
|
|
333
|
+
h('div', { style: S.previewText, children: previewChildren }),
|
|
334
|
+
] }),
|
|
335
|
+
errMsg ? h('div', { style: S.warn, children: '提示:' + errMsg }) : null,
|
|
336
|
+
h('div', { style: S.footer, children: [
|
|
337
|
+
h('span', { style: S.hint, children: (usedModel ? usedModel + ' · ' : '') + 'Esc 关闭' }),
|
|
338
|
+
h('div', { style: { display: 'flex', gap: '8px' }, children: [
|
|
339
|
+
h('button', { type: 'button', style: S.btnSec as unknown as Record<string, string>, onClick: close, children: '跳过' }),
|
|
340
|
+
h('button', { type: 'button', style: S.btnPri as unknown as Record<string, string>, onClick: apply,
|
|
341
|
+
children: selectedCount > 0 ? `采纳 ${selectedCount} 条并填入` : '填入原文' }),
|
|
342
|
+
] }),
|
|
343
|
+
] }),
|
|
344
|
+
])
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const overlay = h('div', {
|
|
348
|
+
style: S.overlay as unknown as Record<string, string>,
|
|
349
|
+
onClick: (e: { target: unknown; currentTarget: unknown }) => { if (e.target === e.currentTarget) close() },
|
|
350
|
+
children: h('div', {
|
|
351
|
+
style: S.modal as unknown as Record<string, string>,
|
|
352
|
+
onClick: (e: { stopPropagation: () => void }) => e.stopPropagation(),
|
|
353
|
+
children: [
|
|
354
|
+
h('div', { style: S.header, children: [
|
|
355
|
+
h('div', { style: S.title, children: '✨ 提示词优化建议' }),
|
|
356
|
+
h('p', { style: S.subtitle, children: phase === 'result' ? `发现 ${items.length} 处可以更精确` : '正在结合上下文生成建议' }),
|
|
357
|
+
] }),
|
|
358
|
+
body,
|
|
359
|
+
],
|
|
360
|
+
}),
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
return h(React.Fragment, null, [button, createPortal(overlay, host)])
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function apply(ctx: unknown): void {
|
|
367
|
+
const c = ctx as {
|
|
368
|
+
slots: {
|
|
369
|
+
inject(slot: string, gen: () => unknown): void
|
|
370
|
+
register(opts: Record<string, unknown>, component: unknown): unknown
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// 唯一注册点:发送按钮左侧的 list slot
|
|
374
|
+
c.slots.inject('conversation.input.right', () => c.slots.register(
|
|
375
|
+
{ name: 'conversation.input.right', id: 'dsh-prompt-refine-button', order: 10 },
|
|
376
|
+
PromptRefineControl,
|
|
377
|
+
))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return { name, inject, apply }
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
window.__ModuleLoader__.load({
|
|
385
|
+
id: 'dsh-prompt-refine',
|
|
386
|
+
factory: makeFactory(),
|
|
387
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// dsh-prompt-refine.ts —— host 半入口
|
|
2
|
+
//
|
|
3
|
+
// 注册 HTTP 端点 → 让 client 半的弹窗能调过来 → 我们用 ctx.llm.stream()
|
|
4
|
+
// 调用当前配置好的模型,返回 3 条优化建议。
|
|
5
|
+
|
|
6
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
7
|
+
import { registerHttpRoutes } from './host'
|
|
8
|
+
|
|
9
|
+
export const name = 'dsh-prompt-refine'
|
|
10
|
+
|
|
11
|
+
// 需要 webServer 注册 HTTP 端点
|
|
12
|
+
// 需要 llm 服务在 handler 被调用时已经存在(用 lazy inject,声明在 apply 里)
|
|
13
|
+
export const inject = ['webServer']
|
|
14
|
+
|
|
15
|
+
export function apply(ctx: Context): void {
|
|
16
|
+
// 注册 HTTP 路由(注册时不需要 llm,handler 里才需要)
|
|
17
|
+
registerHttpRoutes(ctx)
|
|
18
|
+
|
|
19
|
+
// lazy inject llm:确保 llm 服务存在 + 监听它 ready
|
|
20
|
+
// 这样即使 webServer 先就绪、llm 后就绪也能工作
|
|
21
|
+
ctx.inject(['llm'], (llmCtx) => {
|
|
22
|
+
console.log('[dsh-prompt-refine] llm 服务已就绪,可以处理优化请求')
|
|
23
|
+
})
|
|
24
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// host.ts —— 暴露 HTTP 端点,client 调过来我们再调 ctx.llm.stream()
|
|
2
|
+
//
|
|
3
|
+
// 端点:
|
|
4
|
+
// POST /dsh-prompt-refine/suggest
|
|
5
|
+
// body: SuggestRequest (JSON)
|
|
6
|
+
// resp: SuggestResponse (JSON)
|
|
7
|
+
//
|
|
8
|
+
// 关键点:
|
|
9
|
+
// - LLM 调用用官方 ctx.llm.stream();消息必须用 createUserMessage 等
|
|
10
|
+
// 工厂函数构造(Message.content 是 ContentBlock[] 不是 string)
|
|
11
|
+
// - 模型缺省时读用户 settings.yaml 的 agent-default-model(开箱即用)
|
|
12
|
+
// - 失败时降级到兜底建议,前端永远有东西可显示
|
|
13
|
+
|
|
14
|
+
import { readFile } from 'node:fs/promises'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
18
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
19
|
+
import { createUserMessage, createAssistantMessage } from '@deepseek-ai/dsh-llm'
|
|
20
|
+
import type { Message, StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
21
|
+
import type { SuggestRequest, SuggestResponse, Suggestion } from './shared/types'
|
|
22
|
+
import { SYSTEM_PROMPT, buildUserMessage, parseSuggestions } from './shared/prompt'
|
|
23
|
+
|
|
24
|
+
export const ROUTE_PREFIX = '/dsh-prompt-refine'
|
|
25
|
+
|
|
26
|
+
/** ctx.llm 的最小可用子集 */
|
|
27
|
+
interface LlmLike {
|
|
28
|
+
stream(options: {
|
|
29
|
+
provider: string
|
|
30
|
+
model: string
|
|
31
|
+
system?: string
|
|
32
|
+
messages: Message[]
|
|
33
|
+
}): AsyncIterable<StreamChunk>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 读请求体 */
|
|
37
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const chunks: Buffer[] = []
|
|
40
|
+
req.on('data', c => chunks.push(c as Buffer))
|
|
41
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
42
|
+
req.on('error', reject)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 写 JSON 响应 */
|
|
47
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
48
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
|
49
|
+
res.end(JSON.stringify(body))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 拿到 llm 服务(懒注入:即使它晚于 webServer 就绪也能等到) */
|
|
53
|
+
function resolveLlm(ctx: Context): Promise<LlmLike> {
|
|
54
|
+
return new Promise<LlmLike>((resolve, reject) => {
|
|
55
|
+
try {
|
|
56
|
+
ctx.inject(['llm'], (llmCtx) => {
|
|
57
|
+
const llm = (llmCtx as unknown as { llm?: LlmLike }).llm
|
|
58
|
+
if (!llm) {
|
|
59
|
+
reject(new Error('ctx.llm 不存在'))
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
resolve(llm)
|
|
63
|
+
})
|
|
64
|
+
} catch (err) {
|
|
65
|
+
reject(err)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 读用户 settings.yaml 里的 agent-default-model,拿到默认 provider/model。
|
|
72
|
+
* 用逐行扫描而不是 YAML 库:结构固定,避免引入依赖。
|
|
73
|
+
*/
|
|
74
|
+
async function resolveDefaultModel(): Promise<{ provider: string; model: string }> {
|
|
75
|
+
const fallback = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
|
|
76
|
+
try {
|
|
77
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
78
|
+
const text = await readFile(join(home, 'settings.yaml'), 'utf8')
|
|
79
|
+
const lines = text.split(/\r?\n/)
|
|
80
|
+
// 找到 agent-default-model: 之后的缩进块
|
|
81
|
+
let i = lines.findIndex(l => /^agent-default-model\s*:/.test(l))
|
|
82
|
+
if (i < 0) return fallback
|
|
83
|
+
let provider = ''
|
|
84
|
+
let model = ''
|
|
85
|
+
for (i += 1; i < lines.length; i++) {
|
|
86
|
+
const line = lines[i]
|
|
87
|
+
// 遇到下一个顶层 key(非缩进、非空)就结束
|
|
88
|
+
if (/^\S/.test(line)) break
|
|
89
|
+
const pm = line.match(/^\s+provider\s*:\s*(\S+)/)
|
|
90
|
+
const mm = line.match(/^\s+model\s*:\s*(\S+)/)
|
|
91
|
+
if (pm) provider = pm[1]
|
|
92
|
+
if (mm) model = mm[1]
|
|
93
|
+
}
|
|
94
|
+
if (provider && model) return { provider, model }
|
|
95
|
+
return fallback
|
|
96
|
+
} catch {
|
|
97
|
+
return fallback
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 调一次 LLM,拼出完整文本。
|
|
103
|
+
* Message 用官方工厂函数构造。
|
|
104
|
+
*/
|
|
105
|
+
async function callLlm(
|
|
106
|
+
ctx: Context,
|
|
107
|
+
req: { draft: string; history: readonly { role: 'user' | 'assistant'; content: string }[]; provider: string; model: string },
|
|
108
|
+
): Promise<string> {
|
|
109
|
+
const llm = await resolveLlm(ctx)
|
|
110
|
+
|
|
111
|
+
// 历史消息 → 官方 Message 对象
|
|
112
|
+
const historyMessages: Message[] = req.history.map(m => m.role === 'user'
|
|
113
|
+
? createUserMessage({
|
|
114
|
+
content: [{ type: 'text', text: m.content }],
|
|
115
|
+
source: { kind: 'user' },
|
|
116
|
+
})
|
|
117
|
+
: createAssistantMessage({
|
|
118
|
+
content: [{ type: 'text', text: m.content }],
|
|
119
|
+
source: { provider: req.provider, model: req.model },
|
|
120
|
+
}))
|
|
121
|
+
|
|
122
|
+
// 当前草稿作为最后一条 user 消息
|
|
123
|
+
const draftMessage = createUserMessage({
|
|
124
|
+
content: [{ type: 'text', text: buildUserMessage([], req.draft) }],
|
|
125
|
+
source: { kind: 'user' },
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const stream = llm.stream({
|
|
129
|
+
provider: req.provider,
|
|
130
|
+
model: req.model,
|
|
131
|
+
system: SYSTEM_PROMPT,
|
|
132
|
+
messages: [...historyMessages, draftMessage],
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
let out = ''
|
|
136
|
+
let lastError = ''
|
|
137
|
+
for await (const chunk of stream) {
|
|
138
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
139
|
+
out += chunk.text
|
|
140
|
+
} else if (chunk.type === 'finish') {
|
|
141
|
+
const reason = chunk.reason as { kind?: string; failure?: { message?: string } } | undefined
|
|
142
|
+
if (reason?.kind === 'error') lastError = reason.failure?.message ?? '未知模型错误'
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!out && lastError) throw new Error(`模型调用失败: ${lastError}`)
|
|
146
|
+
return out
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 兜底建议 —— LLM 挂掉或解析失败时给用户一个能用的结果 */
|
|
150
|
+
function fallbackSuggestions(): Suggestion[] {
|
|
151
|
+
return [
|
|
152
|
+
{ tag: 'clarity', issue: '请补充更多背景信息(语言、目标、约束)', patch: '请补充:编程语言 / 目标读者 / 期望长度' },
|
|
153
|
+
{ tag: 'format', issue: '请指定输出格式(代码 / 表格 / 列表 / 段落)', patch: '请用 Markdown 格式返回' },
|
|
154
|
+
{ tag: 'constraint', issue: '请说明边界条件或偏好', patch: '请不要使用外部依赖' },
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 注册 HTTP 路由 */
|
|
159
|
+
export function registerHttpRoutes(ctx: Context): void {
|
|
160
|
+
const webServer = (ctx as unknown as {
|
|
161
|
+
webServer?: {
|
|
162
|
+
register(route: {
|
|
163
|
+
kind: string
|
|
164
|
+
path: string
|
|
165
|
+
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>
|
|
166
|
+
}): () => void
|
|
167
|
+
}
|
|
168
|
+
}).webServer
|
|
169
|
+
if (!webServer) {
|
|
170
|
+
console.log('[dsh-prompt-refine] webServer 服务不可用,HTTP 端点未注册')
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
webServer.register({
|
|
175
|
+
kind: 'prefix',
|
|
176
|
+
path: ROUTE_PREFIX,
|
|
177
|
+
handler: async (req, res) => {
|
|
178
|
+
const url = (req.url ?? '/').split('?')[0]
|
|
179
|
+
const method = req.method ?? 'GET'
|
|
180
|
+
const suggestPath = ROUTE_PREFIX + '/suggest'
|
|
181
|
+
|
|
182
|
+
if (url !== suggestPath || method !== 'POST') {
|
|
183
|
+
sendJson(res, 404, { ok: false, error: `not found: ${method} ${url}` })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const body = await readBody(req)
|
|
189
|
+
const parsed = JSON.parse(body) as Partial<SuggestRequest>
|
|
190
|
+
const draft = typeof parsed.draft === 'string' ? parsed.draft.trim() : ''
|
|
191
|
+
const history = Array.isArray(parsed.history)
|
|
192
|
+
? parsed.history.filter((m): m is { role: 'user' | 'assistant'; content: string } =>
|
|
193
|
+
!!m && typeof (m as { content?: unknown }).content === 'string'
|
|
194
|
+
&& ((m as { role?: unknown }).role === 'user' || (m as { role?: unknown }).role === 'assistant'),
|
|
195
|
+
)
|
|
196
|
+
: []
|
|
197
|
+
|
|
198
|
+
if (!draft) {
|
|
199
|
+
sendJson(res, 400, { ok: false, error: 'draft 不能为空' } satisfies SuggestResponse)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 模型:显式传入优先,否则读用户配置
|
|
204
|
+
const explicit = typeof parsed.provider === 'string' && typeof parsed.model === 'string'
|
|
205
|
+
&& parsed.provider && parsed.model
|
|
206
|
+
const resolved = explicit
|
|
207
|
+
? { provider: parsed.provider as string, model: parsed.model as string }
|
|
208
|
+
: await resolveDefaultModel()
|
|
209
|
+
|
|
210
|
+
console.log(`[dsh-prompt-refine] 建议请求: draft=${draft.length}字, history=${history.length}条, model=${resolved.provider}/${resolved.model}`)
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const rawOutput = await callLlm(ctx, { draft, history, ...resolved })
|
|
214
|
+
const suggestions = parseSuggestions(rawOutput)
|
|
215
|
+
sendJson(res, 200, {
|
|
216
|
+
ok: true,
|
|
217
|
+
suggestions,
|
|
218
|
+
usedModel: `${resolved.provider}/${resolved.model}`,
|
|
219
|
+
} satisfies SuggestResponse)
|
|
220
|
+
} catch (err) {
|
|
221
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
222
|
+
console.log(`[dsh-prompt-refine] LLM 调用或解析失败: ${msg}`)
|
|
223
|
+
sendJson(res, 200, {
|
|
224
|
+
ok: true,
|
|
225
|
+
suggestions: fallbackSuggestions(),
|
|
226
|
+
error: '模型调用失败,已用兜底建议: ' + msg,
|
|
227
|
+
usedModel: `${resolved.provider}/${resolved.model}`,
|
|
228
|
+
} satisfies SuggestResponse)
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
sendJson(res, 500, {
|
|
232
|
+
ok: false,
|
|
233
|
+
error: '服务器错误: ' + (err instanceof Error ? err.message : String(err)),
|
|
234
|
+
} satisfies SuggestResponse)
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
console.log('[dsh-prompt-refine] HTTP 端点已注册: POST ' + ROUTE_PREFIX + '/suggest')
|
|
239
|
+
}
|