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,718 @@
|
|
|
1
|
+
/* 由 scripts/build-suite.mjs 从 dsh-followup/index.js 机械生成;勿手改,重跑脚本再生成。 */
|
|
2
|
+
/**
|
|
3
|
+
* dsh-followup — 每轮对话结束后的智能跟进推荐(DeepSeek Harness / dsh)
|
|
4
|
+
*
|
|
5
|
+
* Trae「智能跟进推荐」的同款交互:助手回复落定后,用一次轻量模型调用
|
|
6
|
+
* 生成 2-3 条短跟进建议,缓存在内存里;web 端在输入框上方浮出可点击
|
|
7
|
+
* 的 chip,点击把建议填进草稿(不自动发送)。
|
|
8
|
+
*
|
|
9
|
+
* 工作原理(服务端半):
|
|
10
|
+
* 主通道(0.2.3 起,借鉴 dsh-cost-meter 的链尾监听模式):
|
|
11
|
+
* - ctx.on('llm/stream', listener, { global: true }):llm 服务的瀑布事件,
|
|
12
|
+
* 每次 ctx.llm.stream() 都会经过。监听器拿到下游流后包一层旁路观察
|
|
13
|
+
* (逐 chunk 透传,不改流协议),累积 text-delta 得到助手回复;
|
|
14
|
+
* options.sessionId(GenerateOptions.sessionId,dsh-reference types.ts)
|
|
15
|
+
* 关联会话,options.messages 里最后一个真实用户消息作为本轮输入。
|
|
16
|
+
* { global: true } 让监听器收到隔离 llm 作用域(记忆插件等)的流。
|
|
17
|
+
* AsyncLocalStorage 深度标记排除两类重复捕获:包装路由插件在自己
|
|
18
|
+
* stream() 体内再发起的上游调用(pull 下游时打标记),以及本插件
|
|
19
|
+
* 自己发起的建议生成调用(发起时打标记)。
|
|
20
|
+
* 回落通道:
|
|
21
|
+
* - 客户端回合落定后 POST /generate {sessionId};服务端优先用 llm/stream
|
|
22
|
+
* 实时捕获的缓存(10 分钟内),未命中才解压 session.v4.jsonl.zstd 直读
|
|
23
|
+
* (日志驱动,0.2.0 引入,对新架构进程分裂免疫)
|
|
24
|
+
* 旧架构事件监听(agent/pre-step / assistant-stream / status)保留:
|
|
25
|
+
* 收不到事件的宿主上它们是死代码,旧版本 dsh 上仍可用。
|
|
26
|
+
*
|
|
27
|
+
* 已核对的 dsh API(deepseek-ai/deepseek-harness,对照 dsh-reference):
|
|
28
|
+
* - ctx.on('llm/stream', (options, next) => AsyncIterable, { global: true })
|
|
29
|
+
* 监听器契约:必须调用 next() 推进瀑布并返回(包装的)流;嵌套/排除
|
|
30
|
+
* 时原样返回 next() 的下游
|
|
31
|
+
* - GenerateOptions.sessionId?: Branded<'SessionId'>(packages/llm/llm/src/types.ts)
|
|
32
|
+
* - ctx.on('agent/status', ({ agent, status }) => ...) // idle ⇄ running
|
|
33
|
+
* - ctx.on('agent/pre-step', async ({ agent, messages }, next) => PreStepDecision)
|
|
34
|
+
* - ctx.on('agent/assistant-stream', ({ agent, frame }) => ...)
|
|
35
|
+
* frame = { type: 'start' | 'chunk' | 'end', chunk?: StreamChunk, ... }
|
|
36
|
+
* 文本块 chunk = { type: 'text-delta', text }
|
|
37
|
+
* - ctx.llm.stream({ provider, model, messages, maxTokens, signal })
|
|
38
|
+
* → AsyncIterable<{ type: 'text-delta', text } | { type: 'finish', reason }>
|
|
39
|
+
* - agent.options.provider / agent.options.model:会话当前的路由
|
|
40
|
+
* - ctx.get('agentDefaultModel')?.currentSelection?.() → { provider, model }
|
|
41
|
+
* - webServer.register({ kind: 'prefix', path, handler }, label)
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
45
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
46
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
47
|
+
import { appendFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
48
|
+
import { homedir } from 'node:os'
|
|
49
|
+
import { dirname, join } from 'node:path'
|
|
50
|
+
|
|
51
|
+
const name = 'followup'
|
|
52
|
+
|
|
53
|
+
const DIAG_LOG = join(homedir(), '.dsh', 'followup-debug.log')
|
|
54
|
+
const SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions')
|
|
55
|
+
const PROFILES_ROOT = join(homedir(), '.dsh', 'profiles')
|
|
56
|
+
const SUGGEST_FILE = join(homedir(), '.dsh', 'followup-suggestions.json')
|
|
57
|
+
|
|
58
|
+
/** 每环节一行落盘:事件是否到、文本抓没抓到、生成成没成,一次复现即可定位断点。 */
|
|
59
|
+
const report = async (line) => {
|
|
60
|
+
try {
|
|
61
|
+
await mkdir(dirname(DIAG_LOG), { recursive: true })
|
|
62
|
+
await appendFile(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8')
|
|
63
|
+
} catch { /* ignore */ }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ------------------------------------------------------------------ */
|
|
67
|
+
/* 会话日志直读(新架构进程分裂后 agent/* 事件到不了插件进程, */
|
|
68
|
+
/* 0.2.0 起改为日志驱动:解压 session.v4.jsonl.zstd 取最后一轮文本) */
|
|
69
|
+
/* ------------------------------------------------------------------ */
|
|
70
|
+
|
|
71
|
+
/** 多帧 zstd(追加写每帧独立):按魔数切段逐帧解压,坏帧跳过。 */
|
|
72
|
+
async function decompressLog(file) {
|
|
73
|
+
const zlib = await import('node:zlib')
|
|
74
|
+
if (typeof zlib.zstdDecompressSync !== 'function') {
|
|
75
|
+
throw new Error('node zlib 无 zstd 支持(需 Node ≥23.8)')
|
|
76
|
+
}
|
|
77
|
+
const buf = await readFile(file)
|
|
78
|
+
const MAGIC = 0x28
|
|
79
|
+
const idxs = [0]
|
|
80
|
+
for (let i = 4; i <= buf.length - 4; i += 1) {
|
|
81
|
+
if (buf[i] === MAGIC && buf[i + 1] === 0xB5 && buf[i + 2] === 0x2F && buf[i + 3] === 0xFD) idxs.push(i)
|
|
82
|
+
}
|
|
83
|
+
let out = ''
|
|
84
|
+
for (let k = 0; k < idxs.length; k += 1) {
|
|
85
|
+
try {
|
|
86
|
+
out += zlib.zstdDecompressSync(buf.subarray(idxs[k], idxs[k + 1] ?? buf.length)).toString('utf8')
|
|
87
|
+
} catch { /* 截断/坏帧跳过 */ }
|
|
88
|
+
}
|
|
89
|
+
return out
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 找会话日志文件:目录名可能是裸 uuid 或 session-<uuid>。 */
|
|
93
|
+
async function findSessionFile(sessionId) {
|
|
94
|
+
const bare = String(sessionId).replace(/^session-/, '')
|
|
95
|
+
let workspaces = []
|
|
96
|
+
try { workspaces = await readdir(SESSIONS_ROOT, { withFileTypes: true }) } catch { return null }
|
|
97
|
+
for (const ws of workspaces) {
|
|
98
|
+
if (!ws.isDirectory()) continue
|
|
99
|
+
for (const name of [sessionId, `session-${bare}`, bare]) {
|
|
100
|
+
const file = join(SESSIONS_ROOT, ws.name, name, 'session.v4.jsonl.zstd')
|
|
101
|
+
try { await readFile(file); return file } catch { /* 尝试下一个 */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 从持久日志提取最后一轮:最后一个真实用户消息 + 其后最后的助手文本。 */
|
|
108
|
+
async function lastTurnFromLog(sessionId) {
|
|
109
|
+
const file = await findSessionFile(sessionId)
|
|
110
|
+
if (!file) return null
|
|
111
|
+
const text = await decompressLog(file)
|
|
112
|
+
const events = []
|
|
113
|
+
for (const line of text.split('\n')) {
|
|
114
|
+
if (line.length === 0) continue
|
|
115
|
+
try { events.push(JSON.parse(line)) } catch { /* 半行跳过 */ }
|
|
116
|
+
}
|
|
117
|
+
const isRealUser = (e) => e?.type === 'user/message'
|
|
118
|
+
&& e.data?.source?.kind === 'user'
|
|
119
|
+
&& Array.isArray(e.data.content) && e.data.content.length === 1
|
|
120
|
+
&& e.data.content[0]?.type === 'text' && typeof e.data.content[0].text === 'string'
|
|
121
|
+
const isAssistant = (e) => e?.type === 'assistant/message'
|
|
122
|
+
let assistantText = null
|
|
123
|
+
let userText = null
|
|
124
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
125
|
+
const e = events[i]
|
|
126
|
+
if (assistantText === null && isAssistant(e)) {
|
|
127
|
+
const blocks = e.data?.message?.content
|
|
128
|
+
if (Array.isArray(blocks)) {
|
|
129
|
+
const joined = blocks.filter((b) => b?.type === 'text' && typeof b.text === 'string')
|
|
130
|
+
.map((b) => b.text).join('')
|
|
131
|
+
if (joined.trim().length > 0) assistantText = joined
|
|
132
|
+
}
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
if (assistantText !== null && isRealUser(e)) {
|
|
136
|
+
userText = e.data.content[0].text
|
|
137
|
+
break
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (userText === null || assistantText === null) return null
|
|
141
|
+
return { userText: userText.trim(), assistantText: assistantText.trim() }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 从 profile 用户补丁层读 agent-default-model 的 provider/model(兜底路由)。 */
|
|
145
|
+
async function patchDefaultRoute() {
|
|
146
|
+
let profiles = []
|
|
147
|
+
try { profiles = await readdir(PROFILES_ROOT, { withFileTypes: true }) } catch { return null }
|
|
148
|
+
for (const p of profiles) {
|
|
149
|
+
if (!p.isDirectory()) continue
|
|
150
|
+
try {
|
|
151
|
+
const raw = await readFile(join(PROFILES_ROOT, p.name, 'cordis.patch.yml'), 'utf8')
|
|
152
|
+
const m = raw.match(/id:\s*agent-default-model[\s\S]{0,300}?provider:\s*(\S+)[\s\S]{0,120}?model:\s*(\S+)/)
|
|
153
|
+
if (m) return { provider: m[1].replace(/^['"]|['"]$/g, ''), model: m[2].replace(/^['"]|['"]$/g, '') }
|
|
154
|
+
} catch { /* 下一个 profile */ }
|
|
155
|
+
}
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const inject = ['llm']
|
|
160
|
+
|
|
161
|
+
export const Config = Schema.object({
|
|
162
|
+
/** 总开关:关闭后不监听任何事件,也不注册 web 端点。 */
|
|
163
|
+
enabled: Schema.boolean().default(true).description('是否启用智能跟进推荐'),
|
|
164
|
+
/** 留空则跟随会话当前模型(agent.options)。 */
|
|
165
|
+
provider: Schema.string().description('生成建议使用的 provider(留空跟随会话)'),
|
|
166
|
+
/** 建议配一个快模型:这条调用在每轮回复结束后立刻发生。 */
|
|
167
|
+
model: Schema.string().description('生成建议使用的模型(留空跟随会话,建议快模型)'),
|
|
168
|
+
/** 单次生成超时;超时即放弃本轮建议。 */
|
|
169
|
+
timeoutMs: Schema.number().default(12000).description('生成超时(毫秒)'),
|
|
170
|
+
/** 回合落定后等多久再生成:缓冲多步回合的中间 idle。 */
|
|
171
|
+
idleDelayMs: Schema.number().default(1200).description('回合结束后的缓冲时间(毫秒)'),
|
|
172
|
+
/** 生成几条建议(最多 3)。 */
|
|
173
|
+
maxSuggestions: Schema.number().default(2).description('最多生成几条建议(1-2)'),
|
|
174
|
+
/** 上下文裁剪:超出截断,控制这条调用的 token 开销。 */
|
|
175
|
+
userChars: Schema.number().default(400).description('用户消息最多带多少字符'),
|
|
176
|
+
assistantChars: Schema.number().default(1200).description('助手回复最多带多少字符'),
|
|
177
|
+
/** 助手回复短于此不生成:「好的」「完成」这类没有可跟进的实质内容。 */
|
|
178
|
+
minAssistantChars: Schema.number().default(30).description('助手回复最小长度(字符)'),
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
/* ------------------------------------------------------------------ */
|
|
182
|
+
/* 提示词与解析 */
|
|
183
|
+
/* ------------------------------------------------------------------ */
|
|
184
|
+
|
|
185
|
+
function clampTail(text, limit) {
|
|
186
|
+
const clean = typeof text === 'string' ? text.trim() : ''
|
|
187
|
+
if (clean.length <= limit) return clean
|
|
188
|
+
return `…${clean.slice(-limit)}`
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function buildPrompt(userText, assistantText, count, config) {
|
|
192
|
+
return [
|
|
193
|
+
`You generate follow-up suggestions for an AI chat session (DeepSeek Harness). Based on the exchange below, propose ${count} short follow-up prompts the user would most plausibly send next.`,
|
|
194
|
+
'',
|
|
195
|
+
'Rules:',
|
|
196
|
+
'- Continue the conversation: refine, extend, verify or apply the assistant\'s last answer. Never generic filler like "还有吗" / "继续" / "tell me more".',
|
|
197
|
+
'- One imperative sentence each: 8~24 Chinese characters or 6~16 English words. No trailing period, no quotes, no numbering, no emoji.',
|
|
198
|
+
'- They must be sendable as-is: self-contained requests, not fragments.',
|
|
199
|
+
'- Write in the same language as the exchange.',
|
|
200
|
+
'- Output STRICT JSON only: an array of strings, nothing else. Example: ["第一条建议","第二条建议"]',
|
|
201
|
+
'',
|
|
202
|
+
'[User]',
|
|
203
|
+
clampTail(userText, config.userChars),
|
|
204
|
+
'',
|
|
205
|
+
'[Assistant]',
|
|
206
|
+
clampTail(assistantText, config.assistantChars),
|
|
207
|
+
].join('\n')
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 从模型输出里抠 JSON 数组;抠不出就返回空数组,宁缺毋滥。 */
|
|
211
|
+
function parseSuggestions(text, count) {
|
|
212
|
+
const start = text.indexOf('[')
|
|
213
|
+
const end = text.lastIndexOf(']')
|
|
214
|
+
if (start < 0 || end <= start) return []
|
|
215
|
+
let parsed
|
|
216
|
+
try {
|
|
217
|
+
parsed = JSON.parse(text.slice(start, end + 1))
|
|
218
|
+
} catch {
|
|
219
|
+
return []
|
|
220
|
+
}
|
|
221
|
+
if (!Array.isArray(parsed)) return []
|
|
222
|
+
return parsed
|
|
223
|
+
.filter((item) => typeof item === 'string')
|
|
224
|
+
.map((item) => item.trim().replace(/^[-*•\d.)、\s]+/, '').slice(0, 60))
|
|
225
|
+
.filter((item) => item.length >= 2)
|
|
226
|
+
.slice(0, count)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ------------------------------------------------------------------ */
|
|
230
|
+
/* 插件入口 */
|
|
231
|
+
/* ------------------------------------------------------------------ */
|
|
232
|
+
|
|
233
|
+
export function apply(ctx, config) {
|
|
234
|
+
// 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
|
|
235
|
+
// 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
|
|
236
|
+
const live = { ...config }
|
|
237
|
+
if (!live.enabled) return
|
|
238
|
+
|
|
239
|
+
// 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
|
|
240
|
+
const SETTINGS_KEYS = ['maxSuggestions', 'idleDelayMs', 'timeoutMs', 'minAssistantChars', 'userChars', 'assistantChars']
|
|
241
|
+
const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-followup', 'settings.json')
|
|
242
|
+
try {
|
|
243
|
+
const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
|
|
244
|
+
for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
|
|
245
|
+
} catch { /* 无存档:全部跟随配置 */ }
|
|
246
|
+
let settingsTimer = null
|
|
247
|
+
const persistSettings = () => {
|
|
248
|
+
if (settingsTimer) return
|
|
249
|
+
settingsTimer = setTimeout(() => {
|
|
250
|
+
settingsTimer = null
|
|
251
|
+
try {
|
|
252
|
+
mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
|
|
253
|
+
const out = {}
|
|
254
|
+
for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
|
|
255
|
+
const tmp = `${SETTINGS_PATH}.tmp`
|
|
256
|
+
writeFileSync(tmp, JSON.stringify(out, null, 2))
|
|
257
|
+
renameSync(tmp, SETTINGS_PATH)
|
|
258
|
+
} catch { /* ignore */ }
|
|
259
|
+
}, 400)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
void report(`server boot v0.3.3 enabled=true minAssistantChars=${live.minAssistantChars} idleDelayMs=${live.idleDelayMs} route=${live.provider || ''}/${live.model || ''}(follow-session)`)
|
|
263
|
+
|
|
264
|
+
/** 每个 agent 的对话尾部累积:userText 来自 pre-step,assistantText 来自流帧。 */
|
|
265
|
+
const buffers = new WeakMap()
|
|
266
|
+
const seenFrameTypes = new Set()
|
|
267
|
+
let bufCounter = 0
|
|
268
|
+
const agentConstructorName = (agent) => (agent === undefined || agent === null
|
|
269
|
+
? 'none'
|
|
270
|
+
: `${agent.constructor?.name ?? typeof agent}`)
|
|
271
|
+
const bufferOf = (agent) => {
|
|
272
|
+
let buf = buffers.get(agent)
|
|
273
|
+
if (buf === undefined) {
|
|
274
|
+
buf = { id: ++bufCounter, userText: '', assistantText: '', controller: null }
|
|
275
|
+
buffers.set(agent, buf)
|
|
276
|
+
void report(`buffer new #${buf.id} for agent=${agentConstructorName(agent)}`)
|
|
277
|
+
}
|
|
278
|
+
return buf
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 供 web 端拉取的最近一份建议;seq 递增,客户端凭它去重。 */
|
|
282
|
+
let latest = { seq: 0, suggestions: [], at: 0 }
|
|
283
|
+
/** 最近一次生成的上下文,供「换一批」重生成。 */
|
|
284
|
+
let lastContext = null
|
|
285
|
+
let seqCounter = 0
|
|
286
|
+
let pendingTimer = 0
|
|
287
|
+
|
|
288
|
+
/* -- 建议持久化(0.2.4):重启后打开会话还能拿到最近一轮的建议 ----- */
|
|
289
|
+
/** sessionId → { seq, suggestions, at },boot 时读回,成功生成时写入。 */
|
|
290
|
+
let persisted = {}
|
|
291
|
+
try {
|
|
292
|
+
persisted = JSON.parse(readFileSync(SUGGEST_FILE, 'utf8'))
|
|
293
|
+
if (!persisted || typeof persisted !== 'object') persisted = {}
|
|
294
|
+
for (const entry of Object.values(persisted)) {
|
|
295
|
+
if (Number.isInteger(entry?.seq) && entry.seq > seqCounter) seqCounter = entry.seq
|
|
296
|
+
}
|
|
297
|
+
say('info', `restored ${Object.keys(persisted).length} persisted suggestion set(s)`)
|
|
298
|
+
} catch { /* 首次或损坏:空表开始 */ }
|
|
299
|
+
|
|
300
|
+
function persistSuggestions(sessionId, seq, suggestions) {
|
|
301
|
+
if (!sessionId || !Array.isArray(suggestions) || suggestions.length === 0) return
|
|
302
|
+
persisted[sessionId] = { seq, suggestions, at: Date.now() }
|
|
303
|
+
const keys = Object.keys(persisted)
|
|
304
|
+
if (keys.length > 24) {
|
|
305
|
+
let oldest = null
|
|
306
|
+
let oldestAt = Infinity
|
|
307
|
+
for (const key of keys) {
|
|
308
|
+
if (persisted[key].at < oldestAt) { oldestAt = persisted[key].at; oldest = key }
|
|
309
|
+
}
|
|
310
|
+
if (oldest !== null) delete persisted[oldest]
|
|
311
|
+
}
|
|
312
|
+
void writeFile(SUGGEST_FILE, `${JSON.stringify(persisted)}\n`, 'utf8')
|
|
313
|
+
.catch(() => { /* 持久化失败不影响主流程 */ })
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Trae 逻辑:建议属于「每轮响应」——本轮没有新建议就清掉该会话的旧建议。 */
|
|
317
|
+
function clearSuggestions(sessionId) {
|
|
318
|
+
if (!sessionId || !(sessionId in persisted)) return
|
|
319
|
+
delete persisted[sessionId]
|
|
320
|
+
void writeFile(SUGGEST_FILE, `${JSON.stringify(persisted)}\n`, 'utf8')
|
|
321
|
+
.catch(() => { /* ignore */ })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const say = (level, message) => {
|
|
325
|
+
try {
|
|
326
|
+
console[level === 'error' ? 'error' : 'info'](`[dsh-followup] ${message}`)
|
|
327
|
+
} catch { /* ignore */ }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function singleTextOf(message) {
|
|
331
|
+
if (!message || message.role !== 'user') return null
|
|
332
|
+
// dsh 内部还有 goal / session-reference / notice 等来源的 user 消息,
|
|
333
|
+
// 不是用户在对话框里输入的内容,不进上下文。
|
|
334
|
+
if (message.source?.kind !== 'user') return null
|
|
335
|
+
if (!Array.isArray(message.content) || message.content.length !== 1) return null
|
|
336
|
+
const block = message.content[0]
|
|
337
|
+
return block && block.type === 'text' && typeof block.text === 'string' ? block.text : null
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** llm/stream 捕获用:请求 messages 里最后一个真实用户文本。 */
|
|
341
|
+
function singleTextOfLastUser(messages) {
|
|
342
|
+
if (!Array.isArray(messages)) return null
|
|
343
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
344
|
+
const text = singleTextOf(messages[i])
|
|
345
|
+
if (text !== null) return text
|
|
346
|
+
}
|
|
347
|
+
return null
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function defaultRoute() {
|
|
351
|
+
const defaults = ctx.get('agentDefaultModel')
|
|
352
|
+
const selection = defaults?.currentSelection?.()
|
|
353
|
+
return selection?.provider && selection?.model
|
|
354
|
+
? { provider: selection.provider, model: selection.model }
|
|
355
|
+
: null
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function configRoute() {
|
|
359
|
+
return live.provider && live.model ? { provider: live.provider, model: live.model } : null
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function resolveRoute(agent) {
|
|
363
|
+
const provider = agent?.options?.provider
|
|
364
|
+
const model = agent?.options?.model
|
|
365
|
+
return configRoute()
|
|
366
|
+
?? (provider && model ? { provider, model } : null)
|
|
367
|
+
?? defaultRoute()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function cancelPending(buf, reason) {
|
|
371
|
+
if (buf.controller) {
|
|
372
|
+
try { buf.controller.abort(new Error(reason)) } catch { /* ignore */ }
|
|
373
|
+
buf.controller = null
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* -- 事件监听:拼出对话尾部(旧架构回落通道) ---------------------- */
|
|
378
|
+
/* 新架构进程分裂下这些 agent/* 事件到不了插件进程(0.1.x 实证零事件),
|
|
379
|
+
保留是为了旧版本 dsh 仍然可用;新架构走上面的 llm/stream 捕获 + 日志回落。 */
|
|
380
|
+
|
|
381
|
+
ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
|
|
382
|
+
const buf = bufferOf(agent)
|
|
383
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
384
|
+
const text = singleTextOf(messages[i])
|
|
385
|
+
if (text !== null) { buf.userText = text; break }
|
|
386
|
+
}
|
|
387
|
+
buf.assistantText = ''
|
|
388
|
+
// 用户已经开始说下一句:上一份没生成的建议直接作废。
|
|
389
|
+
cancelPending(buf, 'superseded by next user message')
|
|
390
|
+
void report(`pre-step buf#${buf.id} agent=${agentConstructorName(agent)} msgs=${messages.length} userLen=${(buf.userText ?? '').length} userHead=${JSON.stringify((buf.userText ?? '').slice(0, 40))}`)
|
|
391
|
+
return next()
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
|
395
|
+
const buf = bufferOf(agent)
|
|
396
|
+
if (frame?.type && !seenFrameTypes.has(frame.type)) {
|
|
397
|
+
seenFrameTypes.add(frame.type)
|
|
398
|
+
void report(`stream frame-type first-seen: ${frame.type} agent=${agentConstructorName(agent)}`)
|
|
399
|
+
}
|
|
400
|
+
if (frame?.type === 'start') { buf.assistantText = ''; return }
|
|
401
|
+
const chunk = frame?.type === 'chunk' ? frame.chunk : null
|
|
402
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
403
|
+
buf.assistantText += chunk.text
|
|
404
|
+
// 超长回复只保留尾部,控制内存。
|
|
405
|
+
if (buf.assistantText.length > 20000) buf.assistantText = buf.assistantText.slice(-10000)
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
if (frame?.type === 'end') {
|
|
409
|
+
void report(`stream end buf#${buf.id} agent=${agentConstructorName(agent)} assistantLen=${(buf.assistantText ?? '').length}`)
|
|
410
|
+
}
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
ctx.on('agent/status', ({ agent, status }) => {
|
|
414
|
+
const buf = bufferOf(agent)
|
|
415
|
+
if (status !== 'idle') {
|
|
416
|
+
// 回合重新运转(工具链下一步):撤销缓冲中的生成计划。
|
|
417
|
+
cancelPending(buf, 'agent running again')
|
|
418
|
+
if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = 0 }
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
const userText = (buf.userText ?? '').trim()
|
|
422
|
+
const assistantText = (buf.assistantText ?? '').trim()
|
|
423
|
+
if (!userText || assistantText.length < live.minAssistantChars) {
|
|
424
|
+
void report(`idle skip buf#${buf.id} agent=${agentConstructorName(agent)} userLen=${userText.length} assistantLen=${assistantText.length} (need user>0 & assistant>=${live.minAssistantChars})`)
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
// 缓冲片刻:多步回合的步骤之间也会闪现 idle,期间回到 running 就作废。
|
|
428
|
+
if (pendingTimer) clearTimeout(pendingTimer)
|
|
429
|
+
pendingTimer = setTimeout(() => {
|
|
430
|
+
pendingTimer = 0
|
|
431
|
+
void generateFor(agent, buf, userText, assistantText)
|
|
432
|
+
}, Math.max(200, live.idleDelayMs))
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
/* -- 主通道:llm/stream 瀑布旁路捕获(0.2.3,借鉴 dsh-cost-meter) -- */
|
|
436
|
+
|
|
437
|
+
/** 深度标记:存在即「本插件已知会、不需要再捕获」的 llm 流。 */
|
|
438
|
+
const llmDepth = new AsyncLocalStorage()
|
|
439
|
+
|
|
440
|
+
/** 实时捕获的最近一轮:sessionId → { userText, assistantText, at }。 */
|
|
441
|
+
const turnCache = new Map()
|
|
442
|
+
const TURN_CACHE_MAX = 24
|
|
443
|
+
const TURN_CACHE_TTL_MS = 10 * 60_000
|
|
444
|
+
|
|
445
|
+
function cachedTurnOf(sessionId) {
|
|
446
|
+
const hit = turnCache.get(sessionId)
|
|
447
|
+
if (!hit) return null
|
|
448
|
+
if (Date.now() - hit.at > TURN_CACHE_TTL_MS) { turnCache.delete(sessionId); return null }
|
|
449
|
+
return hit
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function rememberTurn(sessionId, userText, assistantText) {
|
|
453
|
+
if (!sessionId || !userText || !assistantText || assistantText.trim().length === 0) return
|
|
454
|
+
const prev = turnCache.get(sessionId)
|
|
455
|
+
// 同一轮的多步工具链 / 重试续流 userText 相同:助手文本追加累积,
|
|
456
|
+
// 让「最终答案」落在缓存尾部(生成时 clampTail 取尾部正好命中)。
|
|
457
|
+
const merged = prev && prev.userText === userText
|
|
458
|
+
? `${prev.assistantText}\n\n${assistantText}`
|
|
459
|
+
: assistantText
|
|
460
|
+
turnCache.set(sessionId, { userText, assistantText: merged.slice(-20000), at: Date.now() })
|
|
461
|
+
if (turnCache.size > TURN_CACHE_MAX) {
|
|
462
|
+
let oldestKey = null
|
|
463
|
+
let oldestAt = Infinity
|
|
464
|
+
for (const [key, value] of turnCache) {
|
|
465
|
+
if (value.at < oldestAt) { oldestAt = value.at; oldestKey = key }
|
|
466
|
+
}
|
|
467
|
+
if (oldestKey !== null) turnCache.delete(oldestKey)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// llm/stream 捕获是否活着是 0.2.3 的关键真机问题(新架构进程分裂下
|
|
472
|
+
// 事件归属未实证),首帧与每次入缓存都留痕,一次对话即可从日志判定。
|
|
473
|
+
let llmStreamArmed = false
|
|
474
|
+
|
|
475
|
+
ctx.on('llm/stream', (options, next) => {
|
|
476
|
+
// 自己发起的建议生成调用 / 下游包装路由的嵌套调用:原样透传。
|
|
477
|
+
if (llmDepth.getStore() !== undefined) return next()
|
|
478
|
+
const downstream = next()
|
|
479
|
+
if (!llmStreamArmed) {
|
|
480
|
+
llmStreamArmed = true
|
|
481
|
+
void report('llm/stream first fire: capture channel is ALIVE in this process')
|
|
482
|
+
}
|
|
483
|
+
const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : ''
|
|
484
|
+
const userText = (singleTextOfLastUser(options?.messages) ?? '').trim()
|
|
485
|
+
let assistantText = ''
|
|
486
|
+
let downstreamIter = null
|
|
487
|
+
let completed = false
|
|
488
|
+
return (async function* followupObserve() {
|
|
489
|
+
try {
|
|
490
|
+
downstreamIter = downstream[Symbol.asyncIterator]()
|
|
491
|
+
for (;;) {
|
|
492
|
+
// 深度标记内拉取:包装路由适配器在消费上下文里再发起的
|
|
493
|
+
// ctx.llm.stream() 继承标记,内层监听器判定为已处理、透传。
|
|
494
|
+
const result = await llmDepth.run(1, () => downstreamIter.next())
|
|
495
|
+
if (result.done) break
|
|
496
|
+
const chunk = result.value
|
|
497
|
+
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
498
|
+
assistantText += chunk.text
|
|
499
|
+
if (assistantText.length > 20000) assistantText = assistantText.slice(-10000)
|
|
500
|
+
}
|
|
501
|
+
yield chunk
|
|
502
|
+
}
|
|
503
|
+
completed = true
|
|
504
|
+
} finally {
|
|
505
|
+
// 消费方提前中断(用户改下一句、错误终止)也要收尾;流失败时
|
|
506
|
+
// assistantText 不完整,通常过不了 minAssistantChars,无害。
|
|
507
|
+
if (!completed) {
|
|
508
|
+
try { await downstreamIter?.return?.() } catch { /* ignore */ }
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
rememberTurn(sessionId, userText, assistantText)
|
|
512
|
+
void report(`llm/stream captured session=${sessionId ? sessionId.slice(0, 50) : '(none)'} userLen=${userText.length} assistantLen=${assistantText.length} ${!completed ? '(interrupted)' : ''}`)
|
|
513
|
+
} catch { /* 缓存失败绝不影响主对话流 */ }
|
|
514
|
+
}
|
|
515
|
+
})()
|
|
516
|
+
}, { global: true })
|
|
517
|
+
|
|
518
|
+
/* -- 生成 --------------------------------------------------------- */
|
|
519
|
+
|
|
520
|
+
async function runGeneration(llm, route, userText, assistantText, signal) {
|
|
521
|
+
const count = Math.min(2, Math.max(1, Math.round(live.maxSuggestions)))
|
|
522
|
+
// 深度标记内发起:本次生成调用经过 llm/stream 瀑布时,监听器读到
|
|
523
|
+
// 标记即透传——建议生成的 prompt 不会被误缓存为对话轮。
|
|
524
|
+
// reasoningEffort low + maxTokens 2000:真机 04:50 实测 profile 默认
|
|
525
|
+
// high 推理把 600 上限全部烧光(raw len=0 → 无建议渲染)。
|
|
526
|
+
// 给 dsh-cost 打来源标记:建议生成的费用单独归类,不混进对话。
|
|
527
|
+
globalThis.__dshPluginCall = { source: 'followup', at: Date.now() }
|
|
528
|
+
|
|
529
|
+
const iterator = llmDepth.run(1, () => llm.stream({
|
|
530
|
+
provider: route.provider,
|
|
531
|
+
model: route.model,
|
|
532
|
+
reasoningEffort: 'low',
|
|
533
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: buildPrompt(userText, assistantText, count, config) }] }],
|
|
534
|
+
maxTokens: 2000,
|
|
535
|
+
signal,
|
|
536
|
+
})[Symbol.asyncIterator]())
|
|
537
|
+
let text = ''
|
|
538
|
+
try {
|
|
539
|
+
for (;;) {
|
|
540
|
+
const { done, value } = await iterator.next()
|
|
541
|
+
if (done) break
|
|
542
|
+
if (value?.type === 'text-delta' && typeof value.text === 'string') text += value.text
|
|
543
|
+
else if (value?.type === 'finish' && value.reason && (value.reason.kind === 'error' || value.reason.kind === 'aborted')) {
|
|
544
|
+
throw new Error(`followup llm ${value.reason.kind}`)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
} catch (err) {
|
|
548
|
+
// 中断底层流,失败后不在后台继续烧 token。
|
|
549
|
+
try {
|
|
550
|
+
const closing = iterator.return?.()
|
|
551
|
+
if (closing && typeof closing.catch === 'function') closing.catch(() => {})
|
|
552
|
+
} catch { /* ignore */ }
|
|
553
|
+
throw err
|
|
554
|
+
}
|
|
555
|
+
void report(`raw output len=${text.length}: ${JSON.stringify(text.slice(0, 300))}`)
|
|
556
|
+
return parseSuggestions(text, count)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function generateFor(agent, buf, userText, assistantText) {
|
|
560
|
+
const route = resolveRoute(agent)
|
|
561
|
+
if (!route) {
|
|
562
|
+
say('info', 'no model route available, skip')
|
|
563
|
+
void report('generate skip: no model route (config unset, agent.options empty, agentDefaultModel missing)')
|
|
564
|
+
return
|
|
565
|
+
}
|
|
566
|
+
const controller = new AbortController()
|
|
567
|
+
buf.controller = controller
|
|
568
|
+
const timer = setTimeout(() => controller.abort(new Error('followup timeout')), live.timeoutMs)
|
|
569
|
+
// 根作用域没声明 llm inject 时属性访问直接抛(真机 07:41 实证);
|
|
570
|
+
// 捕住降级——cache/日志路径不受影响。
|
|
571
|
+
let eventLlm = null
|
|
572
|
+
try { eventLlm = ctx.llm } catch { eventLlm = null }
|
|
573
|
+
if (eventLlm === null) {
|
|
574
|
+
void report('event-path skip: llm not injectable in this scope (cache path covers it)')
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
try {
|
|
578
|
+
void report(`generate start route=${route.provider}/${route.model} userLen=${userText.length} assistantLen=${assistantText.length}`)
|
|
579
|
+
const suggestions = await runGeneration(eventLlm, route, userText, assistantText, controller.signal)
|
|
580
|
+
if (suggestions.length) {
|
|
581
|
+
lastContext = { route, userText, assistantText }
|
|
582
|
+
latest = { seq: ++seqCounter, suggestions, at: Date.now() }
|
|
583
|
+
let eventSessionId = ''
|
|
584
|
+
try { eventSessionId = agent?.session?.id ?? '' } catch { eventSessionId = '' }
|
|
585
|
+
persistSuggestions(eventSessionId, latest.seq, suggestions)
|
|
586
|
+
say('info', `turn ${latest.seq}: ${suggestions.length} suggestions`)
|
|
587
|
+
void report(`generate ok seq=${latest.seq} count=${suggestions.length} ${JSON.stringify(suggestions)}`)
|
|
588
|
+
} else {
|
|
589
|
+
void report('generate empty: model returned no parseable suggestions')
|
|
590
|
+
}
|
|
591
|
+
} catch (err) {
|
|
592
|
+
if (!/aborted|superseded|timeout/i.test(String(err?.message ?? err))) {
|
|
593
|
+
say('error', `generate failed: ${err?.message ?? err}`)
|
|
594
|
+
void report(`generate failed: ${String(err?.message ?? err).slice(0, 300)}`)
|
|
595
|
+
} else {
|
|
596
|
+
void report(`generate aborted: ${String(err?.message ?? err).slice(0, 120)}`)
|
|
597
|
+
}
|
|
598
|
+
} finally {
|
|
599
|
+
clearTimeout(timer)
|
|
600
|
+
if (buf.controller === controller) buf.controller = null
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/* -- web 端点 ------------------------------------------------------ */
|
|
605
|
+
|
|
606
|
+
// 可选注入:CLI / TUI 等没有 webServer 的宿主照常加载,只是没有 chips。
|
|
607
|
+
// llm 必须在此作用域声明——cordis 的属性访问按作用域校验 inject,
|
|
608
|
+
// 根作用域的静态 inject 不传导进嵌套作用域(真机实测报
|
|
609
|
+
// cannot get property "llm" without inject)。
|
|
610
|
+
ctx.inject(['webServer', 'llm'], (webCtx) => {
|
|
611
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
612
|
+
kind: 'prefix',
|
|
613
|
+
path: '/api/dsh-followup',
|
|
614
|
+
handler: async (req, res) => {
|
|
615
|
+
const send = (status, body) => {
|
|
616
|
+
res.statusCode = status
|
|
617
|
+
res.setHeader('content-type', 'application/json')
|
|
618
|
+
res.end(JSON.stringify(body))
|
|
619
|
+
}
|
|
620
|
+
const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-followup/, '')
|
|
621
|
+
if (path === '/settings' && req.method === 'GET') {
|
|
622
|
+
send(200, { ...live })
|
|
623
|
+
return
|
|
624
|
+
}
|
|
625
|
+
// 运行时改参数:白名单字段,provider/model 不在其中(改了要重建路由)。
|
|
626
|
+
if (path === '/settings' && req.method === 'POST') {
|
|
627
|
+
let raw = ''
|
|
628
|
+
for await (const chunk of req) raw += chunk
|
|
629
|
+
let body
|
|
630
|
+
try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
|
|
631
|
+
const numeric = { maxSuggestions: [1, 3], idleDelayMs: [200, 10000], timeoutMs: [1000, 120000], minAssistantChars: [0, 500], userChars: [100, 4000], assistantChars: [100, 8000] }
|
|
632
|
+
for (const [key, [min, max]] of Object.entries(numeric)) {
|
|
633
|
+
if (body[key] === undefined) continue
|
|
634
|
+
const value = Number(body[key])
|
|
635
|
+
if (!Number.isFinite(value)) continue
|
|
636
|
+
live[key] = Math.min(max, Math.max(min, Math.round(value)))
|
|
637
|
+
}
|
|
638
|
+
persistSettings()
|
|
639
|
+
send(200, { ...live })
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
// 客户端在每次「生成中 → 空闲」后拉取;seq 相同就静默忽略。
|
|
645
|
+
// Trae 逻辑:建议随会话持久化、无时间过期,只被下一轮替换或清除。
|
|
646
|
+
if (path === '/latest' && req.method === 'GET') {
|
|
647
|
+
const qs = new URL(req.url ?? '', 'http://local').searchParams
|
|
648
|
+
const wantSession = qs.get('sessionId') ?? ''
|
|
649
|
+
const entry = wantSession ? persisted[wantSession] : undefined
|
|
650
|
+
if (entry && Array.isArray(entry.suggestions) && entry.suggestions.length > 0) {
|
|
651
|
+
send(200, entry)
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
send(200, latest)
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* 生成建议(0.2.3 双通道):优先用 llm/stream 实时捕获的对话轮
|
|
660
|
+
* (零文件 IO、无解析时延),未命中才回落日志驱动(0.2.0 引入,
|
|
661
|
+
* 解压 session.v4.jsonl.zstd 直读)。
|
|
662
|
+
*/
|
|
663
|
+
if ((path === '/generate' || path === '/refresh') && req.method === 'POST') {
|
|
664
|
+
let body = ''
|
|
665
|
+
for await (const chunk of req) body += chunk
|
|
666
|
+
let sessionId = ''
|
|
667
|
+
try { sessionId = JSON.parse(body).sessionId ?? '' } catch { sessionId = '' }
|
|
668
|
+
if (!sessionId) { send(400, { error: 'missing sessionId' }); return }
|
|
669
|
+
const cached = cachedTurnOf(sessionId)
|
|
670
|
+
const turn = cached
|
|
671
|
+
? { userText: cached.userText, assistantText: cached.assistantText }
|
|
672
|
+
: await lastTurnFromLog(sessionId)
|
|
673
|
+
if (!turn) {
|
|
674
|
+
void report(`generate(${cached ? 'cache' : 'log'}) ${path}: no usable turn in session ${String(sessionId).slice(0, 50)}`)
|
|
675
|
+
// Trae 逻辑:建议属于每轮响应——本轮拿不到可跟进内容即清掉旧建议
|
|
676
|
+
clearSuggestions(String(sessionId))
|
|
677
|
+
send(409, { error: 'no-turn' })
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
const route = configRoute() ?? defaultRoute() ?? await patchDefaultRoute()
|
|
681
|
+
if (!route) {
|
|
682
|
+
void report(`generate(${cached ? 'cache' : 'log'}): no route (config/agentDefaultModel/patch all missing)`)
|
|
683
|
+
send(503, { error: 'no-route' })
|
|
684
|
+
return
|
|
685
|
+
}
|
|
686
|
+
const source = cached ? 'cache' : 'log'
|
|
687
|
+
const controller = new AbortController()
|
|
688
|
+
const timer = setTimeout(() => controller.abort(new Error('followup timeout')), live.timeoutMs)
|
|
689
|
+
try {
|
|
690
|
+
void report(`generate(${source}) start route=${route.provider}/${route.model} userLen=${turn.userText.length} assistantLen=${turn.assistantText.length}`)
|
|
691
|
+
const suggestions = await runGeneration(webCtx.llm, route, turn.userText, turn.assistantText, controller.signal)
|
|
692
|
+
if (!suggestions.length) {
|
|
693
|
+
void report(`generate(${source}) empty`)
|
|
694
|
+
// 同上:本轮生成失败/为空,旧建议不再属于最新一轮
|
|
695
|
+
clearSuggestions(String(sessionId))
|
|
696
|
+
send(502, { error: 'generate-failed' })
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
lastContext = { route, ...turn }
|
|
700
|
+
latest = { seq: ++seqCounter, suggestions, at: Date.now() }
|
|
701
|
+
persistSuggestions(String(sessionId), latest.seq, suggestions)
|
|
702
|
+
void report(`generate(${source}) ok seq=${latest.seq} ${JSON.stringify(suggestions)}`)
|
|
703
|
+
send(200, latest)
|
|
704
|
+
} catch (err) {
|
|
705
|
+
void report(`generate(${source}) failed: ${String(err?.message ?? err).slice(0, 200)}`)
|
|
706
|
+
send(502, { error: 'generate-failed' })
|
|
707
|
+
} finally {
|
|
708
|
+
clearTimeout(timer)
|
|
709
|
+
}
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
send(404, { error: 'not-found' })
|
|
714
|
+
},
|
|
715
|
+
}), 'dsh-followup: /api/dsh-followup/*')
|
|
716
|
+
})
|
|
717
|
+
}
|
|
718
|
+
|