dsh-superinput 0.0.0-stage → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +405 -0
- package/README.md +49 -2
- package/client.js +7397 -0
- package/cordis.patch.yml +6 -0
- package/index.js +34 -0
- package/modules/compaction.js +413 -0
- package/modules/cost.js +1914 -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 +1709 -0
- package/modules/prompts.js +370 -0
- package/modules/voice.js +17 -0
- package/package.json +60 -4
- package/scripts/build-suite.mjs +325 -0
- package/scripts/smoke.mjs +172 -0
- package/server.js +4482 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
/* 由 scripts/build-suite.mjs 从 dsh-prompt-enhancer/index.js 机械生成;勿手改,重跑脚本再生成。 */
|
|
2
|
+
/**
|
|
3
|
+
* dsh-prompt-enhancer — 对话框提示词自动增强插件(DeepSeek Harness / dsh)
|
|
4
|
+
*
|
|
5
|
+
* 工作原理:
|
|
6
|
+
* 挂载 `agent/pre-step` 瀑布流。每一步开始前,dsh 会把「即将进入本轮的
|
|
7
|
+
* 用户消息批次」交给所有 pre-step 监听器裁决(PreStepDecision)。本插件
|
|
8
|
+
* 在拿到默认裁决(kind === 'enter')后,把批次里的纯文本用户消息先发给
|
|
9
|
+
* 一次轻量模型调用,重写成清晰、具体、自包含的提示词,再放行进入会话——
|
|
10
|
+
* 即 WorkBuddy / ChatGPT 风格的「发送前提示词增强」。
|
|
11
|
+
*
|
|
12
|
+
* 安全设计(fail-open):
|
|
13
|
+
* 增强调用失败、超时、找不到模型路由时,一律原样放行原始消息,
|
|
14
|
+
* 绝不阻塞用户的正常对话。
|
|
15
|
+
*
|
|
16
|
+
* 已核对的 dsh API(deepseek-ai/deepseek-harness,developer preview):
|
|
17
|
+
* - PreStepDecision = { kind: 'reject' } | { kind: 'enter', messages: UserMessage[], startsRequestSeries?: true }
|
|
18
|
+
* - ctx.on('agent/pre-step', async ({ agent }, next) => PreStepDecision)
|
|
19
|
+
* - ctx.llm.stream({ provider, model, messages }) → AsyncIterable<StreamChunk>
|
|
20
|
+
* (文本块为 { type: 'text-delta', text };终帧为 { type: 'finish', reason })
|
|
21
|
+
* - createUserMessage({ content, source }) 来自 '@deepseek-ai/dsh-llm'
|
|
22
|
+
* - defineTool(...) 来自 '@deepseek-ai/dsh-tools';服务名 'llm' / 'tools'
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
26
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
27
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
28
|
+
import { appendFile, mkdir } from 'node:fs/promises'
|
|
29
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
30
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
31
|
+
import { homedir } from 'node:os'
|
|
32
|
+
import { dirname, join } from 'node:path'
|
|
33
|
+
|
|
34
|
+
/** 排查日志:客户端把拦截决策写到这里,出问题时直接看文件。 */
|
|
35
|
+
const DEBUG_LOG = join(homedir(), '.dsh', 'prompt-enhancer-debug.log')
|
|
36
|
+
|
|
37
|
+
const name = 'prompt-enhancer'
|
|
38
|
+
|
|
39
|
+
export const inject = ['llm', 'tools']
|
|
40
|
+
|
|
41
|
+
export const Config = Schema.object({
|
|
42
|
+
/** 总开关:关闭后插件完全不参与 pre-step,也不注册工具行为变化。 */
|
|
43
|
+
enabled: Schema.boolean().default(true).description('是否启用自动提示词增强'),
|
|
44
|
+
/** auto:每条人类消息发送前自动增强;manual:只注册 enhance_prompt 工具,不自动改写。 */
|
|
45
|
+
mode: Schema.union(['auto', 'manual']).default('auto').description('auto=自动增强, manual=仅工具'),
|
|
46
|
+
/** 默认增强强度;前端选择器会覆盖它。off 表示默认不增强。 */
|
|
47
|
+
level: Schema.union(['off', 'low', 'high', 'max']).default('high').description('默认增强强度:off/low/high/max'),
|
|
48
|
+
/**
|
|
49
|
+
* pre-step 兜底增强。默认关闭:前端拦截路径已经负责增强,服务端再动一次
|
|
50
|
+
* 会让「自动增强关闭」失效(用户看不到,消息却被改了)。
|
|
51
|
+
* 只有当前端拦截失效、又确实想要兜底时才手动打开。
|
|
52
|
+
*/
|
|
53
|
+
preStepAuto: Schema.boolean().default(false).description('是否在 pre-step 兜底自动增强(默认关闭,前端已负责增强)'),
|
|
54
|
+
/** 留空则跟随当前会话 Agent 的 provider 路由。 */
|
|
55
|
+
provider: Schema.string().description('增强调用使用的 provider(留空跟随会话)'),
|
|
56
|
+
/** 留空则跟随当前会话 Agent 的 model。建议配一个快模型降低延迟。 */
|
|
57
|
+
model: Schema.string().description('增强调用使用的模型(留空跟随会话,建议快模型)'),
|
|
58
|
+
/** 单次增强调用的超时时间,超时即放弃增强、原样放行。 */
|
|
59
|
+
timeoutMs: Schema.number().default(20000).description('增强调用超时(毫秒)'),
|
|
60
|
+
/** 短于该长度(字符)的输入不做增强(如「继续」「ok」)。 */
|
|
61
|
+
minPromptLength: Schema.number().default(4).description('触发增强的最小输入长度(字符)'),
|
|
62
|
+
/** 超过该长度的输入视为已经是完整提示词,不再增强。 */
|
|
63
|
+
maxInputChars: Schema.number().default(4000).description('超过此长度不再增强(字符)'),
|
|
64
|
+
/** 增强后提示词的语言:auto=跟随原始输入。 */
|
|
65
|
+
language: Schema.union(['auto', 'zh', 'en']).default('auto').description('输出语言'),
|
|
66
|
+
/** 追加到内置改写规则之后的自定义风格要求(可为空)。 */
|
|
67
|
+
styleHint: Schema.string().default('').description('自定义风格要求(追加到改写规则后)'),
|
|
68
|
+
/** 增强时把最近一轮会话拼进改写参考:代词/指代(它、这个)能被上下文消解。 */
|
|
69
|
+
useContext: Schema.boolean().default(false).description('参考最近会话上下文(输入框按钮生效)'),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/* ------------------------------------------------------------------ */
|
|
73
|
+
/* 增强强度分级 */
|
|
74
|
+
/* ------------------------------------------------------------------ */
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 四档强度的差异不在「想得多深」,而在「改多少、补多少、要不要改结构」。
|
|
78
|
+
* 每档给独立的规则、token 上限和长度上限,避免 Max 档把一句话膨胀成论文。
|
|
79
|
+
*/
|
|
80
|
+
const LEVELS = {
|
|
81
|
+
off: null,
|
|
82
|
+
low: {
|
|
83
|
+
label: 'Low',
|
|
84
|
+
maxTokens: 900,
|
|
85
|
+
maxRatio: 1.4,
|
|
86
|
+
rules: [
|
|
87
|
+
'INTENSITY: LOW (minimal edit).',
|
|
88
|
+
'Change only what is necessary to remove ambiguity. Keep the original wording, tone and length as much as possible.',
|
|
89
|
+
'Do: fix vague pronouns, unclear verbs, missing objects, redundancy, obvious typos. Split run-on sentences.',
|
|
90
|
+
'Do NOT: add new sections, add headings, invent requirements, add constraints the user did not imply, expand into a spec.',
|
|
91
|
+
'Target length: at most 1.4x the original. Shorter is better.',
|
|
92
|
+
'If the input is already clear, return it almost unchanged.',
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
high: {
|
|
96
|
+
label: 'High',
|
|
97
|
+
maxTokens: 1800,
|
|
98
|
+
maxRatio: 2.2,
|
|
99
|
+
rules: [
|
|
100
|
+
'INTENSITY: HIGH (structured completion).',
|
|
101
|
+
'Rewrite into a clear, self-contained, directly executable request. Add the context, constraints and output expectation that a competent assistant would need.',
|
|
102
|
+
'Do: state the goal explicitly, make implicit requirements explicit, specify the expected output form, keep the user\'s intent and scope.',
|
|
103
|
+
'Do NOT: invent facts, technologies, file names or numbers the user never mentioned. When a detail is genuinely unknown, express it conditionally ("if X, then Y") or mark a default as "(default: ...)".',
|
|
104
|
+
'Do NOT: reply with "please confirm these details first" or ask the user anything. That converts the prompt into a conversation. Fill the gap yourself and keep the request executable.',
|
|
105
|
+
'Do NOT: turn the request into a tutorial, a plan, or an answer. It must stay a request.',
|
|
106
|
+
'Prefer flowing paragraphs over markdown headings unless the task is clearly multi-part.',
|
|
107
|
+
'Target length: 1.3x to 2.2x the original. For very short inputs (under 20 characters), 2-4x is fine — a bare phrase needs more scaffolding than a half-written spec.',
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
max: {
|
|
111
|
+
label: 'Max',
|
|
112
|
+
maxTokens: 3200,
|
|
113
|
+
maxRatio: 3.5,
|
|
114
|
+
rules: [
|
|
115
|
+
'INTENSITY: MAX (first-principles reconstruction).',
|
|
116
|
+
'First reason about what outcome the user actually wants, then rebuild the request from that outcome rather than patching the wording.',
|
|
117
|
+
'Do: decompose into goal, context, constraints, deliverables, acceptance criteria when the task warrants it; surface hidden assumptions and mark each one inline as "(assumption: ...)"; make success verifiable.',
|
|
118
|
+
'Do: point out missing critical information as conditional requirements inside the prompt, never as a question back to the user.',
|
|
119
|
+
'Do NOT: invent facts. Every assumption must be labeled.',
|
|
120
|
+
'Do NOT: pad with generic prompt-engineering boilerplate ("You are a world-class expert..."), role-play scaffolding, or restated instructions.',
|
|
121
|
+
'FORMAT: plain text only, because the output lands in a plain-text composer. No "#" headings, no "**bold**", no "-" or "*" bullets, no code fences.',
|
|
122
|
+
'Structure with short labeled lines instead: 目标:/ 背景:/ 约束:/ 产出:/ 验收: (or Goal:/Context:/Constraints:/Deliverables: when writing English). One requirement per line, sub-points indented with two spaces.',
|
|
123
|
+
'Target length: 2x to 3.5x the original. Never exceed 3500 characters.',
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* ------------------------------------------------------------------ */
|
|
129
|
+
/* 内置改写元提示词 */
|
|
130
|
+
/* ------------------------------------------------------------------ */
|
|
131
|
+
|
|
132
|
+
// 基础规则来自 WorkBuddy 客户端内置的 enhance-prompt 代理(逆向自 app.asar,
|
|
133
|
+
// agentName = 'enhance-prompt'),改写为通用 agent 场景,并按强度分级。
|
|
134
|
+
const BASE_RULES = [
|
|
135
|
+
'You are a Prompt Engineering Expert. You rewrite a user\'s rough input into a more effective prompt for an AI assistant that can read files, run commands and write code.',
|
|
136
|
+
'',
|
|
137
|
+
'CORE PRINCIPLES:',
|
|
138
|
+
'- Preserve the user\'s original intent and scope. You are rewriting a request, not answering it.',
|
|
139
|
+
'- Never fabricate specifics the user did not provide: no invented tech stack, file paths, numbers, names, or requirements.',
|
|
140
|
+
'- Keep the output in the exact same language as the input. If the input mixes languages, keep the mix.',
|
|
141
|
+
'- Remove redundancy, resolve ambiguity, make the ask actionable.',
|
|
142
|
+
'- Do NOT explain HOW to do the task, do NOT answer the question, do NOT add a preamble like "Here is the enhanced prompt".',
|
|
143
|
+
'- Do NOT request guides, tutorials, or code snippets unless the user asked for them.',
|
|
144
|
+
'- Do NOT suggest specific technologies unless they appear in the input.',
|
|
145
|
+
'',
|
|
146
|
+
'MISSING INFORMATION — the most important rule:',
|
|
147
|
+
'- NEVER turn a gap into a question for the user. You are writing a prompt that gets executed immediately, not starting a conversation. Output containing "please confirm...", "请先确认...", "我需要知道..." is a failure.',
|
|
148
|
+
'- Handle a gap in one of two ways: state a reasonable default inline as "(default: ...)", or phrase it as a conditional requirement inside the prompt ("if the site requires login, ...").',
|
|
149
|
+
'- Prefer filling in generally applicable constraints (runnable output, dependencies, error handling, acceptance criteria) over interrogating the user about specifics.',
|
|
150
|
+
'- The rewritten prompt must be executable as-is. Whoever reads it should be able to start working without replying first.',
|
|
151
|
+
'',
|
|
152
|
+
'OUTPUT: only the rewritten prompt text. No commentary, no code fences, no quotes around it.',
|
|
153
|
+
].join('\n')
|
|
154
|
+
|
|
155
|
+
function buildMetaPrompt(raw, config, level, contextTurn) {
|
|
156
|
+
const spec = LEVELS[level] ?? LEVELS.high
|
|
157
|
+
const lines = [BASE_RULES, '', spec.rules.join('\n')]
|
|
158
|
+
// 参考上下文:只用于消解指代、贴合讨论,绝不据此发明新需求。
|
|
159
|
+
if (config.useContext && contextTurn?.userText && contextTurn?.assistantText) {
|
|
160
|
+
lines.push(
|
|
161
|
+
'',
|
|
162
|
+
'Recent conversation context (the most recent user/assistant turn). Use it ONLY to resolve references (it / that / 上面提到的) and align the rewrite with the ongoing discussion; never invent requirements from it:',
|
|
163
|
+
'<context>',
|
|
164
|
+
`User: ${contextTurn.userText.slice(-1200)}`,
|
|
165
|
+
`Assistant: ${contextTurn.assistantText.slice(-1200)}`,
|
|
166
|
+
'</context>',
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
// WorkBuddy 的提示词已内置「跟随输入语言」;这两个配置只用于强制覆盖。
|
|
170
|
+
if (config.language === 'zh') lines.push('', 'OVERRIDE: write the rewritten prompt in Simplified Chinese.')
|
|
171
|
+
else if (config.language === 'en') lines.push('', 'OVERRIDE: write the rewritten prompt in English.')
|
|
172
|
+
if (config.styleHint && config.styleHint.trim().length > 0) {
|
|
173
|
+
lines.push('', `Additional style requirements from the host config: ${config.styleHint.trim()}`)
|
|
174
|
+
}
|
|
175
|
+
// 用标签包住原文:即使原文本身长得像指令,也不会和改写规则混在一起。
|
|
176
|
+
lines.push('', 'Raw input:', '<original_prompt>', raw, '</original_prompt>')
|
|
177
|
+
return lines.join('\n')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ------------------------------------------------------------------ */
|
|
181
|
+
/* 最近一轮会话捕获(参考上下文用;深度标记模式照搬 followup 模块) */
|
|
182
|
+
/* ------------------------------------------------------------------ */
|
|
183
|
+
|
|
184
|
+
const llmDepth = new AsyncLocalStorage()
|
|
185
|
+
const turnCache = new Map()
|
|
186
|
+
const TURN_CACHE_MAX = 24
|
|
187
|
+
const TURN_CACHE_TTL_MS = 10 * 60_000
|
|
188
|
+
|
|
189
|
+
/** options.messages 里最后一条用户消息的纯文本。 */
|
|
190
|
+
function lastUserTextOf(messages) {
|
|
191
|
+
if (!Array.isArray(messages)) return ''
|
|
192
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
193
|
+
const m = messages[i]
|
|
194
|
+
if (m?.role !== 'user') continue
|
|
195
|
+
const blocks = Array.isArray(m.content) ? m.content : []
|
|
196
|
+
const joined = blocks
|
|
197
|
+
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
|
198
|
+
.map((b) => b.text)
|
|
199
|
+
.join('')
|
|
200
|
+
if (joined.trim()) return joined.trim()
|
|
201
|
+
}
|
|
202
|
+
return ''
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function cachedTurnOf(sessionId) {
|
|
206
|
+
const hit = turnCache.get(sessionId)
|
|
207
|
+
if (!hit) return null
|
|
208
|
+
if (Date.now() - hit.at > TURN_CACHE_TTL_MS) { turnCache.delete(sessionId); return null }
|
|
209
|
+
return hit
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function rememberTurn(sessionId, userText, assistantText) {
|
|
213
|
+
if (!sessionId || !userText || !assistantText || assistantText.trim().length === 0) return
|
|
214
|
+
const prev = turnCache.get(sessionId)
|
|
215
|
+
// 同一轮的多步工具链 / 重试续流 userText 相同:助手文本追加累积。
|
|
216
|
+
const merged = prev && prev.userText === userText
|
|
217
|
+
? `${prev.assistantText}\n\n${assistantText}`
|
|
218
|
+
: assistantText
|
|
219
|
+
turnCache.set(sessionId, { userText, assistantText: merged.slice(-6000), at: Date.now() })
|
|
220
|
+
if (turnCache.size > TURN_CACHE_MAX) {
|
|
221
|
+
let oldestKey = null
|
|
222
|
+
let oldestAt = Infinity
|
|
223
|
+
for (const [key, value] of turnCache) {
|
|
224
|
+
if (value.at < oldestAt) { oldestAt = value.at; oldestKey = key }
|
|
225
|
+
}
|
|
226
|
+
if (oldestKey !== null) turnCache.delete(oldestKey)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ------------------------------------------------------------------ */
|
|
231
|
+
/* 工具函数 */
|
|
232
|
+
/* ------------------------------------------------------------------ */
|
|
233
|
+
|
|
234
|
+
/** 提取消息里的纯文本内容;非「聊天框用户消息 / 单个文本块」返回 null(不处理)。 */
|
|
235
|
+
function singleTextOf(message) {
|
|
236
|
+
if (!message || message.role !== 'user') return null
|
|
237
|
+
// dsh 内部还会产生 role 为 user、source.kind 为 goal / session-reference /
|
|
238
|
+
// notice 等的消息;它们不是用户在对话框里输入的内容,绝不能改写。
|
|
239
|
+
if (message.source?.kind !== 'user') return null
|
|
240
|
+
if (!Array.isArray(message.content)) return null
|
|
241
|
+
if (message.content.length !== 1) return null
|
|
242
|
+
const block = message.content[0]
|
|
243
|
+
return block && block.type === 'text' && typeof block.text === 'string' ? block.text : null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 判断是否应该跳过增强。 */
|
|
247
|
+
function shouldSkip(text, config) {
|
|
248
|
+
if (!text) return true
|
|
249
|
+
const trimmed = text.trim()
|
|
250
|
+
if (trimmed.length === 0) return true
|
|
251
|
+
// 斜杠命令(/plan、/goal 等)绝不改写
|
|
252
|
+
if (trimmed.startsWith('/')) return true
|
|
253
|
+
if (trimmed.length < config.minPromptLength) return true
|
|
254
|
+
if (trimmed.length > config.maxInputChars) return true
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 去掉模型输出里常见的包裹物:代码围栏、引号、前缀说明行。 */
|
|
259
|
+
function cleanModelOutput(text) {
|
|
260
|
+
let out = String(text ?? '').trim()
|
|
261
|
+
// 去掉成对代码围栏 ```...```(含语言标注)
|
|
262
|
+
const fence = out.match(/^```[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n?```$/)
|
|
263
|
+
if (fence) out = fence[1].trim()
|
|
264
|
+
// 去掉「增强后提示词:」「Enhanced prompt:」之类前缀行
|
|
265
|
+
out = out.replace(/^(?:enhanced prompt|提示词增强|增强后的提示词|改写后(?:的提示词)?)\s*[::]\s*/i, '')
|
|
266
|
+
// 去掉最外层成对引号
|
|
267
|
+
const q = out.match(/^"([\s\S]*)"$|^“([\s\S]*)”$/)
|
|
268
|
+
if (q) out = (q[1] ?? q[2]).trim()
|
|
269
|
+
// 兜底去 markdown:输入框是纯文本,留着 # 和 ** 只会变成噪音
|
|
270
|
+
out = out
|
|
271
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
272
|
+
.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
273
|
+
.replace(/^(\s*)[-*]\s+/gm, '$1· ')
|
|
274
|
+
return out
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 调一次 LLM 做改写;任何失败都抛错,由上层兜底放行。 */
|
|
278
|
+
async function rewriteWithLlm(ctx, route, raw, config, level, outerSignal, contextTurn) {
|
|
279
|
+
const spec = LEVELS[level] ?? LEVELS.high
|
|
280
|
+
// 超时与外部取消(用户中断 pre-step)收敛到同一个内部 signal,
|
|
281
|
+
// 底层流会被及时中断,不会在后台继续烧 token。
|
|
282
|
+
const controller = new AbortController()
|
|
283
|
+
const forwardAbort = () => controller.abort(outerSignal.reason)
|
|
284
|
+
if (outerSignal?.aborted) forwardAbort()
|
|
285
|
+
else outerSignal?.addEventListener('abort', forwardAbort, { once: true })
|
|
286
|
+
const timer = setTimeout(
|
|
287
|
+
() => controller.abort(new Error('prompt-enhancer timeout')),
|
|
288
|
+
config.timeoutMs,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
const aborted = new Promise((_, reject) => {
|
|
292
|
+
const fail = () => reject(controller.signal.reason ?? new Error('prompt-enhancer aborted'))
|
|
293
|
+
// 外层 signal 可能一开始就已 abort:先检查,再挂监听。
|
|
294
|
+
if (controller.signal.aborted) fail()
|
|
295
|
+
else controller.signal.addEventListener('abort', fail, { once: true })
|
|
296
|
+
})
|
|
297
|
+
// 成功路径下 aborted 不会被消费,挂空 catch 避免 unhandled rejection。
|
|
298
|
+
void aborted.catch(() => {})
|
|
299
|
+
|
|
300
|
+
// 给 dsh-cost 的计量通道打来源标记:增强调用单独归类,费用弹卡里才能
|
|
301
|
+
// 把「增强」和「对话」分开算。5 秒窗口足够覆盖一次调用的启动。
|
|
302
|
+
globalThis.__dshPluginCall = { source: 'enhance', at: Date.now() }
|
|
303
|
+
|
|
304
|
+
const iterator = ctx.llm.stream({
|
|
305
|
+
provider: route.provider,
|
|
306
|
+
model: route.model,
|
|
307
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: buildMetaPrompt(raw, config, level, contextTurn) }] }],
|
|
308
|
+
maxTokens: spec.maxTokens,
|
|
309
|
+
signal: controller.signal,
|
|
310
|
+
})[Symbol.asyncIterator]()
|
|
311
|
+
|
|
312
|
+
async function collect() {
|
|
313
|
+
let text = ''
|
|
314
|
+
for (;;) {
|
|
315
|
+
// 深度标记内拉取:改写调用自己的流不进会话轮缓存。
|
|
316
|
+
const result = await llmDepth.run(1, () => iterator.next())
|
|
317
|
+
const { done, value } = result
|
|
318
|
+
if (done) break
|
|
319
|
+
if (value.type === 'text-delta') text += value.text
|
|
320
|
+
else if (value.type === 'finish' && value.reason && (value.reason.kind === 'error' || value.reason.kind === 'aborted')) {
|
|
321
|
+
throw new Error(`prompt-enhancer llm ${value.reason.kind}: ${value.reason.failure?.message ?? 'unknown'}`)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return text
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const collected = await Promise.race([collect(), aborted])
|
|
329
|
+
const cleaned = cleanModelOutput(collected)
|
|
330
|
+
if (!cleaned) throw new Error('prompt-enhancer got empty output')
|
|
331
|
+
// 失控保护:模型把一句话膨胀成小作文时判失败,回退原文而不是塞给用户。
|
|
332
|
+
// 短输入天然膨胀率高(12 字补成 300 字是合理的),所以保底 600 字;
|
|
333
|
+
// 长输入按倍数算,但封顶 4000 字,避免 4000 字输入算出 2 万字上限。
|
|
334
|
+
const limit = Math.min(Math.max(Math.round(raw.length * spec.maxRatio * 1.6), 600), 4000)
|
|
335
|
+
if (cleaned.length > limit) {
|
|
336
|
+
throw new Error(`prompt-enhancer output runaway: ${cleaned.length} chars > ${limit}`)
|
|
337
|
+
}
|
|
338
|
+
return cleaned
|
|
339
|
+
} catch (err) {
|
|
340
|
+
// 中断底层流,避免失败后还在后台烧 token。
|
|
341
|
+
// 不 await:底层若卡死,等待 return() 会拖住 fail-open。
|
|
342
|
+
try {
|
|
343
|
+
const closing = iterator.return?.()
|
|
344
|
+
if (closing && typeof closing.catch === 'function') closing.catch(() => {})
|
|
345
|
+
} catch { /* ignore */ }
|
|
346
|
+
throw err
|
|
347
|
+
} finally {
|
|
348
|
+
clearTimeout(timer)
|
|
349
|
+
outerSignal?.removeEventListener('abort', forwardAbort)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* ------------------------------------------------------------------ */
|
|
354
|
+
/* 插件入口 */
|
|
355
|
+
/* ------------------------------------------------------------------ */
|
|
356
|
+
|
|
357
|
+
export function apply(ctx, config) {
|
|
358
|
+
// 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
|
|
359
|
+
// 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
|
|
360
|
+
const live = { ...config }
|
|
361
|
+
|
|
362
|
+
// 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。必须先回放
|
|
363
|
+
// 再初始化 currentLevel,重启后上次的强度档位才生效。
|
|
364
|
+
const SETTINGS_KEYS = ['level', 'mode', 'preStepAuto', 'language', 'styleHint', 'timeoutMs', 'minPromptLength', 'maxInputChars', 'useContext']
|
|
365
|
+
const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'prompt-enhancer', 'settings.json')
|
|
366
|
+
try {
|
|
367
|
+
const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
|
|
368
|
+
for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
|
|
369
|
+
} catch { /* 无存档:全部跟随配置 */ }
|
|
370
|
+
let settingsTimer = null
|
|
371
|
+
const persistSettings = () => {
|
|
372
|
+
if (settingsTimer) return
|
|
373
|
+
settingsTimer = setTimeout(() => {
|
|
374
|
+
settingsTimer = null
|
|
375
|
+
try {
|
|
376
|
+
mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
|
|
377
|
+
const out = {}
|
|
378
|
+
for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
|
|
379
|
+
const tmp = `${SETTINGS_PATH}.tmp`
|
|
380
|
+
writeFileSync(tmp, JSON.stringify(out, null, 2))
|
|
381
|
+
renameSync(tmp, SETTINGS_PATH)
|
|
382
|
+
} catch { /* ignore */ }
|
|
383
|
+
}, 400)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const log = (level, msg) => console[level === 'error' ? 'error' : 'log'](`[dsh-prompt-enhancer] ${msg}`)
|
|
387
|
+
|
|
388
|
+
/** 最近一次见到的会话路由,供 enhance_prompt 工具和 web 端点在没有显式配置时复用。 */
|
|
389
|
+
let lastRoute = null
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 运行时增强强度。前端选择器通过 /api/prompt-enhancer/settings 改这里,
|
|
393
|
+
* 让 pre-step 与手动按钮共用同一档位。初始值来自配置。
|
|
394
|
+
*/
|
|
395
|
+
let currentLevel = Object.prototype.hasOwnProperty.call(LEVELS, live.level) ? live.level : 'high'
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* 前端刚增强过的文本指纹。发送拦截路径增强完再 submit 时,pre-step 会再看到
|
|
399
|
+
* 这条消息;用它去重,避免同一句话被改两遍。两分钟自动过期。
|
|
400
|
+
*/
|
|
401
|
+
const recentEnhanced = new Map()
|
|
402
|
+
const DEDUPE_TTL = 120_000
|
|
403
|
+
|
|
404
|
+
function fingerprint(text) {
|
|
405
|
+
let h = 0
|
|
406
|
+
for (let i = 0; i < text.length; i++) {
|
|
407
|
+
h = (h * 31 + text.charCodeAt(i)) | 0
|
|
408
|
+
}
|
|
409
|
+
return `${text.length}:${h}`
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function rememberEnhanced(text) {
|
|
413
|
+
const now = Date.now()
|
|
414
|
+
for (const [key, ts] of recentEnhanced) {
|
|
415
|
+
if (now - ts > DEDUPE_TTL) recentEnhanced.delete(key)
|
|
416
|
+
}
|
|
417
|
+
recentEnhanced.set(fingerprint(text), now)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function wasJustEnhanced(text) {
|
|
421
|
+
const ts = recentEnhanced.get(fingerprint(text))
|
|
422
|
+
return ts !== undefined && Date.now() - ts < DEDUPE_TTL
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 全局默认模型选择(composer 里的模型选择器改的就是它),
|
|
427
|
+
* 让输入框按钮在「会话还没创建、没有 agent」时也能拿到路由。
|
|
428
|
+
*/
|
|
429
|
+
function defaultRoute() {
|
|
430
|
+
const defaults = ctx.get('agentDefaultModel')
|
|
431
|
+
const selection = defaults?.currentSelection?.()
|
|
432
|
+
return selection?.provider && selection?.model
|
|
433
|
+
? { provider: selection.provider, model: selection.model }
|
|
434
|
+
: null
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** 配置里显式指定的路由。 */
|
|
438
|
+
function configRoute() {
|
|
439
|
+
return live.provider && live.model ? { provider: live.provider, model: live.model } : null
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function resolveRoute(agent) {
|
|
443
|
+
const provider = agent?.options?.provider
|
|
444
|
+
const model = agent?.options?.model
|
|
445
|
+
return configRoute()
|
|
446
|
+
?? (provider && model ? { provider, model } : null)
|
|
447
|
+
?? defaultRoute()
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** web 端点没有 agent:配置 > 最近一次会话路由 > 全局默认选择。 */
|
|
451
|
+
function endpointRoute() {
|
|
452
|
+
return configRoute() ?? lastRoute ?? defaultRoute()
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 会话一建立就记住路由,这样输入框按钮在「还没发过消息」时也能工作。
|
|
456
|
+
ctx.on('agent/created', ({ agent }) => {
|
|
457
|
+
const route = resolveRoute(agent)
|
|
458
|
+
if (route) lastRoute = route
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
/** 对单条消息做增强;不需要增强时原样返回同一个对象。 */
|
|
462
|
+
async function enhanceOne(message, agent, signal) {
|
|
463
|
+
const raw = singleTextOf(message)
|
|
464
|
+
if (raw === null || shouldSkip(raw, config)) return message
|
|
465
|
+
// 前端发送拦截路径已经增强过,这里放行,避免二次改写。
|
|
466
|
+
if (wasJustEnhanced(raw.trim())) return message
|
|
467
|
+
const level = currentLevel
|
|
468
|
+
if (!level || level === 'off') return message
|
|
469
|
+
const route = resolveRoute(agent) ?? lastRoute
|
|
470
|
+
if (!route) return message
|
|
471
|
+
if (!llmRef) return message // llm 服务尚未就绪(inject 未解析完):fail-open 放行原文
|
|
472
|
+
const enhanced = await rewriteWithLlm({ llm: llmRef }, route, raw.trim(), config, level, signal)
|
|
473
|
+
log('log', `enhanced[${level}] ${raw.trim().length} -> ${enhanced.length} chars`)
|
|
474
|
+
// 用官方工厂重建消息:获得全新稳定 id,source 沿用原消息
|
|
475
|
+
return createUserMessage({
|
|
476
|
+
content: [{ type: 'text', text: enhanced }],
|
|
477
|
+
source: message.source,
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** 批量增强;没有任何改动时原样返回原数组。 */
|
|
482
|
+
async function enhanceBatch(messages, agent, signal) {
|
|
483
|
+
const out = []
|
|
484
|
+
let changed = false
|
|
485
|
+
for (const message of messages) {
|
|
486
|
+
let next = message
|
|
487
|
+
try {
|
|
488
|
+
next = await enhanceOne(message, agent, signal)
|
|
489
|
+
} catch (err) {
|
|
490
|
+
// fail-open:增强失败绝不阻塞对话
|
|
491
|
+
log('error', `enhance failed, pass through original: ${err?.message ?? err}`)
|
|
492
|
+
next = message
|
|
493
|
+
}
|
|
494
|
+
if (next !== message) changed = true
|
|
495
|
+
out.push(next)
|
|
496
|
+
}
|
|
497
|
+
return changed ? out : messages
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// pre-step 自动增强用的 llm 引用:在下方嵌套 inject 作用域里捕获。
|
|
501
|
+
// 套件宿主不按插件级 inject 并集给顶层作用域授权属性访问——真机 0.3.0:
|
|
502
|
+
// suite 联合声明了 tools,apply 里 ctx.tools 仍报 cannot get property
|
|
503
|
+
// without inject;0.3.2 同款实锤蔓延到请求期 ctx.llm(/rewrite 全挂)。
|
|
504
|
+
// 凡要用的服务,必须写进所在嵌套作用域自己的 inject 数组。
|
|
505
|
+
let llmRef = null
|
|
506
|
+
|
|
507
|
+
if (live.enabled) {
|
|
508
|
+
// 最近一轮会话捕获:输入框按钮带 sessionId 来取(参考上下文开关)。
|
|
509
|
+
// 自己的改写调用经 llmDepth 标记透传,不会被误当成对话轮。
|
|
510
|
+
ctx.on('llm/stream', (options, next) => {
|
|
511
|
+
if (llmDepth.getStore() !== undefined) return next()
|
|
512
|
+
const downstream = next()
|
|
513
|
+
const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : ''
|
|
514
|
+
const userText = lastUserTextOf(options?.messages)
|
|
515
|
+
let assistantText = ''
|
|
516
|
+
let downstreamIter = null
|
|
517
|
+
let completed = false
|
|
518
|
+
return (async function* peObserve() {
|
|
519
|
+
try {
|
|
520
|
+
downstreamIter = downstream[Symbol.asyncIterator]()
|
|
521
|
+
for (;;) {
|
|
522
|
+
const result = await llmDepth.run(1, () => downstreamIter.next())
|
|
523
|
+
if (result.done) break
|
|
524
|
+
const chunk = result.value
|
|
525
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
526
|
+
assistantText += chunk.text
|
|
527
|
+
if (assistantText.length > 8000) assistantText = assistantText.slice(-4000)
|
|
528
|
+
}
|
|
529
|
+
yield chunk
|
|
530
|
+
}
|
|
531
|
+
completed = true
|
|
532
|
+
} finally {
|
|
533
|
+
if (!completed) {
|
|
534
|
+
try { await downstreamIter?.return?.() } catch { /* ignore */ }
|
|
535
|
+
}
|
|
536
|
+
try { rememberTurn(sessionId, userText, assistantText) } catch { /* 缓存失败不影响主对话流 */ }
|
|
537
|
+
}
|
|
538
|
+
})()
|
|
539
|
+
}, { global: true })
|
|
540
|
+
|
|
541
|
+
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
|
|
542
|
+
const decision = await next()
|
|
543
|
+
if (decision.kind !== 'enter') return decision
|
|
544
|
+
const route = resolveRoute(agent)
|
|
545
|
+
if (route) lastRoute = route
|
|
546
|
+
if (live.mode !== 'auto') return decision
|
|
547
|
+
// 前端拦截发送并自行增强时,这里不再二次增强(否则同一句话被改两遍)。
|
|
548
|
+
if (!live.preStepAuto) return decision
|
|
549
|
+
const messages = await enhanceBatch(decision.messages, agent, signal)
|
|
550
|
+
if (messages === decision.messages) return decision
|
|
551
|
+
// spread 保留未来新增的 decision 字段
|
|
552
|
+
return { ...decision, messages }
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
// 注册模型可调用的工具:agent 自己也能主动增强某段提示词。
|
|
556
|
+
// 显式嵌套声明 ['llm','tools'](原因见上方 llmRef 注释);顺手捕获
|
|
557
|
+
// llm 引用,execute 里也不能用外层 ctx(同款报错,见 0.3.2)。
|
|
558
|
+
ctx.inject(['llm', 'tools'], (scope) => {
|
|
559
|
+
llmRef = scope.llm
|
|
560
|
+
scope.tools.register(defineTool({
|
|
561
|
+
name: 'enhance_prompt',
|
|
562
|
+
description: [
|
|
563
|
+
'Rewrite a rough user prompt into a clear, specific, self-contained prompt before using it.',
|
|
564
|
+
'Use it when the user asks to improve/expand their prompt, or before re-running a vague earlier request.',
|
|
565
|
+
].join(' '),
|
|
566
|
+
parameters: {
|
|
567
|
+
prompt: { type: 'string', required: true, description: 'The raw prompt text to enhance' },
|
|
568
|
+
},
|
|
569
|
+
output: {
|
|
570
|
+
schema: { type: 'string' },
|
|
571
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
572
|
+
},
|
|
573
|
+
async execute(args, exec) {
|
|
574
|
+
const route = endpointRoute()
|
|
575
|
+
if (!route) throw new Error('No model route available: set provider/model in the plugin config.')
|
|
576
|
+
const level = currentLevel === 'off' ? 'high' : currentLevel
|
|
577
|
+
const enhanced = await rewriteWithLlm(scope, route, String(args.prompt ?? '').trim(), config, level, exec?.signal)
|
|
578
|
+
return enhanced
|
|
579
|
+
},
|
|
580
|
+
}))
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
log('log', `loaded (mode=${live.mode}, timeout=${live.timeoutMs}ms)`)
|
|
584
|
+
} else {
|
|
585
|
+
log('log', 'loaded but disabled by config')
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/* ------------------------------------------------------------------ */
|
|
589
|
+
/* web 端「增强提示词」端点(只在有 webServer 的宿主里注册) */
|
|
590
|
+
/* ------------------------------------------------------------------ */
|
|
591
|
+
|
|
592
|
+
// 可选注入:CLI / TUI 等没有 webServer 的宿主照常加载,只是没有输入框按钮。
|
|
593
|
+
// webServer 作用域里请求期要调 llm:必须写进这个嵌套作用域自己的 inject
|
|
594
|
+
// (真机 0.3.1:/rewrite 里 rewriteWithLlm(ctx) 报 cannot get property
|
|
595
|
+
// "llm" without inject——顶层并集不授权属性访问,见上方 llmRef 注释)。
|
|
596
|
+
ctx.inject(['webServer', 'llm'], (webCtx) => {
|
|
597
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
598
|
+
kind: 'prefix',
|
|
599
|
+
path: '/api/prompt-enhancer',
|
|
600
|
+
handler: async (req, res) => {
|
|
601
|
+
const send = (status, body) => {
|
|
602
|
+
res.statusCode = status
|
|
603
|
+
res.setHeader('content-type', 'application/json')
|
|
604
|
+
res.end(JSON.stringify(body))
|
|
605
|
+
}
|
|
606
|
+
const path = (req.url ?? '').split('?')[0].replace(/^\/api\/prompt-enhancer/, '')
|
|
607
|
+
|
|
608
|
+
// 前端初始化:读当前档位,避免刷新后选择器回落到默认值。
|
|
609
|
+
if (path === '/settings' && req.method === 'GET') {
|
|
610
|
+
send(200, { ...live, level: currentLevel, levels: Object.keys(LEVELS) })
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// 前端改档位:写进运行时状态,让 pre-step 与按钮共用同一档。
|
|
615
|
+
if (path === '/settings' && req.method === 'POST') {
|
|
616
|
+
let raw = ''
|
|
617
|
+
for await (const chunk of req) raw += chunk
|
|
618
|
+
let body
|
|
619
|
+
try {
|
|
620
|
+
body = JSON.parse(raw)
|
|
621
|
+
} catch {
|
|
622
|
+
send(400, { error: 'invalid-json' })
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
// level 是运行时主开关,必须合法;其余字段宽松校验后写入 live。
|
|
626
|
+
if (body?.level !== undefined) {
|
|
627
|
+
const next = body.level
|
|
628
|
+
if (typeof next !== 'string' || !(next in LEVELS)) {
|
|
629
|
+
send(400, { error: `level must be one of ${Object.keys(LEVELS).join('/')}` })
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
currentLevel = next
|
|
633
|
+
live.level = next
|
|
634
|
+
log('log', `level -> ${next}`)
|
|
635
|
+
}
|
|
636
|
+
if (body?.mode === 'auto' || body?.mode === 'manual') live.mode = body.mode
|
|
637
|
+
if (body?.preStepAuto !== undefined) live.preStepAuto = body.preStepAuto === true
|
|
638
|
+
if (body?.useContext !== undefined) live.useContext = body.useContext === true
|
|
639
|
+
if (body?.language === 'auto' || body?.language === 'zh' || body?.language === 'en') live.language = body.language
|
|
640
|
+
if (typeof body?.styleHint === 'string') live.styleHint = body.styleHint.slice(0, 500)
|
|
641
|
+
for (const [key, min, max] of [['timeoutMs', 1000, 120000], ['minPromptLength', 1, 200], ['maxInputChars', 100, 100000]]) {
|
|
642
|
+
if (body?.[key] === undefined) continue
|
|
643
|
+
const value = Number(body[key])
|
|
644
|
+
if (Number.isFinite(value)) live[key] = Math.min(max, Math.max(min, Math.round(value)))
|
|
645
|
+
}
|
|
646
|
+
persistSettings()
|
|
647
|
+
send(200, { ...live, level: currentLevel })
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// 前端把拦截决策写进 ~/.dsh/prompt-enhancer-debug.log,排查用。
|
|
652
|
+
if (path === '/debug' && req.method === 'POST') {
|
|
653
|
+
let raw = ''
|
|
654
|
+
for await (const chunk of req) raw += chunk
|
|
655
|
+
try {
|
|
656
|
+
await mkdir(dirname(DEBUG_LOG), { recursive: true })
|
|
657
|
+
await appendFile(DEBUG_LOG, `${new Date().toISOString()} ${raw}\n`, 'utf8')
|
|
658
|
+
} catch (err) {
|
|
659
|
+
log('error', `debug log failed: ${err?.message ?? err}`)
|
|
660
|
+
}
|
|
661
|
+
send(200, { ok: true })
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (path !== '/rewrite') {
|
|
666
|
+
send(404, { error: 'not-found' })
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
if (req.method !== 'POST') {
|
|
670
|
+
res.setHeader('allow', 'POST')
|
|
671
|
+
send(405, { error: 'method-not-allowed' })
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
let raw = ''
|
|
675
|
+
for await (const chunk of req) raw += chunk
|
|
676
|
+
let body
|
|
677
|
+
try {
|
|
678
|
+
body = JSON.parse(raw)
|
|
679
|
+
} catch {
|
|
680
|
+
send(400, { error: 'invalid-json' })
|
|
681
|
+
return
|
|
682
|
+
}
|
|
683
|
+
const text = typeof body?.text === 'string' ? body.text.trim() : ''
|
|
684
|
+
if (text.length === 0) {
|
|
685
|
+
send(400, { error: 'text must be a non-empty string' })
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
if (text.length > live.maxInputChars) {
|
|
689
|
+
send(413, { error: `text exceeds ${live.maxInputChars} chars` })
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
// 单次请求可以覆盖档位(发送拦截路径会带上当前选择),否则用运行时档位。
|
|
693
|
+
const requested = typeof body?.level === 'string' && body.level in LEVELS ? body.level : currentLevel
|
|
694
|
+
const level = requested === 'off' ? null : requested
|
|
695
|
+
if (!level) {
|
|
696
|
+
send(200, { text, skipped: 'level-off' })
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
const route = endpointRoute()
|
|
700
|
+
if (!route) {
|
|
701
|
+
send(409, { error: 'no model route available; set provider/model in the plugin config' })
|
|
702
|
+
return
|
|
703
|
+
}
|
|
704
|
+
// 参考上下文开关:前端带 sessionId,服务端取该会话最近一轮。
|
|
705
|
+
const contextTurn = live.useContext && typeof body?.sessionId === 'string'
|
|
706
|
+
? cachedTurnOf(body.sessionId)
|
|
707
|
+
: null
|
|
708
|
+
try {
|
|
709
|
+
const enhanced = await rewriteWithLlm(webCtx, route, text, config, level, undefined, contextTurn)
|
|
710
|
+
// 记下指纹:这条文本随后经 submit 进入 pre-step 时不再二次增强。
|
|
711
|
+
rememberEnhanced(enhanced)
|
|
712
|
+
send(200, { text: enhanced, level })
|
|
713
|
+
} catch (err) {
|
|
714
|
+
log('error', `composer enhance failed: ${err?.message ?? err}`)
|
|
715
|
+
// 落盘:服务端错误只进 console 等于没有(排障教训)。
|
|
716
|
+
try {
|
|
717
|
+
await mkdir(dirname(DEBUG_LOG), { recursive: true })
|
|
718
|
+
await appendFile(DEBUG_LOG, `${new Date().toISOString()} ${JSON.stringify({ phase: 'rewrite-error', error: String(err?.message ?? err).slice(0, 200) })}\n`, 'utf8')
|
|
719
|
+
} catch { /* ignore */ }
|
|
720
|
+
send(502, { error: 'enhance-failed' })
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
}), 'prompt-enhancer: /api/prompt-enhancer/*')
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
|