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/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
+ }