dsh-topic-graph 0.0.0-stage → 0.6.41
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/README.en.md +266 -0
- package/README.md +264 -2
- package/cordis.patch.yml +36 -0
- package/icon.svg +25 -0
- package/lib/client.js +3502 -0
- package/lib/index.js +2959 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +62 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,2959 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-topic-graph — 会话话题图谱(Host 半)
|
|
3
|
+
*
|
|
4
|
+
* 把每个 dsh 会话按「轮次 turn」切分,再让 DeepSeek 把轮次增量聚合成
|
|
5
|
+
* 「话题 topic」与「阶段 stage」:
|
|
6
|
+
* - 轮次:一次 turn/start → turn/end 之间的一问一答(可含多次工具调用)。
|
|
7
|
+
* - 话题:若干连续轮次组成的一条工作线;话题可嵌套子话题(由 AI 判断)。
|
|
8
|
+
* - 阶段:若干相邻话题组成的工作阶段;阶段与话题都有 AI 取的名字。
|
|
9
|
+
*
|
|
10
|
+
* 数据来源:
|
|
11
|
+
* 1. 历史会话:ctx.sessionQuery.readSession(id) 一次性重放事件日志;
|
|
12
|
+
* 2. 实时会话:ctx.on('session/event') 增量折叠,按 seq 去重。
|
|
13
|
+
* 折叠出的轮次统计(token 用量、工具调用、Agent 耗时)是唯一事实缓存,
|
|
14
|
+
* AI 只负责「轮次 → 话题/阶段」的结构划分,结构持久化在
|
|
15
|
+
* <DSH_HOME>/data/topic-graph/<sessionId>.json(原子写、防抖)。
|
|
16
|
+
*
|
|
17
|
+
* 增量切分:每次只把「尚未切分的轮次摘要 + 已有话题/阶段结构」发给模型,
|
|
18
|
+
* 已有轮次的正文不会重复输入;模型输出更新后的完整结构(很小),
|
|
19
|
+
* Host 校验轮次覆盖、父子关系与阶段归属后落盘。
|
|
20
|
+
*
|
|
21
|
+
* 浏览器半通过 webServer 路由取数:
|
|
22
|
+
* GET /plugins/topic-graph/ping
|
|
23
|
+
* GET /plugins/topic-graph/sessions
|
|
24
|
+
* GET /plugins/topic-graph/graph?sessionId=&version=
|
|
25
|
+
* POST /plugins/topic-graph/segment { sessionId?, full? }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readFile, writeFile, mkdir, readdir, rename, unlink } from 'node:fs/promises'
|
|
29
|
+
import { join } from 'node:path'
|
|
30
|
+
import os from 'node:os'
|
|
31
|
+
|
|
32
|
+
export const name = 'dsh-topic-graph'
|
|
33
|
+
export const inject = ['webServer', 'sessionQuery']
|
|
34
|
+
|
|
35
|
+
// ── 常量 ────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const ROUTE_BASE = '/plugins/topic-graph'
|
|
38
|
+
const DATA_DIR_NAME = 'topic-graph'
|
|
39
|
+
const SAVE_DEBOUNCE_MS = 500
|
|
40
|
+
const TITLE_TTL_MS = 60000
|
|
41
|
+
const SESSIONS_TTL_MS = 10000
|
|
42
|
+
const SESSIONS_LIMIT = 150
|
|
43
|
+
const TOPIC_TITLE_MAX = 16
|
|
44
|
+
const STAGE_TITLE_MAX = 10
|
|
45
|
+
const SUMMARY_MAX = 60
|
|
46
|
+
const TURN_PROMPT_MAX = 600
|
|
47
|
+
const TURN_ASSISTANT_MAX = 400
|
|
48
|
+
// /graph 里每轮正文只发预览(折叠全文保留在 Host 内存供 AI;完整正文在聊天里)
|
|
49
|
+
const TURN_PROMPT_PREVIEW = 200
|
|
50
|
+
const TURN_ASSISTANT_PREVIEW = 160
|
|
51
|
+
// 单轮 AI 分析:轨迹重建的截断与上限
|
|
52
|
+
const TURN_TRACE_MAX_ITEMS = 24
|
|
53
|
+
const TURN_TRACE_ARGS_MAX = 160
|
|
54
|
+
const TURN_TRACE_RESULT_MAX = 360
|
|
55
|
+
const TURN_TRACE_TEXT_MAX = 360
|
|
56
|
+
const TURN_ANALYSIS_STEPS_MAX = 8
|
|
57
|
+
// 内存中的会话图 LRU 上限(超出后写回磁盘并淘汰,磁盘副本仍是底稿)
|
|
58
|
+
const GRAPHS_MAX = 40
|
|
59
|
+
const MODEL_FILE = '.model.json'
|
|
60
|
+
const TITLE_CACHE_MAX = 2000
|
|
61
|
+
|
|
62
|
+
/** 用户直接输入的消息 source.kind(其余是 dsh 注入的上下文 / 审批通知等)。 */
|
|
63
|
+
const DIRECT_USER_KINDS = new Set(['user', 'goal'])
|
|
64
|
+
|
|
65
|
+
// ── 通用小工具 ──────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function nowIso() { return new Date().toISOString() }
|
|
68
|
+
|
|
69
|
+
/** 会话 id 规范化:dsh 里同时存在 `session-<uuid>` 与裸 `<uuid>` 两种写法。 */
|
|
70
|
+
function normSessionId(id) {
|
|
71
|
+
return String(id || '').replace(/^session-/, '')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 候选 id 形式(读会话日志时逐个尝试)。 */
|
|
75
|
+
function sessionIdCandidates(id) {
|
|
76
|
+
const raw = String(id || '')
|
|
77
|
+
const bare = normSessionId(raw)
|
|
78
|
+
const out = []
|
|
79
|
+
for (const candidate of [raw, bare, `session-${bare}`]) {
|
|
80
|
+
if (candidate && !out.includes(candidate)) out.push(candidate)
|
|
81
|
+
}
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function clampInt(value, fallback, min, max) {
|
|
86
|
+
const n = Number(value)
|
|
87
|
+
if (!Number.isFinite(n)) return fallback
|
|
88
|
+
return Math.max(min, Math.min(max, Math.floor(n)))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 压缩空白并截断到 max 个字符(按 code unit,够用且不拆代理对风险低)。 */
|
|
92
|
+
function summarize(text, max = 80) {
|
|
93
|
+
const cleaned = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
94
|
+
const chars = Array.from(cleaned)
|
|
95
|
+
if (chars.length <= max) return cleaned
|
|
96
|
+
return `${chars.slice(0, max).join('')}…`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** dsh 事件 time → 毫秒时间戳;拿不到返回 0。 */
|
|
100
|
+
function normalizeTime(value) {
|
|
101
|
+
let ms = 0
|
|
102
|
+
if (typeof value === 'number' && Number.isFinite(value)) ms = value
|
|
103
|
+
else if (typeof value === 'string' && value) {
|
|
104
|
+
if (/^\d+$/.test(value)) ms = Number(value)
|
|
105
|
+
else {
|
|
106
|
+
const d = new Date(value)
|
|
107
|
+
return Number.isNaN(d.getTime()) ? 0 : d.getTime()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!ms) return 0
|
|
111
|
+
if (ms < 1e12) ms *= 1000 // 10 位秒级补成毫秒
|
|
112
|
+
return ms
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 从 content blocks 提取文本(可限定 block 类型,按字符上限截断)。 */
|
|
116
|
+
function extractBlocksText(content, max = 400, types = null) {
|
|
117
|
+
if (!Array.isArray(content)) return ''
|
|
118
|
+
let out = ''
|
|
119
|
+
for (const block of content) {
|
|
120
|
+
if (!block || typeof block !== 'object') continue
|
|
121
|
+
if (block.type !== 'text' && block.type !== 'reasoning') continue
|
|
122
|
+
if (types && !types.includes(block.type)) continue
|
|
123
|
+
if (typeof block.text === 'string' && block.text.length > 0) {
|
|
124
|
+
out += (out ? '\n' : '') + block.text
|
|
125
|
+
if (out.length >= max) break
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return summarize(out, max)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 统计 reasoning 文本长度(不保留正文,省内存)。 */
|
|
132
|
+
function countBlocksText(content, types = ['reasoning']) {
|
|
133
|
+
if (!Array.isArray(content)) return 0
|
|
134
|
+
let total = 0
|
|
135
|
+
for (const block of content) {
|
|
136
|
+
if (!block || typeof block !== 'object') continue
|
|
137
|
+
if (!types.includes(block.type)) continue
|
|
138
|
+
if (typeof block.text === 'string') total += block.text.length
|
|
139
|
+
}
|
|
140
|
+
return total
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function emptyTokens() {
|
|
144
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function addUsage(turn, usage) {
|
|
148
|
+
if (!usage || typeof usage !== 'object') return
|
|
149
|
+
const u = turn.tokens
|
|
150
|
+
const input = numOr0(usage.inputTokens)
|
|
151
|
+
const output = numOr0(usage.outputTokens)
|
|
152
|
+
const cacheRead = numOr0(usage.cacheReadTokens)
|
|
153
|
+
const cacheWrite = numOr0(usage.cacheWriteTokens)
|
|
154
|
+
const reasoning = numOr0(usage.reasoningTokens)
|
|
155
|
+
u.input += input
|
|
156
|
+
u.output += output
|
|
157
|
+
u.cacheRead += cacheRead
|
|
158
|
+
u.cacheWrite += cacheWrite
|
|
159
|
+
u.reasoning += reasoning
|
|
160
|
+
const explicit = numOr0(usage.totalTokens)
|
|
161
|
+
u.total += explicit > 0 ? explicit : input + output + cacheRead + cacheWrite
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 把一次 LLM 调用的用量累进图级成本统计。 */
|
|
165
|
+
function accumulateAiUsage(g, usage) {
|
|
166
|
+
if (!g || !usage) return
|
|
167
|
+
const t = g.aiTokens
|
|
168
|
+
t.input += numOr0(usage.input)
|
|
169
|
+
t.output += numOr0(usage.output)
|
|
170
|
+
t.cacheRead += numOr0(usage.cacheRead)
|
|
171
|
+
t.cacheWrite += numOr0(usage.cacheWrite)
|
|
172
|
+
t.total += numOr0(usage.total)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function numOr0(value) {
|
|
176
|
+
const n = Number(value)
|
|
177
|
+
return Number.isFinite(n) && n > 0 ? n : 0
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isSubagentHeader(header) {
|
|
181
|
+
if (!header || typeof header !== 'object') return false
|
|
182
|
+
if (header.origin === 'subagent') return true
|
|
183
|
+
if (typeof header.parentSession === 'string' && header.parentSession
|
|
184
|
+
&& Number(header.delegationDepth) > 0) return true
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isSubagentSession(session) {
|
|
189
|
+
if (!session || typeof session !== 'object') return false
|
|
190
|
+
if (session.origin === 'subagent') return true
|
|
191
|
+
return isSubagentHeader(session.header)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── HTTP 小工具 ─────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
function sendJson(res, code, value) {
|
|
197
|
+
const body = JSON.stringify(value)
|
|
198
|
+
res.writeHead(code, {
|
|
199
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
200
|
+
'Cache-Control': 'no-store',
|
|
201
|
+
'Content-Length': Buffer.byteLength(body),
|
|
202
|
+
})
|
|
203
|
+
res.end(body)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function readJsonBody(req, limit = 1024 * 1024) {
|
|
207
|
+
const chunks = []
|
|
208
|
+
let size = 0
|
|
209
|
+
for await (const chunk of req) {
|
|
210
|
+
size += chunk.length
|
|
211
|
+
if (size > limit) throw new Error('request body too large')
|
|
212
|
+
chunks.push(chunk)
|
|
213
|
+
}
|
|
214
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim()
|
|
215
|
+
if (!raw) return {}
|
|
216
|
+
return JSON.parse(raw)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── 容错 JSON 解析(模型输出可能带围栏 / 截断) ─────────────────────────────
|
|
220
|
+
|
|
221
|
+
function repairTruncatedJson(text) {
|
|
222
|
+
const start = text.indexOf('{')
|
|
223
|
+
if (start === -1) return null
|
|
224
|
+
const slice = text.slice(start)
|
|
225
|
+
let out = ''
|
|
226
|
+
let inString = false
|
|
227
|
+
const stack = []
|
|
228
|
+
for (let i = 0; i < slice.length; i += 1) {
|
|
229
|
+
const ch = slice[i]
|
|
230
|
+
out += ch
|
|
231
|
+
if (inString) {
|
|
232
|
+
if (ch === '\\') { out += slice[i + 1] || ''; i += 1; continue }
|
|
233
|
+
if (ch === '"') inString = false
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
if (ch === '"') { inString = true; continue }
|
|
237
|
+
if (ch === '{' || ch === '[') stack.push(ch === '{' ? '}' : ']')
|
|
238
|
+
else if (ch === '}' || ch === ']') { if (stack.length > 0) stack.pop() }
|
|
239
|
+
}
|
|
240
|
+
// 截断点可能落在字符串 / 转义符 / 半截键上:先清理再补括号
|
|
241
|
+
if (inString) {
|
|
242
|
+
out = out.replace(/\\+$/, '')
|
|
243
|
+
out += '"'
|
|
244
|
+
}
|
|
245
|
+
out = out.replace(/,\s*"[^"]*"\s*:\s*$/, '') // 半截的 "key":
|
|
246
|
+
out = out.replace(/,\s*$/, '')
|
|
247
|
+
while (stack.length > 0) out += stack.pop()
|
|
248
|
+
try {
|
|
249
|
+
JSON.parse(out)
|
|
250
|
+
return out
|
|
251
|
+
} catch {
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function parseGraphJson(raw) {
|
|
257
|
+
if (typeof raw !== 'string' || !raw.trim()) return null
|
|
258
|
+
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/)
|
|
259
|
+
const candidates = []
|
|
260
|
+
if (fenced && fenced[1]) candidates.push(fenced[1])
|
|
261
|
+
candidates.push(raw)
|
|
262
|
+
for (const candidate of candidates) {
|
|
263
|
+
const start = candidate.indexOf('{')
|
|
264
|
+
const end = candidate.lastIndexOf('}')
|
|
265
|
+
if (start !== -1 && end > start) {
|
|
266
|
+
try {
|
|
267
|
+
const parsed = JSON.parse(candidate.slice(start, end + 1))
|
|
268
|
+
if (parsed && (Array.isArray(parsed.topics) || Array.isArray(parsed.stages) || Array.isArray(parsed.relations))) return parsed
|
|
269
|
+
} catch { /* try next */ }
|
|
270
|
+
}
|
|
271
|
+
const repaired = repairTruncatedJson(candidate)
|
|
272
|
+
if (repaired) {
|
|
273
|
+
try {
|
|
274
|
+
const parsed = JSON.parse(repaired)
|
|
275
|
+
if (parsed && (Array.isArray(parsed.topics) || Array.isArray(parsed.stages) || Array.isArray(parsed.relations))) return parsed
|
|
276
|
+
} catch { /* try next */ }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return null
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── LLM ─────────────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
/** 读取未在 inject 中声明的可选服务:直接属性访问会被 Cordis 的 inject 检查拦截。 */
|
|
285
|
+
function getHostService(ctx, name) {
|
|
286
|
+
try {
|
|
287
|
+
if (typeof ctx.get === 'function') return ctx.get(name)
|
|
288
|
+
} catch { /* fall through */ }
|
|
289
|
+
try {
|
|
290
|
+
return ctx[name]
|
|
291
|
+
} catch {
|
|
292
|
+
return undefined
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** 解析 dsh 当前配置的模型路由;config 优先,否则探测。 */
|
|
297
|
+
async function resolveLlmTarget(llm, cfg) {
|
|
298
|
+
if (!llm || typeof llm.stream !== 'function') throw new Error('llm service unavailable')
|
|
299
|
+
let providers = []
|
|
300
|
+
try {
|
|
301
|
+
const listed = llm.listProviders()
|
|
302
|
+
providers = (listed && typeof listed.then === 'function') ? await listed : listed
|
|
303
|
+
} catch {
|
|
304
|
+
providers = []
|
|
305
|
+
}
|
|
306
|
+
providers = Array.isArray(providers) ? providers : []
|
|
307
|
+
const provider = (typeof cfg.provider === 'string' && cfg.provider) || providers[0]?.id
|
|
308
|
+
if (!provider) throw new Error('no llm provider available')
|
|
309
|
+
if (typeof cfg.model === 'string' && cfg.model) return { provider, model: cfg.model }
|
|
310
|
+
try {
|
|
311
|
+
const models = await llm.listModels(provider)
|
|
312
|
+
const fallback = Array.isArray(models) ? models[0]?.id : undefined
|
|
313
|
+
if (fallback) return { provider, model: fallback }
|
|
314
|
+
} catch { /* fall through */ }
|
|
315
|
+
throw new Error(`no model resolved for provider "${provider}"`)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 调一次模型,拼接 text-delta 输出。 */
|
|
319
|
+
/** 从 llm finish 失败事实构造带 code/status 的 Error(供上层做自适应重试)。 */
|
|
320
|
+
function llmFailureError(failure, fallbackMessage) {
|
|
321
|
+
const message = failure && typeof failure === 'object' && typeof failure.message === 'string' && failure.message
|
|
322
|
+
? failure.message
|
|
323
|
+
: (fallbackMessage || 'llm request failed')
|
|
324
|
+
const error = new Error(message)
|
|
325
|
+
if (failure && typeof failure === 'object') {
|
|
326
|
+
if (typeof failure.code === 'string') error.code = failure.code
|
|
327
|
+
if (typeof failure.status === 'number') error.status = failure.status
|
|
328
|
+
}
|
|
329
|
+
return error
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** 把模型错误压成一行可展示文本(带 code/status,便于定位 400 之类的问题)。 */
|
|
333
|
+
function describeLlmError(error) {
|
|
334
|
+
const code = typeof error?.code === 'string' ? error.code : ''
|
|
335
|
+
const status = typeof error?.status === 'number' ? `HTTP ${error.status}` : ''
|
|
336
|
+
if (code === 'PARSE_OUTPUT') {
|
|
337
|
+
return '模型输出被截断或不是合法 JSON(已自动用更大输出上限重试;可稍后重试/全部重切)'
|
|
338
|
+
}
|
|
339
|
+
const message = summarize(String(error?.message || error || 'AI 请求失败'), 180)
|
|
340
|
+
return [code, status, message].filter(Boolean).join(' · ')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** 调一次模型,拼接 text-delta 输出。effortOverride 传 null 表示使用适配器默认推理档位。 */
|
|
344
|
+
async function llmText(ctx, cfg, system, userText, maxTokens, effortOverride) {
|
|
345
|
+
const llm = getHostService(ctx, 'llm')
|
|
346
|
+
const { provider, model } = await resolveLlmTarget(llm, cfg)
|
|
347
|
+
// 输出上限不能超过模型自己的 maxTokens,否则部分 provider 会直接 400
|
|
348
|
+
let cappedMaxTokens = maxTokens
|
|
349
|
+
try {
|
|
350
|
+
if (typeof llm.resolveModelInfo === 'function') {
|
|
351
|
+
const info = await llm.resolveModelInfo(provider, model)
|
|
352
|
+
const modelMax = Number(info?.maxTokens)
|
|
353
|
+
if (Number.isFinite(modelMax) && modelMax > 0) cappedMaxTokens = Math.min(cappedMaxTokens, modelMax)
|
|
354
|
+
}
|
|
355
|
+
} catch { /* 模型信息取不到就按原值请求 */ }
|
|
356
|
+
const effort = effortOverride === undefined ? (cfg.reasoningEffort || 'off') : effortOverride
|
|
357
|
+
let text = ''
|
|
358
|
+
let failure = null
|
|
359
|
+
const usage = emptyTokens()
|
|
360
|
+
for await (const chunk of llm.stream({
|
|
361
|
+
provider,
|
|
362
|
+
model,
|
|
363
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: userText }] }],
|
|
364
|
+
system,
|
|
365
|
+
temperature: 0.2,
|
|
366
|
+
maxTokens: cappedMaxTokens,
|
|
367
|
+
...(effort ? { reasoningEffort: effort } : {}),
|
|
368
|
+
})) {
|
|
369
|
+
if (!chunk || typeof chunk !== 'object') continue
|
|
370
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
|
|
371
|
+
else if (chunk.type === 'usage' && chunk.usage && typeof chunk.usage === 'object') {
|
|
372
|
+
const u = chunk.usage
|
|
373
|
+
usage.input += numOr0(u.inputTokens)
|
|
374
|
+
usage.output += numOr0(u.outputTokens)
|
|
375
|
+
usage.cacheRead += numOr0(u.cacheReadTokens)
|
|
376
|
+
usage.cacheWrite += numOr0(u.cacheWriteTokens)
|
|
377
|
+
const explicit = numOr0(u.totalTokens)
|
|
378
|
+
usage.total += explicit > 0 ? explicit : numOr0(u.inputTokens) + numOr0(u.outputTokens) + numOr0(u.cacheReadTokens) + numOr0(u.cacheWriteTokens)
|
|
379
|
+
} else if (chunk.type === 'finish') {
|
|
380
|
+
const reason = chunk.reason
|
|
381
|
+
if (reason && (reason.kind === 'error' || reason.kind === 'aborted')) {
|
|
382
|
+
failure = reason.failure || { message: reason.kind }
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (failure) throw llmFailureError(failure, 'llm request failed')
|
|
387
|
+
return { text, provider, model, maxTokens: cappedMaxTokens, usage }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 模型报 400 / INVALID_REQUEST 时换适配器默认推理档位再试一次:
|
|
392
|
+
* 部分模型对显式 thinking 开关比较挑剔,去掉显式档位能避开这类 400。
|
|
393
|
+
*/
|
|
394
|
+
function shouldRetryWithDefaultEffort(error) {
|
|
395
|
+
const code = String(error?.code || '').toUpperCase()
|
|
396
|
+
const status = Number(error?.status) || 0
|
|
397
|
+
return status === 400
|
|
398
|
+
|| code === 'INVALID_REQUEST'
|
|
399
|
+
|| code === 'UNSUPPORTED_REASONING_EFFORT'
|
|
400
|
+
|| /request failed \(400\)/i.test(String(error?.message || ''))
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── 提示词 ──────────────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
const SEGMENT_SYSTEM_PROMPT = [
|
|
406
|
+
'你是一个「会话话题图谱切分器」。用户与 AI 在 DeepSeek Harness 中协作,每一轮(轮次)是一问一答,可能包含多次工具调用。',
|
|
407
|
+
'你的任务:把轮次按工作主题聚合成「话题 topic」,把话题按推进顺序聚合成「阶段 stage」,并为每个话题、阶段命名。',
|
|
408
|
+
'【切分规则】',
|
|
409
|
+
'1. 一个话题 = 一条独立的工作意图/任务线,聚合若干连续轮次;通常 1~6 轮,长任务可以更多。',
|
|
410
|
+
'2. 子话题:只有当一个话题内部出现明显独立、值得单独查看的子任务(如大任务下的子模块实现、某个错误的专项排查)时,才建立子话题:子话题的 parentId 指向父话题,轮次范围落在父话题范围内。没有必要时不要建子话题(宁可扁平)。子话题最多 3 层(根话题算第 1 层),不要超过 3 层。',
|
|
411
|
+
'3. 阶段 = 工作推进的大阶段(如:需求澄清、方案设计、实现、排错、验收)。一个阶段聚合若干按时间相邻的话题;通常 1~4 个阶段,长会话可以更多。每个话题必须属于恰好一个阶段(子话题跟随父话题)。',
|
|
412
|
+
'4. 所有轮次必须恰好被一个话题覆盖,不允许遗漏或重复;父子话题嵌套时,子话题覆盖它自己的轮次,父话题覆盖其余轮次。',
|
|
413
|
+
'5. 已有话题/阶段的 id 与命名尽量保留,只在内容确实变化时修改;新轮次只能并入已有话题、新建话题或新建子话题,不要重排历史。',
|
|
414
|
+
'6. 命名要求:话题标题 ≤12 个汉字(英文 ≤24 字符),必须具体描述工作内容,禁止“工作”“任务”“对话”“进行中”等泛化词;阶段标题 ≤8 字;摘要 ≤40 字。输出语言与输入记录的主要语言一致。',
|
|
415
|
+
'7. 关系边:如果两个话题之间存在明显语义关系(依赖 / 延续 / 对比 / 修复 / 验证 / 回访 / 同一目标),可在 relations 里输出一条有向关系边;label 用 2~6 个字的动词短语。相邻顺序关系(next)与阶段归属(belongTo)由系统自动生成,不要重复输出;没有信息量的关系不要输出,最多 12 条。',
|
|
416
|
+
'8. 只输出纯 JSON,禁止 markdown 代码块、注释或任何解释文字。',
|
|
417
|
+
'【输出格式】',
|
|
418
|
+
'{"topics":[{"id":"已有话题id(新话题省略)","tempId":"新话题的临时id","title":"话题名","summary":"一句话摘要","parentId":"父话题id或null","turnStart":1,"turnEnd":3,"status":"active或done"}],"stages":[{"id":"已有阶段id(新阶段省略)","tempId":"新阶段临时id","title":"阶段名","summary":"阶段摘要","topicRefs":["话题id或tempId"]}],"relations":[{"from":"话题id或tempId","to":"话题id或tempId","label":"关系词"}]}',
|
|
419
|
+
'注意:topics 必须输出全部话题(已有 + 新增),stages 必须输出全部阶段(已有 + 新增),relations 只需输出确实存在的关系(可省略);已有 id 原样保留,topicRefs / relations 引用话题的 id 或 tempId。',
|
|
420
|
+
].join('\n')
|
|
421
|
+
|
|
422
|
+
function renderTurnLine(turn) {
|
|
423
|
+
const tools = Object.entries(turn.toolCalls.byName || {})
|
|
424
|
+
.sort((a, b) => b[1] - a[1])
|
|
425
|
+
.map(([toolName, count]) => `${toolName}×${count}`)
|
|
426
|
+
.join(', ')
|
|
427
|
+
const parts = [`[轮${turn.turn}]`, `用户: ${summarize(turn.prompt || '(无文本)', 240)}`]
|
|
428
|
+
if (turn.assistant) parts.push(`AI: ${summarize(turn.assistant, 200)}`)
|
|
429
|
+
if (tools) parts.push(`工具: ${tools}`)
|
|
430
|
+
if (turn.tokens.total > 0) parts.push(`tokens≈${turn.tokens.total}`)
|
|
431
|
+
return parts.join(' | ')
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function topicDepth(topicsById, topic, guard = 0) {
|
|
435
|
+
if (!topic || !topic.parentId || guard > 20) return 0
|
|
436
|
+
const parent = topicsById.get(topic.parentId)
|
|
437
|
+
if (!parent) return 0
|
|
438
|
+
return 1 + topicDepth(topicsById, parent, guard + 1)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function renderExistingStructure(g) {
|
|
442
|
+
if (!g.topics.length && !g.stages.length) return ''
|
|
443
|
+
const lines = []
|
|
444
|
+
if (g.stages.length) {
|
|
445
|
+
lines.push('【已有阶段(按顺序)】')
|
|
446
|
+
for (const stage of g.stages) {
|
|
447
|
+
const rootIds = g.topics.filter((t) => t.parentId == null && t.stageId === stage.id).map((t) => t.id)
|
|
448
|
+
lines.push(`- ${stage.id} "${stage.title}"${stage.summary ? `(${summarize(stage.summary, 24)})` : ''} 话题: ${rootIds.join(', ') || '无'}`)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (g.topics.length) {
|
|
452
|
+
const byId = new Map(g.topics.map((t) => [t.id, t]))
|
|
453
|
+
const children = childrenIndex(g)
|
|
454
|
+
// 话题数很多时,摘要对「判断新轮次归属」帮助有限,省掉以缩短请求
|
|
455
|
+
const withSummary = g.topics.length <= 60
|
|
456
|
+
lines.push('【已有话题】')
|
|
457
|
+
for (const topic of g.topics) {
|
|
458
|
+
const turnNums = allTurnNums(g, topic, children)
|
|
459
|
+
const range = turnNums.length ? `轮${turnNums[0]}-轮${turnNums[turnNums.length - 1]}` : '无轮次'
|
|
460
|
+
const parent = topic.parentId ? ` 父话题:${topic.parentId}` : ''
|
|
461
|
+
const stage = topic.parentId ? '' : ` 阶段:${topic.stageId || '?'}`
|
|
462
|
+
const summary = withSummary ? ` 摘要:${summarize(topic.summary || '无', 40)}` : ''
|
|
463
|
+
lines.push(`- ${topic.id} "${topic.title}" ${range} 状态:${topic.status}${stage}${parent}${summary}`)
|
|
464
|
+
if (lines.length > 220) { lines.push(`- …(其余 ${g.topics.length - lines.length + 3} 个话题省略)`); break }
|
|
465
|
+
}
|
|
466
|
+
if (g.relations.length) {
|
|
467
|
+
lines.push('【已有关系(供参考,可更新)】')
|
|
468
|
+
lines.push(g.relations.slice(0, 20).map((rel) => `${rel.from}→${rel.to}:${rel.label}`).join('; '))
|
|
469
|
+
}
|
|
470
|
+
// 避免 byId 未使用告警(保持结构清晰)
|
|
471
|
+
void byId
|
|
472
|
+
}
|
|
473
|
+
return lines.join('\n')
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── 图状态 ──────────────────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
/** 插件运行期状态。HMR 重载时模块重新求值,旧实例的状态随旧闭包被替换。 */
|
|
479
|
+
let stateRef = null
|
|
480
|
+
|
|
481
|
+
function createState() {
|
|
482
|
+
return {
|
|
483
|
+
graphs: new Map(),
|
|
484
|
+
sessionParents: new Map(),
|
|
485
|
+
lineageAt: 0,
|
|
486
|
+
lastSeen: new Map(),
|
|
487
|
+
titleCache: new Map(),
|
|
488
|
+
segmentKeys: new Set(),
|
|
489
|
+
sessionsCache: null,
|
|
490
|
+
dataVersion: 0,
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function stateOf() {
|
|
495
|
+
if (!stateRef) stateRef = createState()
|
|
496
|
+
return stateRef
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** 会话图 LRU:超出上限时把最久未用的图写回磁盘并从内存移除(磁盘副本仍在)。 */
|
|
500
|
+
function evictGraphsIfNeeded(state) {
|
|
501
|
+
if (state.graphs.size <= GRAPHS_MAX) return
|
|
502
|
+
const candidates = [...state.graphs.entries()]
|
|
503
|
+
.filter(([, g]) => g.ready && !g.loading && !g.segRunning)
|
|
504
|
+
.sort((a, b) => (a[1].lastUsedAt || 0) - (b[1].lastUsedAt || 0))
|
|
505
|
+
for (const [key, g] of candidates) {
|
|
506
|
+
if (state.graphs.size <= GRAPHS_MAX) break
|
|
507
|
+
clearTimeout(g.saveTimer)
|
|
508
|
+
clearTimeout(g.segmentTimer)
|
|
509
|
+
if (g.topics.length) void saveGraphNow(g)
|
|
510
|
+
state.graphs.delete(key)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** 记录访问时间,触发按需淘汰。 */
|
|
515
|
+
function touchGraph(g) {
|
|
516
|
+
g.lastUsedAt = Date.now()
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** titleCache 上限保护。 */
|
|
520
|
+
function capTitleCache(state) {
|
|
521
|
+
if (state.titleCache.size <= TITLE_CACHE_MAX) return
|
|
522
|
+
let excess = state.titleCache.size - TITLE_CACHE_MAX
|
|
523
|
+
for (const key of state.titleCache.keys()) {
|
|
524
|
+
state.titleCache.delete(key)
|
|
525
|
+
if (--excess <= 0) break
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** 记录 / 刷新一个会话的血缘元信息(父子会话、委托深度)。 */
|
|
530
|
+
function updateSessionMeta(sessionId, header) {
|
|
531
|
+
if (!sessionId || !header) return
|
|
532
|
+
const key = normSessionId(sessionId)
|
|
533
|
+
const state = stateOf()
|
|
534
|
+
const prev = state.sessionParents.get(key) || {}
|
|
535
|
+
// 两个来源(listSessions 的完整 header / readSession 的 snapshot.session)可能字段不全:
|
|
536
|
+
// header 明确给出 parentSession 才覆盖,否则沿用已记录的父子关系。
|
|
537
|
+
const parentKey = header.parentSession
|
|
538
|
+
? normSessionId(header.parentSession)
|
|
539
|
+
: (prev.parentKey || null)
|
|
540
|
+
const depth = header.delegationDepth != null
|
|
541
|
+
? Number(header.delegationDepth) || 0
|
|
542
|
+
: (prev.depth != null
|
|
543
|
+
? prev.depth
|
|
544
|
+
: (parentKey ? (state.sessionParents.get(parentKey)?.depth || 0) + 1 : 0))
|
|
545
|
+
state.sessionParents.set(key, {
|
|
546
|
+
id: header.id || sessionId,
|
|
547
|
+
key,
|
|
548
|
+
parentKey,
|
|
549
|
+
depth,
|
|
550
|
+
isSubagent: isSubagentHeader(header),
|
|
551
|
+
title: prev.title || '',
|
|
552
|
+
cwd: header.cwd || prev.cwd || '',
|
|
553
|
+
createdAt: header.createdAt || prev.createdAt || 0,
|
|
554
|
+
lastActiveAt: prev.lastActiveAt || 0,
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** 收集某会话下的子智能体会话(含更深层),带环检测与深度上限。 */
|
|
559
|
+
function collectDescendants(parentKey, maxDepth = 2) {
|
|
560
|
+
const state = stateOf()
|
|
561
|
+
const rootKey = normSessionId(parentKey)
|
|
562
|
+
const out = []
|
|
563
|
+
const visited = new Set([rootKey])
|
|
564
|
+
const walk = (key, depth) => {
|
|
565
|
+
if (depth > maxDepth) return
|
|
566
|
+
for (const [childKey, meta] of state.sessionParents.entries()) {
|
|
567
|
+
if (meta.parentKey !== key || visited.has(childKey)) continue
|
|
568
|
+
visited.add(childKey)
|
|
569
|
+
out.push({ key: childKey, id: meta.id || `session-${childKey}`, depth, meta })
|
|
570
|
+
walk(childKey, depth + 1)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
walk(rootKey, 1)
|
|
574
|
+
out.sort((a, b) => (a.meta.createdAt || 0) - (b.meta.createdAt || 0))
|
|
575
|
+
return out
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** 子会话是在父会话哪一轮启动的:优先按子会话首轮开始时间落进父轮次区间。 */
|
|
579
|
+
function findParentTurnForTime(parentGraph, time) {
|
|
580
|
+
if (!parentGraph || !parentGraph.turns.length) return null
|
|
581
|
+
const t = Number(time) || 0
|
|
582
|
+
if (t <= 0) return parentGraph.turns[parentGraph.turns.length - 1].turn
|
|
583
|
+
let best = null
|
|
584
|
+
for (const turn of parentGraph.turns) {
|
|
585
|
+
const start = turn.startTime || 0
|
|
586
|
+
const end = Math.max(turn.endTime || 0, turn.lastTime || 0)
|
|
587
|
+
if (start <= t && t <= end) return turn.turn
|
|
588
|
+
if (start <= t) best = turn.turn
|
|
589
|
+
}
|
|
590
|
+
return best != null ? best : parentGraph.turns[0].turn
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function createGraph(sessionId) {
|
|
594
|
+
return {
|
|
595
|
+
sessionId,
|
|
596
|
+
key: normSessionId(sessionId),
|
|
597
|
+
ready: false,
|
|
598
|
+
loading: null,
|
|
599
|
+
pending: [],
|
|
600
|
+
pendingPrompts: [],
|
|
601
|
+
turns: [],
|
|
602
|
+
turnIndex: new Map(),
|
|
603
|
+
openSteps: new Map(),
|
|
604
|
+
topics: [],
|
|
605
|
+
stages: [],
|
|
606
|
+
relations: [],
|
|
607
|
+
relationsSignature: '',
|
|
608
|
+
relationError: '',
|
|
609
|
+
llmLastErrorAt: '',
|
|
610
|
+
aiRetriedAt: '',
|
|
611
|
+
segBatchLimit: 0,
|
|
612
|
+
parentSessionId: null,
|
|
613
|
+
depth: 0,
|
|
614
|
+
subagentLinks: [],
|
|
615
|
+
topicSeq: 0,
|
|
616
|
+
stageSeq: 0,
|
|
617
|
+
segmentedThroughTurn: 0,
|
|
618
|
+
lastAssignedTopicId: null,
|
|
619
|
+
aiStatus: 'idle',
|
|
620
|
+
aiError: '',
|
|
621
|
+
aiMode: '',
|
|
622
|
+
aiTokens: emptyTokens(),
|
|
623
|
+
aiCalls: 0,
|
|
624
|
+
aiProgress: 0,
|
|
625
|
+
turnAnalyses: {},
|
|
626
|
+
llmProvider: '',
|
|
627
|
+
llmModel: '',
|
|
628
|
+
lastAiAt: '',
|
|
629
|
+
lastEventAt: 0,
|
|
630
|
+
lastSeq: -1,
|
|
631
|
+
version: 0,
|
|
632
|
+
saveTimer: null,
|
|
633
|
+
segmentTimer: null,
|
|
634
|
+
segRunning: false,
|
|
635
|
+
segQueued: false,
|
|
636
|
+
segQueuedFull: false,
|
|
637
|
+
lastSegAt: 0,
|
|
638
|
+
lastUsedAt: 0,
|
|
639
|
+
updatedAt: '',
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function newTurn(turn, time) {
|
|
644
|
+
return {
|
|
645
|
+
turn,
|
|
646
|
+
startSeq: null,
|
|
647
|
+
endSeq: null,
|
|
648
|
+
startTime: time || 0,
|
|
649
|
+
endTime: time || 0,
|
|
650
|
+
lastTime: time || 0,
|
|
651
|
+
prompt: '',
|
|
652
|
+
assistant: '',
|
|
653
|
+
reasoningChars: 0,
|
|
654
|
+
tokens: emptyTokens(),
|
|
655
|
+
toolCalls: { total: 0, byName: {} },
|
|
656
|
+
toolErrors: 0,
|
|
657
|
+
steps: 0,
|
|
658
|
+
requests: 0,
|
|
659
|
+
agentMs: 0,
|
|
660
|
+
status: 'open',
|
|
661
|
+
provider: '',
|
|
662
|
+
model: '',
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function ensureTurn(g, turnNumber, time) {
|
|
667
|
+
const n = Number(turnNumber)
|
|
668
|
+
if (!Number.isInteger(n) || n < 1) return null
|
|
669
|
+
let turn = g.turnIndex.get(n)
|
|
670
|
+
if (!turn) {
|
|
671
|
+
turn = newTurn(n, time)
|
|
672
|
+
g.turns.push(turn)
|
|
673
|
+
g.turnIndex.set(n, turn)
|
|
674
|
+
g.turns.sort((a, b) => a.turn - b.turn)
|
|
675
|
+
}
|
|
676
|
+
if (time && time > turn.lastTime) turn.lastTime = time
|
|
677
|
+
return turn
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function currentOpenTurn(g) {
|
|
681
|
+
for (let i = g.turns.length - 1; i >= 0; i -= 1) {
|
|
682
|
+
if (g.turns[i].status === 'open') return g.turns[i]
|
|
683
|
+
}
|
|
684
|
+
return g.turns[g.turns.length - 1]
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function turnEndStatus(reason) {
|
|
688
|
+
const kind = reason && typeof reason === 'object' ? reason.kind : ''
|
|
689
|
+
if (kind === 'aborted') return 'aborted'
|
|
690
|
+
if (kind === 'error') return 'error'
|
|
691
|
+
if (kind === 'interrupted') return 'interrupted'
|
|
692
|
+
return 'done'
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function closeOpenSteps(g, turn, endTime) {
|
|
696
|
+
const prefix = `${turn.turn}:`
|
|
697
|
+
for (const [key, startTime] of [...g.openSteps.entries()]) {
|
|
698
|
+
if (!key.startsWith(prefix)) continue
|
|
699
|
+
const elapsed = Math.max(0, (endTime || startTime) - startTime)
|
|
700
|
+
turn.agentMs += elapsed
|
|
701
|
+
g.openSteps.delete(key)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** 把一条已提交的 session 事件折叠进图状态。 */
|
|
706
|
+
function applyEvent(g, event) {
|
|
707
|
+
if (!event || typeof event.type !== 'string') return false
|
|
708
|
+
const data = event.data && typeof event.data === 'object' ? event.data : {}
|
|
709
|
+
const time = normalizeTime(event.time) || Date.now()
|
|
710
|
+
const seq = Number.isInteger(event.seq) ? event.seq : g.lastSeq + 1
|
|
711
|
+
if (seq <= g.lastSeq) return false // 已折叠过的历史事件(导入后又收到实时事件)
|
|
712
|
+
g.lastSeq = seq
|
|
713
|
+
if (time > g.lastEventAt) g.lastEventAt = time
|
|
714
|
+
|
|
715
|
+
const type = event.type
|
|
716
|
+
if (type === 'agent/inbox/spliced') {
|
|
717
|
+
// 下一轮排队进来的用户消息:先记住,等 turn/start 归位(比 currentOpenTurn 更稳)
|
|
718
|
+
if (data.target === 'next-turn' && Array.isArray(data.inserted)) {
|
|
719
|
+
for (const msg of data.inserted) {
|
|
720
|
+
const kind = msg?.source?.kind
|
|
721
|
+
if (!DIRECT_USER_KINDS.has(kind)) continue
|
|
722
|
+
const text = extractBlocksText(msg?.content, TURN_PROMPT_MAX, ['text'])
|
|
723
|
+
if (text) g.pendingPrompts.push(text)
|
|
724
|
+
break
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return true
|
|
728
|
+
}
|
|
729
|
+
if (type === 'turn/start') {
|
|
730
|
+
const turn = ensureTurn(g, data.turn, time)
|
|
731
|
+
if (turn) {
|
|
732
|
+
if (!turn.startTime) turn.startTime = time
|
|
733
|
+
if (turn.startSeq == null) turn.startSeq = seq
|
|
734
|
+
turn.status = 'open'
|
|
735
|
+
if (!turn.prompt && g.pendingPrompts.length) turn.prompt = g.pendingPrompts.shift()
|
|
736
|
+
}
|
|
737
|
+
return true
|
|
738
|
+
}
|
|
739
|
+
if (type === 'turn/end') {
|
|
740
|
+
const turn = ensureTurn(g, data.turn != null ? data.turn : g.turns[g.turns.length - 1]?.turn, time)
|
|
741
|
+
if (turn) {
|
|
742
|
+
turn.endTime = time
|
|
743
|
+
turn.endSeq = seq
|
|
744
|
+
turn.status = turnEndStatus(data.reason)
|
|
745
|
+
closeOpenSteps(g, turn, time)
|
|
746
|
+
}
|
|
747
|
+
return true
|
|
748
|
+
}
|
|
749
|
+
if (type === 'step/start') {
|
|
750
|
+
const turn = ensureTurn(g, data.turn, time)
|
|
751
|
+
if (turn) {
|
|
752
|
+
turn.steps += 1
|
|
753
|
+
g.openSteps.set(`${turn.turn}:${data.step}`, time)
|
|
754
|
+
}
|
|
755
|
+
return true
|
|
756
|
+
}
|
|
757
|
+
if (type === 'step/end') {
|
|
758
|
+
const turn = ensureTurn(g, data.turn, time)
|
|
759
|
+
if (turn) {
|
|
760
|
+
const key = `${turn.turn}:${data.step}`
|
|
761
|
+
const startTime = g.openSteps.get(key)
|
|
762
|
+
if (startTime != null) {
|
|
763
|
+
turn.agentMs += Math.max(0, time - startTime)
|
|
764
|
+
g.openSteps.delete(key)
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return true
|
|
768
|
+
}
|
|
769
|
+
if (type === 'user/message') {
|
|
770
|
+
const message = data.message && data.message.content ? data.message : data
|
|
771
|
+
const kind = message?.source?.kind
|
|
772
|
+
if (DIRECT_USER_KINDS.has(kind)) {
|
|
773
|
+
const text = extractBlocksText(message.content, TURN_PROMPT_MAX, ['text'])
|
|
774
|
+
if (text) {
|
|
775
|
+
const turn = currentOpenTurn(g)
|
|
776
|
+
if (turn && !turn.prompt) turn.prompt = text
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return true
|
|
780
|
+
}
|
|
781
|
+
if (type === 'assistant/message') {
|
|
782
|
+
const turn = ensureTurn(g, data.turn, time)
|
|
783
|
+
if (!turn) return true
|
|
784
|
+
turn.requests += 1
|
|
785
|
+
const message = data.message
|
|
786
|
+
const text = extractBlocksText(message?.content, TURN_ASSISTANT_MAX, ['text'])
|
|
787
|
+
if (text) turn.assistant = text
|
|
788
|
+
turn.reasoningChars += countBlocksText(message?.content, ['reasoning'])
|
|
789
|
+
addUsage(turn, data.usage)
|
|
790
|
+
const source = message?.source
|
|
791
|
+
if (source && source.kind === 'model') {
|
|
792
|
+
if (source.model) turn.model = String(source.model)
|
|
793
|
+
if (source.provider) turn.provider = String(source.provider)
|
|
794
|
+
}
|
|
795
|
+
return true
|
|
796
|
+
}
|
|
797
|
+
if (type === 'tool/call') {
|
|
798
|
+
const turn = ensureTurn(g, data.turn, time)
|
|
799
|
+
if (turn && data.name) {
|
|
800
|
+
turn.toolCalls.total += 1
|
|
801
|
+
const map = turn.toolCalls.byName
|
|
802
|
+
map[data.name] = (map[data.name] || 0) + 1
|
|
803
|
+
}
|
|
804
|
+
return true
|
|
805
|
+
}
|
|
806
|
+
if (type === 'tool/result') {
|
|
807
|
+
const turn = ensureTurn(g, data.turn, time)
|
|
808
|
+
if (turn) {
|
|
809
|
+
const isError = data.message?.isError === true || data.isError === true
|
|
810
|
+
if (isError) turn.toolErrors += 1
|
|
811
|
+
}
|
|
812
|
+
return true
|
|
813
|
+
}
|
|
814
|
+
return false
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** 冻结一份轮次数组,避免读路径与折叠路径互相踩。 */
|
|
818
|
+
function snapshotTurns(g) {
|
|
819
|
+
return g.turns.map((t) => ({ ...t, tokens: { ...t.tokens }, toolCalls: { total: t.toolCalls.total, byName: { ...t.toolCalls.byName } } }))
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ── 会话导入(读历史日志 + 恢复切分结果) ───────────────────────────────────
|
|
823
|
+
|
|
824
|
+
async function readSessionEvents(ctx, sessionId) {
|
|
825
|
+
if (!ctx.sessionQuery || typeof ctx.sessionQuery.readSession !== 'function') return null
|
|
826
|
+
let lastError = null
|
|
827
|
+
for (const candidate of sessionIdCandidates(sessionId)) {
|
|
828
|
+
try {
|
|
829
|
+
const snapshot = await ctx.sessionQuery.readSession(candidate)
|
|
830
|
+
if (snapshot && Array.isArray(snapshot.events)) return snapshot
|
|
831
|
+
} catch (error) {
|
|
832
|
+
lastError = error
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (lastError) throw lastError
|
|
836
|
+
return null
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function rebuildFromEvents(g, events) {
|
|
840
|
+
g.turns = []
|
|
841
|
+
g.turnIndex = new Map()
|
|
842
|
+
g.openSteps = new Map()
|
|
843
|
+
g.pendingPrompts = []
|
|
844
|
+
g.lastSeq = -1
|
|
845
|
+
g.lastEventAt = 0
|
|
846
|
+
for (const event of events) applyEvent(g, event)
|
|
847
|
+
// 收尾:日志重放时不应留下 open 的步骤;未闭合的轮次按最后事件时间标记
|
|
848
|
+
for (const turn of g.turns) {
|
|
849
|
+
if (turn.status === 'open' && turn.endTime && turn.endTime > turn.startTime) {
|
|
850
|
+
// 事件里确实没有 turn/end 的旧日志:保守视为已结束(最后一条事件的时间)
|
|
851
|
+
if (turn.endSeq != null) turn.status = 'done'
|
|
852
|
+
}
|
|
853
|
+
if (turn.endTime < turn.startTime) turn.endTime = turn.startTime
|
|
854
|
+
if (turn.endSeq == null) {
|
|
855
|
+
// 未闭合轮次(正在运行的最后一次 turn):保留 open 语义,用最后事件时间算耗时
|
|
856
|
+
turn.endTime = Math.max(turn.endTime, turn.lastTime)
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ── 单轮轨迹重建(按需,不驻留内存) ───────────────────────────────────────
|
|
862
|
+
|
|
863
|
+
/** 从原始事件里重建某一轮的「模型 / 工具」有序轨迹(扁平、带截断)。 */
|
|
864
|
+
function buildTurnTrace(events, turnNumber) {
|
|
865
|
+
const items = []
|
|
866
|
+
if (!Array.isArray(events)) return items
|
|
867
|
+
for (const event of events) {
|
|
868
|
+
if (!event || typeof event.type !== 'string') continue
|
|
869
|
+
const data = event.data && typeof event.data === 'object' ? event.data : {}
|
|
870
|
+
if (data.turn !== turnNumber) continue
|
|
871
|
+
const type = event.type
|
|
872
|
+
if (type === 'tool/call') {
|
|
873
|
+
items.push({
|
|
874
|
+
kind: 'tool',
|
|
875
|
+
name: String(data.name || 'tool'),
|
|
876
|
+
args: typeof data.arguments === 'string' && data.arguments ? summarize(data.arguments, TURN_TRACE_ARGS_MAX) : '',
|
|
877
|
+
ok: null,
|
|
878
|
+
result: '',
|
|
879
|
+
})
|
|
880
|
+
} else if (type === 'tool/result') {
|
|
881
|
+
const msg = data.message && typeof data.message === 'object' ? data.message : {}
|
|
882
|
+
const isError = msg.isError === true || data.isError === true
|
|
883
|
+
const result = summarize(extractBlocksText(msg.content, TURN_TRACE_RESULT_MAX, ['text']), TURN_TRACE_RESULT_MAX)
|
|
884
|
+
const last = items[items.length - 1]
|
|
885
|
+
if (last && last.kind === 'tool' && last.ok == null) {
|
|
886
|
+
last.ok = !isError
|
|
887
|
+
last.result = result || (isError ? '(失败)' : '')
|
|
888
|
+
} else {
|
|
889
|
+
items.push({ kind: 'tool', name: String(data.name || 'tool'), args: '', ok: !isError, result })
|
|
890
|
+
}
|
|
891
|
+
} else if (type === 'assistant/message') {
|
|
892
|
+
const msg = data.message && typeof data.message === 'object' ? data.message : {}
|
|
893
|
+
const text = summarize(extractBlocksText(msg.content, TURN_TRACE_TEXT_MAX, ['text']), TURN_TRACE_TEXT_MAX)
|
|
894
|
+
const hasReasoning = countBlocksText(msg.content, ['reasoning']) > 0
|
|
895
|
+
if (text || hasReasoning) items.push({ kind: 'model', text, reasoning: hasReasoning })
|
|
896
|
+
}
|
|
897
|
+
if (items.length >= TURN_TRACE_MAX_ITEMS) break
|
|
898
|
+
}
|
|
899
|
+
return items
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** 把轨迹渲染成给模型的线性文本。 */
|
|
903
|
+
function renderTurnTrace(trace) {
|
|
904
|
+
const lines = []
|
|
905
|
+
let step = 0
|
|
906
|
+
for (const item of trace) {
|
|
907
|
+
if (item.kind === 'tool') {
|
|
908
|
+
step += 1
|
|
909
|
+
const parts = [`[${step}] 工具 ${item.name}`]
|
|
910
|
+
if (item.args) parts.push(`参数 ${item.args}`)
|
|
911
|
+
if (item.ok != null) parts.push(item.ok ? `结果 ${item.result || '成功'}` : `失败 ${item.result || ''}`.trim())
|
|
912
|
+
lines.push(parts.join(' | '))
|
|
913
|
+
} else {
|
|
914
|
+
lines.push(`[模型] ${item.reasoning ? '思考' : '输出'}: ${item.text || '(无文字)'}`)
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return lines.join('\n')
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function validateLoadedSegment(g, persisted) {
|
|
921
|
+
if (!persisted || typeof persisted !== 'object') return false
|
|
922
|
+
const topicsRaw = Array.isArray(persisted.topics) ? persisted.topics : []
|
|
923
|
+
const stagesRaw = Array.isArray(persisted.stages) ? persisted.stages : []
|
|
924
|
+
const lastTurn = g.turns.length ? g.turns[g.turns.length - 1].turn : 0
|
|
925
|
+
const through = clampInt(persisted.segmentedThroughTurn, 0, 0, 100000)
|
|
926
|
+
if (through > lastTurn) return false // 日志回退/被改写:整份切分作废
|
|
927
|
+
if (topicsRaw.length === 0) return false
|
|
928
|
+
|
|
929
|
+
const topicIds = new Set()
|
|
930
|
+
const topics = []
|
|
931
|
+
for (const raw of topicsRaw) {
|
|
932
|
+
if (!raw || typeof raw !== 'object') continue
|
|
933
|
+
const id = typeof raw.id === 'string' && raw.id ? raw.id : ''
|
|
934
|
+
if (!id || topicIds.has(id)) continue
|
|
935
|
+
topicIds.add(id)
|
|
936
|
+
const ownTurns = (Array.isArray(raw.ownTurns) ? raw.ownTurns : [])
|
|
937
|
+
.map((n) => Number(n))
|
|
938
|
+
.filter((n) => Number.isInteger(n) && n >= 1 && n <= lastTurn)
|
|
939
|
+
topics.push({
|
|
940
|
+
id,
|
|
941
|
+
title: summarize(raw.title || '未命名话题', TOPIC_TITLE_MAX),
|
|
942
|
+
summary: summarize(raw.summary || '', SUMMARY_MAX),
|
|
943
|
+
parentId: typeof raw.parentId === 'string' && raw.parentId ? raw.parentId : null,
|
|
944
|
+
ownTurns: [...new Set(ownTurns)].sort((a, b) => a - b),
|
|
945
|
+
status: raw.status === 'active' ? 'active' : 'done',
|
|
946
|
+
source: raw.source === 'rule' ? 'rule' : 'llm',
|
|
947
|
+
stageId: typeof raw.stageId === 'string' && raw.stageId ? raw.stageId : null,
|
|
948
|
+
})
|
|
949
|
+
}
|
|
950
|
+
if (!topics.length) return false
|
|
951
|
+
const byId = new Map(topics.map((t) => [t.id, t]))
|
|
952
|
+
// 父子关系必须存在且不成环
|
|
953
|
+
for (const topic of topics) {
|
|
954
|
+
if (topic.parentId && !byId.has(topic.parentId)) topic.parentId = null
|
|
955
|
+
}
|
|
956
|
+
for (const topic of topics) {
|
|
957
|
+
let cursor = topic
|
|
958
|
+
let guard = 0
|
|
959
|
+
while (cursor.parentId && guard < 30) {
|
|
960
|
+
cursor = byId.get(cursor.parentId)
|
|
961
|
+
if (!cursor || cursor === topic) { topic.parentId = null; break }
|
|
962
|
+
guard += 1
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const stageIds = new Set()
|
|
967
|
+
const stages = []
|
|
968
|
+
for (const raw of stagesRaw) {
|
|
969
|
+
if (!raw || typeof raw !== 'object') continue
|
|
970
|
+
const id = typeof raw.id === 'string' && raw.id ? raw.id : ''
|
|
971
|
+
if (!id || stageIds.has(id)) continue
|
|
972
|
+
stageIds.add(id)
|
|
973
|
+
stages.push({
|
|
974
|
+
id,
|
|
975
|
+
title: summarize(raw.title || '阶段', STAGE_TITLE_MAX),
|
|
976
|
+
summary: summarize(raw.summary || '', SUMMARY_MAX),
|
|
977
|
+
order: clampInt(raw.order, stages.length, 0, 10000),
|
|
978
|
+
})
|
|
979
|
+
}
|
|
980
|
+
if (!stages.length) {
|
|
981
|
+
stages.push({ id: 's1', title: '初始阶段', summary: '', order: 0 })
|
|
982
|
+
}
|
|
983
|
+
// 话题的阶段归属修复:根话题必须属于一个阶段
|
|
984
|
+
for (const topic of topics) {
|
|
985
|
+
if (topic.parentId) continue
|
|
986
|
+
if (!topic.stageId || !stageIds.has(topic.stageId)) {
|
|
987
|
+
topic.stageId = stages[stages.length - 1].id
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
for (const topic of topics) {
|
|
991
|
+
if (!topic.parentId) continue
|
|
992
|
+
const parent = byId.get(topic.parentId)
|
|
993
|
+
topic.stageId = parent?.stageId || (stages.length ? stages[stages.length - 1].id : null)
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const stageIdSet = new Set(stages.map((s) => s.id))
|
|
997
|
+
const topicIdSet = new Set(topics.map((t) => t.id))
|
|
998
|
+
g.relationsSignature = typeof persisted.relationsSignature === 'string' ? persisted.relationsSignature : ''
|
|
999
|
+
if (persisted.aiTokens && typeof persisted.aiTokens === 'object') {
|
|
1000
|
+
for (const key of Object.keys(g.aiTokens)) g.aiTokens[key] = numOr0(persisted.aiTokens[key])
|
|
1001
|
+
}
|
|
1002
|
+
g.aiCalls = numOr0(persisted.aiCalls)
|
|
1003
|
+
g.turnAnalyses = (persisted.turnAnalyses && typeof persisted.turnAnalyses === 'object')
|
|
1004
|
+
? persisted.turnAnalyses
|
|
1005
|
+
: {}
|
|
1006
|
+
g.relations = (Array.isArray(persisted.relations) ? persisted.relations : [])
|
|
1007
|
+
.filter((r) => r && typeof r.from === 'string' && typeof r.to === 'string'
|
|
1008
|
+
&& topicIdSet.has(r.from) && topicIdSet.has(r.to) && r.from !== r.to)
|
|
1009
|
+
.map((r) => ({ from: r.from, to: r.to, label: summarize(r.label || '关联', RELATION_LABEL_MAX) }))
|
|
1010
|
+
.slice(0, MAX_RELATIONS)
|
|
1011
|
+
g.topics = topics
|
|
1012
|
+
g.stages = stages
|
|
1013
|
+
g.segmentedThroughTurn = through
|
|
1014
|
+
g.topicSeq = Math.max(0, ...topics.map((t) => numericSuffix(t.id)))
|
|
1015
|
+
g.stageSeq = Math.max(0, ...stages.map((s) => numericSuffix(s.id)))
|
|
1016
|
+
g.lastAssignedTopicId = null
|
|
1017
|
+
g.updatedAt = typeof persisted.updatedAt === 'string' ? persisted.updatedAt : ''
|
|
1018
|
+
return true
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function numericSuffix(id) {
|
|
1022
|
+
const match = /(\d+)$/.exec(String(id || ''))
|
|
1023
|
+
return match ? Number(match[1]) : 0
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async function loadPersistedSegment(g) {
|
|
1027
|
+
try {
|
|
1028
|
+
const raw = await readFile(graphFile(g), 'utf8')
|
|
1029
|
+
const parsed = JSON.parse(raw)
|
|
1030
|
+
return parsed && typeof parsed === 'object' ? parsed : null
|
|
1031
|
+
} catch {
|
|
1032
|
+
return null
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** 确保某会话的图已导入(只导入一次);并发请求共享同一个 Promise。 */
|
|
1037
|
+
async function ensureGraph(ctx, sessionId) {
|
|
1038
|
+
const key = normSessionId(sessionId)
|
|
1039
|
+
const state = stateOf()
|
|
1040
|
+
let g = state.graphs.get(key)
|
|
1041
|
+
if (!g) {
|
|
1042
|
+
g = createGraph(sessionId)
|
|
1043
|
+
state.graphs.set(key, g)
|
|
1044
|
+
}
|
|
1045
|
+
if (g.ready) { touchGraph(g); return g }
|
|
1046
|
+
if (g.loading) return g.loading
|
|
1047
|
+
g.loading = (async () => {
|
|
1048
|
+
let snapshot = null
|
|
1049
|
+
let importError = ''
|
|
1050
|
+
try {
|
|
1051
|
+
snapshot = await readSessionEvents(ctx, sessionId)
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
importError = String(error?.message || error)
|
|
1054
|
+
}
|
|
1055
|
+
if (snapshot) {
|
|
1056
|
+
rebuildFromEvents(g, snapshot.events || [])
|
|
1057
|
+
g.sessionId = snapshot.session?.id || sessionId
|
|
1058
|
+
g.key = normSessionId(g.sessionId)
|
|
1059
|
+
updateSessionMeta(g.sessionId, snapshot.session)
|
|
1060
|
+
const meta = stateOf().sessionParents.get(g.key)
|
|
1061
|
+
const parentMeta = meta?.parentKey ? stateOf().sessionParents.get(meta.parentKey) : null
|
|
1062
|
+
g.parentSessionId = meta?.parentKey ? (parentMeta?.id || `session-${meta.parentKey}`) : null
|
|
1063
|
+
g.depth = meta?.depth || 0
|
|
1064
|
+
}
|
|
1065
|
+
// 实时事件在导入期间入队,导入后按 seq 去重补折叠
|
|
1066
|
+
const pending = g.pending
|
|
1067
|
+
g.pending = []
|
|
1068
|
+
const persisted = await loadPersistedSegment(g)
|
|
1069
|
+
const restored = snapshot ? validateLoadedSegment(g, persisted) : false
|
|
1070
|
+
const ruleOnly = g.topics.length > 0 && g.topics.every((topic) => topic.source !== 'llm')
|
|
1071
|
+
if (restored && persisted && persisted.aiGenerated !== true && ruleOnly && cfgRef.segmentMode !== 'rule') {
|
|
1072
|
+
// 上次只落到规则切分(例如 AI 当时 400 / 不可用)→ 现在允许 AI 时重试一次;
|
|
1073
|
+
// 6 小时内最多重试一次,避免模型持续不可用时每次打开面板都烧 token。
|
|
1074
|
+
const lastRetry = Date.parse(persisted.aiRetriedAt || '')
|
|
1075
|
+
const retryRecently = Number.isFinite(lastRetry) && Date.now() - lastRetry < 6 * 3600 * 1000
|
|
1076
|
+
if (!retryRecently) {
|
|
1077
|
+
g.topics = []
|
|
1078
|
+
g.stages = []
|
|
1079
|
+
g.relations = []
|
|
1080
|
+
g.relationsSignature = ''
|
|
1081
|
+
g.aiRetriedAt = nowIso()
|
|
1082
|
+
g.segmentedThroughTurn = 0
|
|
1083
|
+
g.topicSeq = 0
|
|
1084
|
+
g.stageSeq = 0
|
|
1085
|
+
g.lastAssignedTopicId = null
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
g.ready = true
|
|
1089
|
+
g.loading = null
|
|
1090
|
+
g.aiStatus = 'idle'
|
|
1091
|
+
touchGraph(g)
|
|
1092
|
+
evictGraphsIfNeeded(state)
|
|
1093
|
+
for (const event of pending) {
|
|
1094
|
+
if (applyEvent(g, event)) g.version += 1
|
|
1095
|
+
}
|
|
1096
|
+
if (!restored && g.topics.length > 0) {
|
|
1097
|
+
// 持久化数据不可用:清空结构重新切分
|
|
1098
|
+
g.topics = []
|
|
1099
|
+
g.stages = []
|
|
1100
|
+
g.segmentedThroughTurn = 0
|
|
1101
|
+
}
|
|
1102
|
+
// 旧版本持久化没有关系签名:恢复后补一次「仅标题」关系分析(不阻塞首帧)
|
|
1103
|
+
if (restored && cfgRef.relationMode === 'titles' && !g.relationsSignature && g.topics.length >= 2) {
|
|
1104
|
+
void refineRelations(ctx, g).catch((error) => {
|
|
1105
|
+
g.relationError = describeLlmError(error)
|
|
1106
|
+
g.version += 1
|
|
1107
|
+
})
|
|
1108
|
+
}
|
|
1109
|
+
g.version += 1
|
|
1110
|
+
if (importError) g.aiError = `读取会话日志失败:${summarize(importError, 120)}`
|
|
1111
|
+
scheduleSegment(ctx, g, {})
|
|
1112
|
+
return g
|
|
1113
|
+
})()
|
|
1114
|
+
return g.loading
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// ── 切分结构操作 ────────────────────────────────────────────────────────────
|
|
1118
|
+
|
|
1119
|
+
function createTopic(g, title) {
|
|
1120
|
+
g.topicSeq += 1
|
|
1121
|
+
const topic = {
|
|
1122
|
+
id: `t${g.topicSeq}`,
|
|
1123
|
+
title: summarize(title || '未命名话题', TOPIC_TITLE_MAX),
|
|
1124
|
+
summary: '',
|
|
1125
|
+
parentId: null,
|
|
1126
|
+
ownTurns: [],
|
|
1127
|
+
status: 'active',
|
|
1128
|
+
source: 'llm',
|
|
1129
|
+
stageId: null,
|
|
1130
|
+
}
|
|
1131
|
+
g.topics.push(topic)
|
|
1132
|
+
return topic
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function createStage(g, title) {
|
|
1136
|
+
g.stageSeq += 1
|
|
1137
|
+
const stage = {
|
|
1138
|
+
id: `s${g.stageSeq}`,
|
|
1139
|
+
title: summarize(title || `阶段 ${g.stageSeq}`, STAGE_TITLE_MAX),
|
|
1140
|
+
summary: '',
|
|
1141
|
+
order: g.stages.length,
|
|
1142
|
+
}
|
|
1143
|
+
g.stages.push(stage)
|
|
1144
|
+
return stage
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function topicsById(g) {
|
|
1148
|
+
return new Map(g.topics.map((t) => [t.id, t]))
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** 预建「父话题 id → 子话题数组」索引,避免 allTurnNums 每次都全表扫描。 */
|
|
1152
|
+
function childrenIndex(g) {
|
|
1153
|
+
const children = new Map()
|
|
1154
|
+
for (const topic of g.topics) {
|
|
1155
|
+
if (!topic.parentId) continue
|
|
1156
|
+
if (!children.has(topic.parentId)) children.set(topic.parentId, [])
|
|
1157
|
+
children.get(topic.parentId).push(topic)
|
|
1158
|
+
}
|
|
1159
|
+
return children
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function allTurnNums(g, topic, children) {
|
|
1163
|
+
const index = children || childrenIndex(g)
|
|
1164
|
+
const nums = new Set()
|
|
1165
|
+
const visit = (node, guard) => {
|
|
1166
|
+
if (!node || guard > 30) return
|
|
1167
|
+
for (const n of node.ownTurns || []) nums.add(n)
|
|
1168
|
+
for (const child of index.get(node.id) || []) visit(child, guard + 1)
|
|
1169
|
+
}
|
|
1170
|
+
visit(topic, 0)
|
|
1171
|
+
return [...nums].sort((a, b) => a - b)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function topicOwningTurn(g, turnNumber) {
|
|
1175
|
+
for (const topic of g.topics) {
|
|
1176
|
+
if ((topic.ownTurns || []).includes(turnNumber)) return topic
|
|
1177
|
+
}
|
|
1178
|
+
return null
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function assignTurnToTopic(g, topic, turnNumber) {
|
|
1182
|
+
if (!topic) return
|
|
1183
|
+
for (const other of g.topics) {
|
|
1184
|
+
if (other === topic) continue
|
|
1185
|
+
const index = other.ownTurns.indexOf(turnNumber)
|
|
1186
|
+
if (index !== -1) other.ownTurns.splice(index, 1)
|
|
1187
|
+
}
|
|
1188
|
+
if (!topic.ownTurns.includes(turnNumber)) {
|
|
1189
|
+
topic.ownTurns.push(turnNumber)
|
|
1190
|
+
topic.ownTurns.sort((a, b) => a - b)
|
|
1191
|
+
}
|
|
1192
|
+
g.lastAssignedTopicId = topic.id
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function lastTopicInOrder(g) {
|
|
1196
|
+
return g.topics.length ? g.topics[g.topics.length - 1] : null
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const RELATION_LABEL_MAX = 8
|
|
1200
|
+
const MAX_RELATIONS = 30
|
|
1201
|
+
|
|
1202
|
+
/** 解析 AI 输出的 relations,得到可落盘的 {from,to,label} 列表(只保留合法、去重、有向)。 */
|
|
1203
|
+
function resolveRelations(g, rawRelations, tempToId, existingById) {
|
|
1204
|
+
const resolve = (ref) => {
|
|
1205
|
+
if (typeof ref !== 'string' || !ref) return null
|
|
1206
|
+
const id = tempToId.get(ref) || ref
|
|
1207
|
+
if (existingById.has(id)) return id
|
|
1208
|
+
return g.topics.some((t) => t.id === id) ? id : null
|
|
1209
|
+
}
|
|
1210
|
+
const out = []
|
|
1211
|
+
const seen = new Set()
|
|
1212
|
+
for (const raw of Array.isArray(rawRelations) ? rawRelations : []) {
|
|
1213
|
+
if (!raw || typeof raw !== 'object') continue
|
|
1214
|
+
const from = resolve(raw.from)
|
|
1215
|
+
const to = resolve(raw.to)
|
|
1216
|
+
if (!from || !to || from === to) continue
|
|
1217
|
+
const label = summarize(raw.label || '关联', RELATION_LABEL_MAX)
|
|
1218
|
+
const key = `${from}|${to}|${label}`
|
|
1219
|
+
if (seen.has(key)) continue
|
|
1220
|
+
seen.add(key)
|
|
1221
|
+
out.push({ from, to, label })
|
|
1222
|
+
if (out.length >= MAX_RELATIONS) break
|
|
1223
|
+
}
|
|
1224
|
+
return out
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/** 把 AI 输出的完整结构合入图状态;batch 内轮次重新归属,历史归属保持不变。 */
|
|
1228
|
+
function applyAiStructure(g, parsed, batch) {
|
|
1229
|
+
const existingById = topicsById(g)
|
|
1230
|
+
const tempToId = new Map()
|
|
1231
|
+
const claims = new Map() // topicId -> { start, end }
|
|
1232
|
+
const outTopics = []
|
|
1233
|
+
const rawTopics = Array.isArray(parsed.topics) ? parsed.topics : []
|
|
1234
|
+
|
|
1235
|
+
for (const raw of rawTopics) {
|
|
1236
|
+
if (!raw || typeof raw !== 'object') continue
|
|
1237
|
+
const rawId = typeof raw.id === 'string' ? raw.id : ''
|
|
1238
|
+
const tempId = typeof raw.tempId === 'string' ? raw.tempId : ''
|
|
1239
|
+
let topic = rawId ? existingById.get(rawId) : null
|
|
1240
|
+
if (!topic) {
|
|
1241
|
+
topic = createTopic(g, raw.title || raw.summary || '未命名话题')
|
|
1242
|
+
topic.source = 'llm'
|
|
1243
|
+
}
|
|
1244
|
+
if (rawId) tempToId.set(rawId, topic.id)
|
|
1245
|
+
if (tempId) tempToId.set(tempId, topic.id)
|
|
1246
|
+
const title = summarize(raw.title || topic.title, TOPIC_TITLE_MAX)
|
|
1247
|
+
if (title) topic.title = title
|
|
1248
|
+
if (typeof raw.summary === 'string' && raw.summary) topic.summary = summarize(raw.summary, SUMMARY_MAX)
|
|
1249
|
+
if (raw.status === 'active' || raw.status === 'done') topic.status = raw.status
|
|
1250
|
+
topic.source = 'llm'
|
|
1251
|
+
topic._parentRef = typeof raw.parentId === 'string' && raw.parentId ? raw.parentId : null
|
|
1252
|
+
const start = clampInt(raw.turnStart, 1, 1, 100000)
|
|
1253
|
+
const end = clampInt(raw.turnEnd, start, start, 100000)
|
|
1254
|
+
claims.set(topic.id, { start, end })
|
|
1255
|
+
outTopics.push(topic)
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// 新话题 id 可能被父话题先引用 → 先统一注册到 g.topics 再解析
|
|
1259
|
+
for (const topic of outTopics) {
|
|
1260
|
+
if (!g.topics.includes(topic)) g.topics.push(topic)
|
|
1261
|
+
}
|
|
1262
|
+
for (const topic of outTopics) {
|
|
1263
|
+
const ref = topic._parentRef
|
|
1264
|
+
delete topic._parentRef
|
|
1265
|
+
if (!ref) { topic.parentId = null; continue }
|
|
1266
|
+
const resolved = tempToId.get(ref)
|
|
1267
|
+
|| (existingById.has(ref) ? ref : null)
|
|
1268
|
+
|| (g.topics.some((t) => t.id === ref) ? ref : null)
|
|
1269
|
+
topic.parentId = resolved && resolved !== topic.id ? resolved : null
|
|
1270
|
+
}
|
|
1271
|
+
// 父话题必须存在,且不允许成环
|
|
1272
|
+
for (const topic of g.topics) {
|
|
1273
|
+
if (!topic.parentId) continue
|
|
1274
|
+
if (!g.topics.some((t) => t.id === topic.parentId)) topic.parentId = null
|
|
1275
|
+
}
|
|
1276
|
+
for (const topic of g.topics) {
|
|
1277
|
+
let cursor = topic
|
|
1278
|
+
let guard = 0
|
|
1279
|
+
while (cursor.parentId && guard < 30) {
|
|
1280
|
+
const parent = g.topics.find((t) => t.id === cursor.parentId)
|
|
1281
|
+
if (!parent || parent === topic) { topic.parentId = null; break }
|
|
1282
|
+
cursor = parent
|
|
1283
|
+
guard += 1
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
// 子话题最多 3 层(根话题为第 1 层):超过的压平为根话题
|
|
1287
|
+
for (const topic of g.topics) {
|
|
1288
|
+
if (!topic.parentId) continue
|
|
1289
|
+
let level = 1
|
|
1290
|
+
let cursor = topic
|
|
1291
|
+
let guard = 0
|
|
1292
|
+
while (cursor.parentId && guard < 30) {
|
|
1293
|
+
const parent = g.topics.find((t) => t.id === cursor.parentId)
|
|
1294
|
+
if (!parent) break
|
|
1295
|
+
level += 1
|
|
1296
|
+
cursor = parent
|
|
1297
|
+
guard += 1
|
|
1298
|
+
}
|
|
1299
|
+
if (level > 3) topic.parentId = null
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// 轮次归属:batch 内每个轮次取「覆盖它且最深」的话题;未覆盖则跟随前一轮
|
|
1303
|
+
const depthOf = (topic) => {
|
|
1304
|
+
let depth = 0
|
|
1305
|
+
let cursor = topic
|
|
1306
|
+
let guard = 0
|
|
1307
|
+
while (cursor?.parentId && guard < 30) {
|
|
1308
|
+
cursor = g.topics.find((t) => t.id === cursor.parentId)
|
|
1309
|
+
depth += 1
|
|
1310
|
+
guard += 1
|
|
1311
|
+
}
|
|
1312
|
+
return depth
|
|
1313
|
+
}
|
|
1314
|
+
let previousTopic = g.lastAssignedTopicId ? g.topics.find((t) => t.id === g.lastAssignedTopicId) : null
|
|
1315
|
+
for (const turn of batch.turns) {
|
|
1316
|
+
const candidates = outTopics.filter((topic) => {
|
|
1317
|
+
const claim = claims.get(topic.id)
|
|
1318
|
+
return claim && turn.turn >= claim.start && turn.turn <= claim.end
|
|
1319
|
+
})
|
|
1320
|
+
let target = null
|
|
1321
|
+
if (candidates.length) {
|
|
1322
|
+
candidates.sort((a, b) => {
|
|
1323
|
+
const depthDiff = depthOf(b) - depthOf(a)
|
|
1324
|
+
if (depthDiff !== 0) return depthDiff
|
|
1325
|
+
return (claims.get(b.id)?.start || 0) - (claims.get(a.id)?.start || 0)
|
|
1326
|
+
})
|
|
1327
|
+
target = candidates[0]
|
|
1328
|
+
} else if (previousTopic) {
|
|
1329
|
+
target = previousTopic
|
|
1330
|
+
} else {
|
|
1331
|
+
// 兜底:取结束轮次最接近且早于本轮的已有话题
|
|
1332
|
+
const past = g.topics
|
|
1333
|
+
.filter((topic) => {
|
|
1334
|
+
const claim = claims.get(topic.id)
|
|
1335
|
+
return claim && claim.end < turn.turn
|
|
1336
|
+
})
|
|
1337
|
+
.sort((a, b) => (claims.get(b.id)?.end || 0) - (claims.get(a.id)?.end || 0))
|
|
1338
|
+
target = past[0] || outTopics[0] || lastTopicInOrder(g)
|
|
1339
|
+
}
|
|
1340
|
+
if (!target) {
|
|
1341
|
+
target = createTopic(g, turn.prompt ? summarize(turn.prompt, 12) : `轮次 ${turn.turn}`)
|
|
1342
|
+
target.source = 'rule'
|
|
1343
|
+
target.stageId = g.stages.length ? g.stages[g.stages.length - 1].id : null
|
|
1344
|
+
outTopics.push(target)
|
|
1345
|
+
}
|
|
1346
|
+
assignTurnToTopic(g, target, turn.turn)
|
|
1347
|
+
previousTopic = target
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// 最新一轮所在话题标为 active(其余保留模型判断),并清理没有任何轮次的话题
|
|
1351
|
+
const lastBatchTurnNo = batch.turns.length ? batch.turns[batch.turns.length - 1].turn : 0
|
|
1352
|
+
for (const topic of g.topics) {
|
|
1353
|
+
if ((topic.ownTurns || []).includes(lastBatchTurnNo)) topic.status = 'active'
|
|
1354
|
+
}
|
|
1355
|
+
g.topics = g.topics.filter((topic) => allTurnNums(g, topic).length > 0)
|
|
1356
|
+
// 被清掉的话题若曾作为父话题,子话题提升为根话题
|
|
1357
|
+
for (const topic of g.topics) {
|
|
1358
|
+
if (topic.parentId && !g.topics.some((t) => t.id === topic.parentId)) topic.parentId = null
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// 关系边:保留旧关系(两端话题仍存在),合并本次 AI 输出的新关系
|
|
1362
|
+
{
|
|
1363
|
+
const live = new Set(g.topics.map((t) => t.id))
|
|
1364
|
+
const kept = (Array.isArray(g.relations) ? g.relations : []).filter((rel) => live.has(rel.from) && live.has(rel.to))
|
|
1365
|
+
const added = resolveRelations(g, parsed.relations, tempToId, existingById)
|
|
1366
|
+
const byKey = new Map()
|
|
1367
|
+
for (const rel of kept) byKey.set(`${rel.from}|${rel.to}|${rel.label}`, rel)
|
|
1368
|
+
for (const rel of added) byKey.set(`${rel.from}|${rel.to}|${rel.label}`, rel)
|
|
1369
|
+
g.relations = [...byKey.values()].slice(0, MAX_RELATIONS)
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// 阶段:合并 AI 输出(保留已有阶段 id/顺序),把根话题按 topicRefs 归入
|
|
1373
|
+
const refsToStage = new Map() // topicId -> stageId
|
|
1374
|
+
const outStages = []
|
|
1375
|
+
const existingStages = g.stages.slice()
|
|
1376
|
+
const resolveTopicRef = (ref) => {
|
|
1377
|
+
if (typeof ref !== 'string') return null
|
|
1378
|
+
const id = tempToId.get(ref) || ref
|
|
1379
|
+
let topic = g.topics.find((t) => t.id === id)
|
|
1380
|
+
if (!topic) return null
|
|
1381
|
+
let guard = 0
|
|
1382
|
+
while (topic.parentId && guard < 30) {
|
|
1383
|
+
const parent = g.topics.find((t) => t.id === topic.parentId)
|
|
1384
|
+
if (!parent) break
|
|
1385
|
+
topic = parent
|
|
1386
|
+
guard += 1
|
|
1387
|
+
}
|
|
1388
|
+
return topic.id
|
|
1389
|
+
}
|
|
1390
|
+
const rawStages = Array.isArray(parsed.stages) ? parsed.stages : []
|
|
1391
|
+
for (const raw of rawStages) {
|
|
1392
|
+
if (!raw || typeof raw !== 'object') continue
|
|
1393
|
+
const rawId = typeof raw.id === 'string' ? raw.id : ''
|
|
1394
|
+
const tempId = typeof raw.tempId === 'string' ? raw.tempId : ''
|
|
1395
|
+
void tempId
|
|
1396
|
+
let stage = rawId ? existingStages.find((s) => s.id === rawId) : null
|
|
1397
|
+
if (!stage) stage = createStage(g, raw.title || `阶段 ${g.stages.length + 1}`)
|
|
1398
|
+
const title = summarize(raw.title || stage.title, STAGE_TITLE_MAX)
|
|
1399
|
+
if (title) stage.title = title
|
|
1400
|
+
if (typeof raw.summary === 'string' && raw.summary) stage.summary = summarize(raw.summary, SUMMARY_MAX)
|
|
1401
|
+
stage.order = outStages.length
|
|
1402
|
+
outStages.push(stage)
|
|
1403
|
+
const refs = Array.isArray(raw.topicRefs) ? raw.topicRefs : []
|
|
1404
|
+
for (const ref of refs) {
|
|
1405
|
+
const topicId = resolveTopicRef(ref)
|
|
1406
|
+
if (topicId) refsToStage.set(topicId, stage.id)
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// 未被输出的已有阶段:若仍有话题则保留
|
|
1411
|
+
for (const stage of existingStages) {
|
|
1412
|
+
if (outStages.includes(stage)) continue
|
|
1413
|
+
const hasTopics = g.topics.some((t) => t.parentId == null && t.stageId === stage.id)
|
|
1414
|
+
if (hasTopics) {
|
|
1415
|
+
stage.order = outStages.length
|
|
1416
|
+
outStages.push(stage)
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
if (!outStages.length) {
|
|
1420
|
+
if (existingStages.length) outStages.push(...existingStages)
|
|
1421
|
+
else outStages.push(createStage(g, '初始阶段'))
|
|
1422
|
+
}
|
|
1423
|
+
g.stages = outStages
|
|
1424
|
+
|
|
1425
|
+
// 根话题:显式引用优先;否则沿用旧归属;再否则放入最后一个阶段
|
|
1426
|
+
const rootTopics = g.topics.filter((t) => t.parentId == null)
|
|
1427
|
+
for (const topic of rootTopics) {
|
|
1428
|
+
const stageId = refsToStage.get(topic.id)
|
|
1429
|
+
if (stageId) topic.stageId = stageId
|
|
1430
|
+
}
|
|
1431
|
+
for (const topic of rootTopics) {
|
|
1432
|
+
if (!topic.stageId || !g.stages.some((s) => s.id === topic.stageId)) {
|
|
1433
|
+
topic.stageId = g.stages[g.stages.length - 1].id
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
// 子话题跟随父话题的阶段
|
|
1437
|
+
for (const topic of g.topics) {
|
|
1438
|
+
if (!topic.parentId) continue
|
|
1439
|
+
const parent = g.topics.find((t) => t.id === topic.parentId)
|
|
1440
|
+
topic.stageId = parent?.stageId || g.stages[g.stages.length - 1].id
|
|
1441
|
+
}
|
|
1442
|
+
// 清理没有任何根话题的空阶段(至少保留一个阶段,作为后续新话题的兜底归属)
|
|
1443
|
+
const nonEmptyStages = g.stages.filter((stage) => g.topics.some((t) => t.parentId == null && t.stageId === stage.id))
|
|
1444
|
+
if (nonEmptyStages.length) {
|
|
1445
|
+
g.stages = nonEmptyStages
|
|
1446
|
+
nonEmptyStages.forEach((stage, index) => { stage.order = index })
|
|
1447
|
+
} else if (!g.stages.length) {
|
|
1448
|
+
g.stages = [createStage(g, '初始阶段')]
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function titleFromPrompt(prompt, turn) {
|
|
1453
|
+
const cleaned = summarize(prompt || '', 16)
|
|
1454
|
+
return cleaned || `轮次 ${turn}`
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/** 规则兜底(无 AI / AI 不可用):按轮次数量分组,保证界面始终可用。 */
|
|
1458
|
+
function applyRuleStructure(g, batch) {
|
|
1459
|
+
let currentStage = g.stages.length ? g.stages[g.stages.length - 1] : createStage(g, '阶段 1')
|
|
1460
|
+
for (const turn of batch.turns) {
|
|
1461
|
+
let current = null
|
|
1462
|
+
const last = lastTopicInOrder(g)
|
|
1463
|
+
if (last && last.parentId == null && last.ownTurns.length > 0
|
|
1464
|
+
&& last.ownTurns.length < cfgRef.ruleTurnsPerTopic
|
|
1465
|
+
&& last.status === 'active') {
|
|
1466
|
+
current = last
|
|
1467
|
+
}
|
|
1468
|
+
if (!current) {
|
|
1469
|
+
const stageRootCount = g.topics.filter((t) => t.parentId == null && t.stageId === currentStage.id).length
|
|
1470
|
+
if (stageRootCount >= cfgRef.ruleTopicsPerStage) {
|
|
1471
|
+
currentStage = createStage(g, `阶段 ${g.stages.length + 1}`)
|
|
1472
|
+
}
|
|
1473
|
+
current = createTopic(g, titleFromPrompt(turn.prompt, turn.turn))
|
|
1474
|
+
current.source = 'rule'
|
|
1475
|
+
current.summary = summarize(turn.prompt || '', SUMMARY_MAX)
|
|
1476
|
+
current.stageId = currentStage.id
|
|
1477
|
+
}
|
|
1478
|
+
assignTurnToTopic(g, current, turn.turn)
|
|
1479
|
+
current.status = 'active'
|
|
1480
|
+
for (const other of g.topics) if (other !== current) other.status = 'done'
|
|
1481
|
+
}
|
|
1482
|
+
if (!g.stages.length) g.stages.push(currentStage)
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/** 增量兜底:扩展上一轮所属话题,不再重排历史。 */
|
|
1486
|
+
function applyExtendFallback(g, batch) {
|
|
1487
|
+
if (!g.topics.length) {
|
|
1488
|
+
applyRuleStructure(g, batch)
|
|
1489
|
+
return
|
|
1490
|
+
}
|
|
1491
|
+
let target = topicOwningTurn(g, batch.turns[0].turn - 1) || lastTopicInOrder(g)
|
|
1492
|
+
for (const turn of batch.turns) {
|
|
1493
|
+
if (!target) {
|
|
1494
|
+
applyRuleStructure(g, { turns: [turn] })
|
|
1495
|
+
target = topicOwningTurn(g, turn.turn)
|
|
1496
|
+
continue
|
|
1497
|
+
}
|
|
1498
|
+
assignTurnToTopic(g, target, turn.turn)
|
|
1499
|
+
if (!target.summary && turn.prompt) target.summary = summarize(turn.prompt, SUMMARY_MAX)
|
|
1500
|
+
target.status = 'active'
|
|
1501
|
+
}
|
|
1502
|
+
for (const topic of g.topics) {
|
|
1503
|
+
if (target && topic === target) continue
|
|
1504
|
+
topic.status = 'done'
|
|
1505
|
+
}
|
|
1506
|
+
// 保证阶段归属存在
|
|
1507
|
+
if (!g.stages.length) createStage(g, '初始阶段')
|
|
1508
|
+
for (const topic of g.topics) {
|
|
1509
|
+
if (!topic.parentId && (!topic.stageId || !g.stages.some((s) => s.id === topic.stageId))) {
|
|
1510
|
+
topic.stageId = g.stages[g.stages.length - 1].id
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// ── 增量切分调度 ────────────────────────────────────────────────────────────
|
|
1516
|
+
|
|
1517
|
+
function lastSettledTurn(g) {
|
|
1518
|
+
let last = 0
|
|
1519
|
+
for (const turn of g.turns) {
|
|
1520
|
+
if (turn.status === 'open') continue
|
|
1521
|
+
if (turn.turn > last) last = turn.turn
|
|
1522
|
+
}
|
|
1523
|
+
return last
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function collectBatch(g, limitTurn) {
|
|
1527
|
+
const turns = []
|
|
1528
|
+
let chars = 0
|
|
1529
|
+
for (const turn of g.turns) {
|
|
1530
|
+
if (turn.turn <= g.segmentedThroughTurn) continue
|
|
1531
|
+
if (turn.turn > limitTurn) break
|
|
1532
|
+
if (turn.status === 'open') break
|
|
1533
|
+
const line = renderTurnLine(turn)
|
|
1534
|
+
if (turns.length >= cfgRef.batchTurns) break
|
|
1535
|
+
if (turns.length > 0 && chars + line.length > cfgRef.maxInputChars) break
|
|
1536
|
+
turns.push({ turn, line })
|
|
1537
|
+
chars += line.length
|
|
1538
|
+
}
|
|
1539
|
+
return { turns: turns.map((item) => item.turn), lines: turns.map((item) => item.line) }
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function buildSegmentInput(g, batch) {
|
|
1543
|
+
const existing = renderExistingStructure(g)
|
|
1544
|
+
const head = g.turns.length ? `【会话轮次总数】${g.turns.length}` : ''
|
|
1545
|
+
const parts = []
|
|
1546
|
+
if (head) parts.push(head)
|
|
1547
|
+
if (existing) parts.push(existing)
|
|
1548
|
+
const links = Array.isArray(g.subagentLinks) ? g.subagentLinks : []
|
|
1549
|
+
if (links.length) {
|
|
1550
|
+
parts.push('【该会话启动的子智能体(它们的完整内容单独切分,这里只用于判断哪些轮次在委托任务,可作为话题归属线索)】')
|
|
1551
|
+
for (const link of links) {
|
|
1552
|
+
parts.push(`- 子会话 ${link.sessionId} "${link.title || '子智能体'}" 从轮${link.parentTurn ?? '?'} 启动,共 ${link.turnCount || 0} 轮`)
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
parts.push(`【需要归属的新轮次(共 ${batch.turns.length} 轮)】`)
|
|
1556
|
+
parts.push(batch.lines.join('\n'))
|
|
1557
|
+
parts.push('')
|
|
1558
|
+
parts.push(g.topics.length
|
|
1559
|
+
? '请基于已有话题/阶段结构,判断这些新轮次的归属(并入已有话题 / 新建话题 / 新建子话题),必要时更新阶段划分,并输出更新后的完整结构 JSON。'
|
|
1560
|
+
: '这是会话最开始的若干轮,请从零建立话题与阶段结构,输出 JSON。')
|
|
1561
|
+
return parts.join('\n')
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/** 单次切分请求可能过长:分批(batchTurns)之外,再按内容长度做二次截断。 */
|
|
1565
|
+
function trimBatchForRequest(batch) {
|
|
1566
|
+
const maxChars = cfgRef.maxInputChars
|
|
1567
|
+
if (!maxChars) return batch
|
|
1568
|
+
const turns = []
|
|
1569
|
+
const lines = []
|
|
1570
|
+
let chars = 0
|
|
1571
|
+
for (let i = 0; i < batch.turns.length; i += 1) {
|
|
1572
|
+
const line = batch.lines[i] || ''
|
|
1573
|
+
if (turns.length > 0 && chars + line.length > maxChars) break
|
|
1574
|
+
turns.push(batch.turns[i])
|
|
1575
|
+
lines.push(line)
|
|
1576
|
+
chars += line.length
|
|
1577
|
+
}
|
|
1578
|
+
return turns.length ? { turns, lines } : batch
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/** 判断模型输出是否完整(截断的输出即使能修复也不应当作成功应用)。 */
|
|
1582
|
+
function looksCompleteJsonText(raw) {
|
|
1583
|
+
let text = String(raw || '').trim()
|
|
1584
|
+
if (!text) return false
|
|
1585
|
+
const fenced = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)]
|
|
1586
|
+
if (fenced.length) text = fenced[fenced.length - 1][1].trim()
|
|
1587
|
+
text = text.replace(/```$/g, '').trim()
|
|
1588
|
+
return text.endsWith('}')
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/** 输出上限:要输出「全部已有话题 + 阶段 + 关系」,话题越多输出越长。 */
|
|
1592
|
+
function segmentMaxTokens(g) {
|
|
1593
|
+
const topics = Array.isArray(g.topics) ? g.topics.length : 0
|
|
1594
|
+
const stages = Array.isArray(g.stages) ? g.stages.length : 0
|
|
1595
|
+
return clampInt(4096 + topics * 90 + stages * 60, 4096, 4096, 16384)
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
async function askLlmSegment(ctx, g, batch) {
|
|
1599
|
+
const input = buildSegmentInput(g, trimBatchForRequest(batch))
|
|
1600
|
+
let maxTokens = segmentMaxTokens(g)
|
|
1601
|
+
let effort // undefined = 使用配置档位;null = 适配器默认档位
|
|
1602
|
+
let lastError = null
|
|
1603
|
+
let parseRetried = false
|
|
1604
|
+
let effortRetried = false
|
|
1605
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
1606
|
+
try {
|
|
1607
|
+
const result = await llmText(ctx, cfgRef, SEGMENT_SYSTEM_PROMPT, input, maxTokens, effort)
|
|
1608
|
+
g.llmProvider = result.provider
|
|
1609
|
+
g.llmModel = result.model
|
|
1610
|
+
accumulateAiUsage(g, result.usage)
|
|
1611
|
+
g.aiCalls += 1
|
|
1612
|
+
const parsed = parseGraphJson(result.text)
|
|
1613
|
+
const complete = parsed ? looksCompleteJsonText(result.text) : false
|
|
1614
|
+
if (parsed && complete) return parsed
|
|
1615
|
+
// 大输出重试之后仍然被截断:能修复出结构就尽量挽救,避免整段退回规则
|
|
1616
|
+
if (parsed && !complete && parseRetried) return parsed
|
|
1617
|
+
const error = new Error('模型输出不是合法的图谱 JSON')
|
|
1618
|
+
error.code = 'PARSE_OUTPUT'
|
|
1619
|
+
throw error
|
|
1620
|
+
} catch (error) {
|
|
1621
|
+
lastError = error
|
|
1622
|
+
// 输出被截断 → 保持批次不变,放大输出上限重试(缩批不能解决输出长度问题)
|
|
1623
|
+
if (error && error.code === 'PARSE_OUTPUT') {
|
|
1624
|
+
if (!parseRetried && maxTokens < 16000) {
|
|
1625
|
+
parseRetried = true
|
|
1626
|
+
maxTokens = Math.min(16000, maxTokens * 2)
|
|
1627
|
+
continue
|
|
1628
|
+
}
|
|
1629
|
+
break
|
|
1630
|
+
}
|
|
1631
|
+
// 400 / INVALID_REQUEST → 换适配器默认推理档位再试一次
|
|
1632
|
+
if (!effortRetried && shouldRetryWithDefaultEffort(error)) {
|
|
1633
|
+
effortRetried = true
|
|
1634
|
+
effort = null
|
|
1635
|
+
continue
|
|
1636
|
+
}
|
|
1637
|
+
break
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
throw lastError || new Error('AI 请求失败')
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// ── 仅按标题判断话题关联(含 A→B→A 回访) ───────────────────────────────
|
|
1644
|
+
|
|
1645
|
+
const RELATION_SYSTEM_PROMPT = [
|
|
1646
|
+
'你是「话题关系分析器」。给定一个会话里按时间顺序排列的话题(标题、轮次、阶段、父子关系、摘要),判断话题之间的语义联系。',
|
|
1647
|
+
'重点关注:',
|
|
1648
|
+
'1. 回访:先说 A,中间说 B,后来又回到 A(输出 from=先说的 A,to=后说的 A,label=回访)。',
|
|
1649
|
+
'2. 延展:先说 A,后又说 A 内部的子问题 A1、A2(输出 from=A,to=A1/A2,label=延展)。',
|
|
1650
|
+
'3. 依赖 / 延续 / 对比 / 修复 / 验证 / 补充 / 同一目标 等有意义的关系。',
|
|
1651
|
+
'4. 只依据标题与摘要判断,不要臆测细枝末节;没有明确联系的不要输出。',
|
|
1652
|
+
'5. 只使用给定的话题 id,输出有向边;尽量让每个话题平均有 1~2 条关系边(不含相邻 next),重复的合并。',
|
|
1653
|
+
'只输出纯 JSON,禁止 markdown 代码块与解释。格式:',
|
|
1654
|
+
'{"relations":[{"from":"话题id","to":"另一话题id","label":"2~6字关系词"}]}',
|
|
1655
|
+
'没有关系时输出 {"relations":[]}。',
|
|
1656
|
+
].join('\n')
|
|
1657
|
+
|
|
1658
|
+
/** 只根据话题标题判断关联;签名未变化时跳过,避免重复烧 token。 */
|
|
1659
|
+
async function refineRelations(ctx, g) {
|
|
1660
|
+
if (cfgRef.relationMode !== 'titles' || cfgRef.segmentMode === 'rule') return
|
|
1661
|
+
const topics = g.topics
|
|
1662
|
+
if (topics.length < 2) {
|
|
1663
|
+
if (g.relations.length) {
|
|
1664
|
+
g.relations = []
|
|
1665
|
+
g.relationsSignature = ''
|
|
1666
|
+
g.version += 1
|
|
1667
|
+
scheduleSave(g)
|
|
1668
|
+
}
|
|
1669
|
+
return
|
|
1670
|
+
}
|
|
1671
|
+
const signature = topics.map((topic) => `${topic.id}:${topic.title}`).join('|')
|
|
1672
|
+
if (g.relationsSignature === signature) return
|
|
1673
|
+
const stageTitle = new Map(g.stages.map((stage) => [stage.id, stage.title]))
|
|
1674
|
+
const byId = topicsById(g)
|
|
1675
|
+
const cap = Math.max(24, Math.min(300, Math.ceil(topics.length * 1.5)))
|
|
1676
|
+
const lines = topics.map((topic) => {
|
|
1677
|
+
const range = topic.ownTurns.length
|
|
1678
|
+
? `轮${topic.ownTurns[0]}-${topic.ownTurns[topic.ownTurns.length - 1]}`
|
|
1679
|
+
: '无轮次'
|
|
1680
|
+
const parent = topic.parentId ? byId.get(topic.parentId) : null
|
|
1681
|
+
const kind = parent ? ` 子话题(父:${parent.title})` : ''
|
|
1682
|
+
const summary = topic.summary ? ` 摘要:${summarize(topic.summary, 40)}` : ''
|
|
1683
|
+
return `${topic.id} "${topic.title}" ${range} 阶段:${stageTitle.get(topic.stageId) || '?'}${kind}${summary}`
|
|
1684
|
+
})
|
|
1685
|
+
const listText = `【话题列表(按时间顺序;共 ${topics.length} 个)】\n${lines.join('\n')}`
|
|
1686
|
+
const live = new Set(topics.map((topic) => topic.id))
|
|
1687
|
+
const parseOut = (text) => {
|
|
1688
|
+
const parsed = parseGraphJson(text)
|
|
1689
|
+
if (!parsed || !Array.isArray(parsed.relations)) return null
|
|
1690
|
+
const list = []
|
|
1691
|
+
const seen = new Set()
|
|
1692
|
+
for (const raw of parsed.relations) {
|
|
1693
|
+
if (!raw || typeof raw !== 'object') continue
|
|
1694
|
+
const from = typeof raw.from === 'string' ? raw.from : ''
|
|
1695
|
+
const to = typeof raw.to === 'string' ? raw.to : ''
|
|
1696
|
+
if (!live.has(from) || !live.has(to) || from === to) continue
|
|
1697
|
+
const key = `${from}>${to}`
|
|
1698
|
+
if (seen.has(key)) continue
|
|
1699
|
+
seen.add(key)
|
|
1700
|
+
list.push({ from, to, label: summarize(raw.label || '关联', 8) })
|
|
1701
|
+
if (list.length >= cap) break
|
|
1702
|
+
}
|
|
1703
|
+
return list
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
const result = await llmText(
|
|
1707
|
+
ctx,
|
|
1708
|
+
cfgRef,
|
|
1709
|
+
RELATION_SYSTEM_PROMPT,
|
|
1710
|
+
`${listText}\n\n请输出关系边;最多 ${cap} 条。`,
|
|
1711
|
+
Math.min(4000, 400 + topics.length * 30),
|
|
1712
|
+
'off',
|
|
1713
|
+
)
|
|
1714
|
+
accumulateAiUsage(g, result.usage)
|
|
1715
|
+
g.aiCalls += 1
|
|
1716
|
+
const out = parseOut(result.text)
|
|
1717
|
+
if (!out) throw new Error('模型输出不是合法的关系 JSON')
|
|
1718
|
+
|
|
1719
|
+
// 合并切分阶段已有的关系(而不是覆盖)
|
|
1720
|
+
const existing = (g.relations || []).filter((rel) => live.has(rel.from) && live.has(rel.to))
|
|
1721
|
+
let merged = mergeRelations(existing, out).slice(0, cap)
|
|
1722
|
+
|
|
1723
|
+
// 二次补偿:对没有任何关系边的孤立话题再跑一次(只接受涉及它们的边)
|
|
1724
|
+
try {
|
|
1725
|
+
const linked = new Set()
|
|
1726
|
+
for (const rel of merged) { linked.add(rel.from); linked.add(rel.to) }
|
|
1727
|
+
const orphans = topics.filter((topic) => !linked.has(topic.id))
|
|
1728
|
+
if (orphans.length && orphans.length < topics.length && topics.length <= 100) {
|
|
1729
|
+
const orphanIds = orphans.map((topic) => topic.id).join(', ')
|
|
1730
|
+
const result2 = await llmText(
|
|
1731
|
+
ctx,
|
|
1732
|
+
cfgRef,
|
|
1733
|
+
RELATION_SYSTEM_PROMPT,
|
|
1734
|
+
`${listText}\n\n以上话题中,以下这些目前没有任何关系边:${orphanIds}。\n请只判断它们与其他话题之间是否存在关系(回访 / 延展 / 依赖 / 延续 / 修复 / 验证 等),输出有向边;最多 ${cap} 条。`,
|
|
1735
|
+
Math.min(4000, 400 + topics.length * 30),
|
|
1736
|
+
'off',
|
|
1737
|
+
)
|
|
1738
|
+
accumulateAiUsage(g, result2.usage)
|
|
1739
|
+
g.aiCalls += 1
|
|
1740
|
+
const extra = parseOut(result2.text)
|
|
1741
|
+
if (extra && extra.length) {
|
|
1742
|
+
const keys = new Set()
|
|
1743
|
+
for (const rel of existing) keys.add(`${rel.from}>${rel.to}`)
|
|
1744
|
+
for (const rel of out) keys.add(`${rel.from}>${rel.to}`)
|
|
1745
|
+
const additions = extra.filter((rel) => !keys.has(`${rel.from}>${rel.to}`))
|
|
1746
|
+
merged = mergeRelations(merged, additions).slice(0, cap)
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
} catch (error) {
|
|
1750
|
+
// 二次补偿失败不影响主结果
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
g.relations = merged
|
|
1754
|
+
g.relationsSignature = signature
|
|
1755
|
+
g.relationError = ''
|
|
1756
|
+
g.version += 1
|
|
1757
|
+
g.updatedAt = nowIso()
|
|
1758
|
+
scheduleSave(g)
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// ── 单轮 AI 分析(how-to) ────────────────────────────────────────────────
|
|
1762
|
+
|
|
1763
|
+
const TURN_ANALYSIS_SYSTEM_PROMPT = [
|
|
1764
|
+
'你是「技术写作助手」。下面给出某一轮对话里模型的执行轨迹(按时间顺序的模型输出与工具调用)。',
|
|
1765
|
+
'请用通俗、面向人的语言,把这一轮「实际做了什么」写成一份分步骤说明(how-to)。',
|
|
1766
|
+
'要求:',
|
|
1767
|
+
'1. 只描述这一轮之内发生的事,不要扩展到其它轮次;',
|
|
1768
|
+
'2. 以「模型输出」和「工具调用」为基本单元,扁平 1~2 层,最多 8 步;',
|
|
1769
|
+
'3. 每步先用一句话说“做了什么”,再补一句目的或结果,尽量不用术语;',
|
|
1770
|
+
'4. 工具名、参数点到为止,重点是“为了什么、得到什么”;',
|
|
1771
|
+
'5. 每个步骤额外标注 stepCount:该步骤覆盖了多少个执行单元(整数,即轨迹里模型输出/工具调用的条数),所有步骤的 stepCount 合计应约等于轨迹单元总数。',
|
|
1772
|
+
'只输出纯 JSON,禁止 markdown 代码块与任何解释,格式:',
|
|
1773
|
+
'{"steps":[{"title":"步骤标题","detail":"通俗说明","tools":["工具名"],"outcome":"这一步的结果/产出","stepCount":2}]}',
|
|
1774
|
+
].join('\n')
|
|
1775
|
+
|
|
1776
|
+
/** 解析单轮分析 JSON:只认含 steps 数组的对象。 */
|
|
1777
|
+
function parseTurnAnalysisJson(raw) {
|
|
1778
|
+
if (typeof raw !== 'string' || !raw.trim()) return null
|
|
1779
|
+
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/)
|
|
1780
|
+
const candidates = fenced && fenced[1] ? [fenced[1], raw] : [raw]
|
|
1781
|
+
for (const candidate of candidates) {
|
|
1782
|
+
const start = candidate.indexOf('{')
|
|
1783
|
+
const end = candidate.lastIndexOf('}')
|
|
1784
|
+
if (start === -1 || end <= start) continue
|
|
1785
|
+
try {
|
|
1786
|
+
const parsed = JSON.parse(candidate.slice(start, end + 1))
|
|
1787
|
+
if (parsed && Array.isArray(parsed.steps)) return parsed
|
|
1788
|
+
} catch { /* try next */ }
|
|
1789
|
+
}
|
|
1790
|
+
return null
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/** 按需重建轨迹 → AI 生成分步说明 → 持久化缓存(签名未变时不重跑)。 */
|
|
1794
|
+
async function analyzeTurn(ctx, sessionId, turnNumber, opts = {}) {
|
|
1795
|
+
const g = await ensureGraph(ctx, sessionId)
|
|
1796
|
+
if (!g.ready) throw new Error('会话尚未就绪')
|
|
1797
|
+
const turn = g.turnIndex.get(turnNumber)
|
|
1798
|
+
if (!turn) throw new Error('轮次不存在')
|
|
1799
|
+
if (turn.status === 'open' || turn.endSeq == null) throw new Error('轮次尚未结束,暂不支持分析')
|
|
1800
|
+
const signature = `${turn.endSeq}:${turn.toolCalls.total}:${turn.requests}`
|
|
1801
|
+
g.turnAnalyses = g.turnAnalyses || {}
|
|
1802
|
+
const cached = g.turnAnalyses[turnNumber]
|
|
1803
|
+
if (!opts.force && cached && cached.signature === signature && Array.isArray(cached.steps) && cached.steps.length) {
|
|
1804
|
+
return cached
|
|
1805
|
+
}
|
|
1806
|
+
// 按需从日志重建轨迹,用完即弃(不驻留内存)
|
|
1807
|
+
const snapshot = await readSessionEvents(ctx, g.sessionId || sessionId)
|
|
1808
|
+
const trace = buildTurnTrace(snapshot ? snapshot.events : [], turnNumber)
|
|
1809
|
+
if (!trace.length) throw new Error('该轮次没有可分析的轨迹')
|
|
1810
|
+
const input = `【轮次】第 ${turnNumber} 轮\n【轨迹单元总数】${trace.length}\n【轨迹】\n${renderTurnTrace(trace)}`
|
|
1811
|
+
const result = await llmText(ctx, cfgRef, TURN_ANALYSIS_SYSTEM_PROMPT, input, 2000, 'off')
|
|
1812
|
+
accumulateAiUsage(g, result.usage)
|
|
1813
|
+
g.aiCalls += 1
|
|
1814
|
+
const parsed = parseTurnAnalysisJson(result.text)
|
|
1815
|
+
const steps = (Array.isArray(parsed && parsed.steps) ? parsed.steps : [])
|
|
1816
|
+
.filter((step) => step && typeof step === 'object')
|
|
1817
|
+
.map((step) => ({
|
|
1818
|
+
title: summarize(String(step.title || step.step || ''), 60) || '步骤',
|
|
1819
|
+
detail: summarize(String(step.detail || step.explain || step.how || ''), 300),
|
|
1820
|
+
tools: Array.isArray(step.tools) ? step.tools.slice(0, 12).map((x) => String(x)) : [],
|
|
1821
|
+
outcome: summarize(String(step.outcome || step.result || ''), 200),
|
|
1822
|
+
stepCount: clampInt(step.stepCount != null ? step.stepCount : step.toolCalls, 1, 0, 999),
|
|
1823
|
+
}))
|
|
1824
|
+
.slice(0, TURN_ANALYSIS_STEPS_MAX)
|
|
1825
|
+
if (!steps.length) {
|
|
1826
|
+
const error = new Error('AI 未生成有效的分步说明')
|
|
1827
|
+
error.code = 'PARSE_OUTPUT'
|
|
1828
|
+
throw error
|
|
1829
|
+
}
|
|
1830
|
+
const analysis = { steps, signature, generatedAt: nowIso() }
|
|
1831
|
+
g.turnAnalyses[turnNumber] = analysis
|
|
1832
|
+
g.version += 1
|
|
1833
|
+
scheduleSave(g)
|
|
1834
|
+
return analysis
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
async function runSegment(ctx, g, opts = {}) {
|
|
1838
|
+
if (!g.ready) return
|
|
1839
|
+
if (g.segRunning) {
|
|
1840
|
+
g.segQueued = true
|
|
1841
|
+
g.segQueuedFull = g.segQueuedFull || Boolean(opts.full)
|
|
1842
|
+
return
|
|
1843
|
+
}
|
|
1844
|
+
g.segRunning = true
|
|
1845
|
+
g.aiStatus = 'running'
|
|
1846
|
+
g.aiError = ''
|
|
1847
|
+
g.relationError = ''
|
|
1848
|
+
g.lastAiAt = nowIso()
|
|
1849
|
+
// 把子智能体信息暴露给切分提示词(子会话内容单独切分,这里只做委托归属线索)
|
|
1850
|
+
try {
|
|
1851
|
+
g.subagentLinks = collectDescendants(g.key, 1).map((item) => {
|
|
1852
|
+
const child = stateOf().graphs.get(item.key)
|
|
1853
|
+
return {
|
|
1854
|
+
sessionId: item.id,
|
|
1855
|
+
title: stateOf().titleCache.get(item.key)?.title || item.meta.title || '',
|
|
1856
|
+
parentTurn: findParentTurnForTime(g, item.meta.createdAt),
|
|
1857
|
+
turnCount: child && child.ready ? child.turns.length : 0,
|
|
1858
|
+
}
|
|
1859
|
+
})
|
|
1860
|
+
} catch { g.subagentLinks = [] }
|
|
1861
|
+
g.aiMode = cfgRef.segmentMode
|
|
1862
|
+
g.version += 1
|
|
1863
|
+
if (opts.full) {
|
|
1864
|
+
g.topics = []
|
|
1865
|
+
g.stages = []
|
|
1866
|
+
g.relations = []
|
|
1867
|
+
g.relationsSignature = ''
|
|
1868
|
+
g.segBatchLimit = cfgRef.batchTurns
|
|
1869
|
+
g.segmentedThroughTurn = 0
|
|
1870
|
+
g.topicSeq = 0
|
|
1871
|
+
g.stageSeq = 0
|
|
1872
|
+
g.lastAssignedTopicId = null
|
|
1873
|
+
}
|
|
1874
|
+
try {
|
|
1875
|
+
let guard = 0
|
|
1876
|
+
// 连续失败的批次数:达到 2 后本会话先降级为规则/扩展切分,避免反复 400 烧钱
|
|
1877
|
+
let consecutiveFailures = 0
|
|
1878
|
+
while (guard < 120) {
|
|
1879
|
+
guard += 1
|
|
1880
|
+
const settled = lastSettledTurn(g)
|
|
1881
|
+
if (settled <= g.segmentedThroughTurn) break
|
|
1882
|
+
const fullBatch = collectBatch(g, settled)
|
|
1883
|
+
if (!fullBatch.turns.length) break
|
|
1884
|
+
let batch = fullBatch
|
|
1885
|
+
let applied = false
|
|
1886
|
+
if (cfgRef.segmentMode !== 'rule' && consecutiveFailures < 2) {
|
|
1887
|
+
// 自适应拆分:请求失败时把本批轮次减半重试,直到单轮;单轮仍失败才回退。
|
|
1888
|
+
// 记住「最近一次成功/收敛的批次大小」,下一批直接用它,避免每次都先撞一次 400。
|
|
1889
|
+
const knownLimit = Math.max(1, Number(g.segBatchLimit) || cfgRef.batchTurns)
|
|
1890
|
+
let size = Math.min(fullBatch.turns.length, knownLimit)
|
|
1891
|
+
let tries = 0
|
|
1892
|
+
while (true) {
|
|
1893
|
+
tries += 1
|
|
1894
|
+
const sub = {
|
|
1895
|
+
turns: fullBatch.turns.slice(0, size),
|
|
1896
|
+
lines: fullBatch.lines.slice(0, size),
|
|
1897
|
+
}
|
|
1898
|
+
try {
|
|
1899
|
+
const parsed = await askLlmSegment(ctx, g, sub)
|
|
1900
|
+
applyAiStructure(g, parsed, sub)
|
|
1901
|
+
applied = true
|
|
1902
|
+
batch = sub
|
|
1903
|
+
g.aiError = ''
|
|
1904
|
+
consecutiveFailures = 0
|
|
1905
|
+
g.segBatchLimit = Math.max(1, Math.min(knownLimit, size))
|
|
1906
|
+
break
|
|
1907
|
+
} catch (error) {
|
|
1908
|
+
batch = sub
|
|
1909
|
+
g.aiError = describeLlmError(error)
|
|
1910
|
+
// 输出解析失败与批次大小无关:不浪费时间逐级缩批,直接回退本批并降级后续
|
|
1911
|
+
if (error && error.code === 'PARSE_OUTPUT') {
|
|
1912
|
+
consecutiveFailures += 1
|
|
1913
|
+
break
|
|
1914
|
+
}
|
|
1915
|
+
if (size <= 1 || tries >= 3) {
|
|
1916
|
+
consecutiveFailures += 1
|
|
1917
|
+
break
|
|
1918
|
+
}
|
|
1919
|
+
size = Math.max(1, Math.floor(size / 2))
|
|
1920
|
+
g.segBatchLimit = size
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
if (!applied) {
|
|
1925
|
+
applyExtendFallback(g, batch)
|
|
1926
|
+
consecutiveFailures = Math.max(consecutiveFailures, 1)
|
|
1927
|
+
}
|
|
1928
|
+
g.segmentedThroughTurn = batch.turns[batch.turns.length - 1].turn
|
|
1929
|
+
g.aiProgress = settled > 0 ? Math.min(1, g.segmentedThroughTurn / settled) : 0
|
|
1930
|
+
g.updatedAt = nowIso()
|
|
1931
|
+
g.lastSegAt = Date.now()
|
|
1932
|
+
g.version += 1
|
|
1933
|
+
scheduleSave(g)
|
|
1934
|
+
}
|
|
1935
|
+
// 批次上限耗尽仍未切完:显式记录,避免静默停在半途
|
|
1936
|
+
if (lastSettledTurn(g) > g.segmentedThroughTurn && guard >= 120) {
|
|
1937
|
+
g.aiError = `切分批次超过上限(120)后暂停:已切 ${g.segmentedThroughTurn}/${lastSettledTurn(g)} 轮`
|
|
1938
|
+
g.aiProgress = lastSettledTurn(g) > 0 ? g.segmentedThroughTurn / lastSettledTurn(g) : 0
|
|
1939
|
+
}
|
|
1940
|
+
// 结构稳定后,再单独用「标题」做一次轻量关系分析(含 A→B→A 回访)
|
|
1941
|
+
if (cfgRef.segmentMode !== 'rule') {
|
|
1942
|
+
try {
|
|
1943
|
+
await refineRelations(ctx, g)
|
|
1944
|
+
} catch (error) {
|
|
1945
|
+
g.relationError = describeLlmError(error)
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
g.aiStatus = g.aiError ? 'error' : 'idle'
|
|
1949
|
+
} catch (error) {
|
|
1950
|
+
g.aiStatus = 'error'
|
|
1951
|
+
g.aiError = describeLlmError(error)
|
|
1952
|
+
} finally {
|
|
1953
|
+
g.segRunning = false
|
|
1954
|
+
g.version += 1
|
|
1955
|
+
g.updatedAt = g.updatedAt || nowIso()
|
|
1956
|
+
scheduleSave(g)
|
|
1957
|
+
if (g.segQueued) {
|
|
1958
|
+
const full = g.segQueuedFull
|
|
1959
|
+
g.segQueued = false
|
|
1960
|
+
g.segQueuedFull = false
|
|
1961
|
+
scheduleSegment(ctx, g, { full })
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
function scheduleSegment(ctx, g, opts = {}) {
|
|
1967
|
+
if (!g.ready) return
|
|
1968
|
+
if (!cfgRef.autoSegment && !opts.full && !opts.force) return
|
|
1969
|
+
if (g.segRunning) {
|
|
1970
|
+
g.segQueued = true
|
|
1971
|
+
g.segQueuedFull = g.segQueuedFull || Boolean(opts.full)
|
|
1972
|
+
return
|
|
1973
|
+
}
|
|
1974
|
+
if (lastSettledTurn(g) <= g.segmentedThroughTurn && !opts.full) return
|
|
1975
|
+
clearTimeout(g.segmentTimer)
|
|
1976
|
+
const sinceLast = Date.now() - (g.lastSegAt || 0)
|
|
1977
|
+
const wait = Math.max(cfgRef.debounceMs, cfgRef.minGapMs - sinceLast, 0)
|
|
1978
|
+
g.segmentTimer = setTimeout(() => {
|
|
1979
|
+
g.segmentTimer = null
|
|
1980
|
+
void runSegment(ctx, g, { full: Boolean(opts.full) })
|
|
1981
|
+
}, wait)
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// ── 持久化 ──────────────────────────────────────────────────────────────────
|
|
1985
|
+
|
|
1986
|
+
function dataRoot() {
|
|
1987
|
+
const home = process.env.DSH_HOME || join(os.homedir(), '.dsh')
|
|
1988
|
+
return join(home, 'data', DATA_DIR_NAME)
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
function graphFile(g) {
|
|
1992
|
+
return join(dataRoot(), `${g.key}.json`)
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function modelFile() {
|
|
1996
|
+
return join(dataRoot(), MODEL_FILE)
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
async function loadModelOverride() {
|
|
2000
|
+
try {
|
|
2001
|
+
const raw = await readFile(modelFile(), 'utf8')
|
|
2002
|
+
const parsed = JSON.parse(raw)
|
|
2003
|
+
if (parsed && typeof parsed.provider === 'string' && typeof parsed.model === 'string') {
|
|
2004
|
+
cfgRef.provider = parsed.provider
|
|
2005
|
+
cfgRef.model = parsed.model
|
|
2006
|
+
}
|
|
2007
|
+
} catch { /* 无覆盖文件或非法:保持 config 默认 */ }
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
async function saveModelOverride(provider, model) {
|
|
2011
|
+
await mkdir(dataRoot(), { recursive: true })
|
|
2012
|
+
const file = modelFile()
|
|
2013
|
+
const tmp = `${file}.tmp`
|
|
2014
|
+
await writeFile(tmp, JSON.stringify({ provider, model }), 'utf8')
|
|
2015
|
+
await rename(tmp, file)
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function serializeGraph(g) {
|
|
2019
|
+
return {
|
|
2020
|
+
v: 1,
|
|
2021
|
+
sessionId: g.sessionId,
|
|
2022
|
+
updatedAt: g.updatedAt || nowIso(),
|
|
2023
|
+
segmentedThroughTurn: g.segmentedThroughTurn,
|
|
2024
|
+
aiGenerated: g.topics.some((topic) => topic.source === 'llm'),
|
|
2025
|
+
aiError: g.aiError || '',
|
|
2026
|
+
aiRetriedAt: g.aiRetriedAt || '',
|
|
2027
|
+
aiTokens: g.aiTokens || emptyTokens(),
|
|
2028
|
+
aiCalls: g.aiCalls || 0,
|
|
2029
|
+
turnAnalyses: g.turnAnalyses || {},
|
|
2030
|
+
topics: g.topics.map((t) => ({
|
|
2031
|
+
id: t.id,
|
|
2032
|
+
title: t.title,
|
|
2033
|
+
summary: t.summary,
|
|
2034
|
+
parentId: t.parentId,
|
|
2035
|
+
ownTurns: t.ownTurns,
|
|
2036
|
+
status: t.status,
|
|
2037
|
+
source: t.source,
|
|
2038
|
+
stageId: t.stageId,
|
|
2039
|
+
})),
|
|
2040
|
+
stages: g.stages.map((s) => ({ id: s.id, title: s.title, summary: s.summary, order: s.order })),
|
|
2041
|
+
relations: (g.relations || []).map((r) => ({ from: r.from, to: r.to, label: r.label })),
|
|
2042
|
+
relationsSignature: g.relationsSignature || '',
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
function scheduleSave(g) {
|
|
2047
|
+
clearTimeout(g.saveTimer)
|
|
2048
|
+
g.saveTimer = setTimeout(() => {
|
|
2049
|
+
g.saveTimer = null
|
|
2050
|
+
void saveGraphNow(g)
|
|
2051
|
+
}, SAVE_DEBOUNCE_MS)
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
async function saveGraphNow(g) {
|
|
2055
|
+
try {
|
|
2056
|
+
const dir = dataRoot()
|
|
2057
|
+
await mkdir(dir, { recursive: true })
|
|
2058
|
+
const file = graphFile(g)
|
|
2059
|
+
const tmp = `${file}.tmp`
|
|
2060
|
+
await writeFile(tmp, JSON.stringify(serializeGraph(g)), 'utf8')
|
|
2061
|
+
await rename(tmp, file)
|
|
2062
|
+
stateOf().segmentKeys.add(g.key)
|
|
2063
|
+
} catch {
|
|
2064
|
+
// 持久化失败不影响内存视图
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
// ── 聚合 / 序列化响应 ───────────────────────────────────────────────────────
|
|
2069
|
+
|
|
2070
|
+
function emptyAggregate() {
|
|
2071
|
+
return {
|
|
2072
|
+
turnCount: 0,
|
|
2073
|
+
tokens: emptyTokens(),
|
|
2074
|
+
toolCalls: { total: 0, byName: {} },
|
|
2075
|
+
wallMs: 0,
|
|
2076
|
+
agentMs: 0,
|
|
2077
|
+
requests: 0,
|
|
2078
|
+
toolErrors: 0,
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
function addTurnToAggregate(agg, turn) {
|
|
2083
|
+
agg.turnCount += 1
|
|
2084
|
+
const tokens = turn.tokens
|
|
2085
|
+
for (const key of Object.keys(agg.tokens)) agg.tokens[key] += numOr0(tokens[key])
|
|
2086
|
+
agg.toolCalls.total += turn.toolCalls.total
|
|
2087
|
+
for (const [toolName, count] of Object.entries(turn.toolCalls.byName)) {
|
|
2088
|
+
agg.toolCalls.byName[toolName] = (agg.toolCalls.byName[toolName] || 0) + count
|
|
2089
|
+
}
|
|
2090
|
+
agg.wallMs += Math.max(0, (turn.endTime || 0) - (turn.startTime || 0))
|
|
2091
|
+
agg.agentMs += Math.max(0, turn.agentMs || 0)
|
|
2092
|
+
agg.requests += turn.requests
|
|
2093
|
+
agg.toolErrors += turn.toolErrors
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function ratio(value, total) {
|
|
2097
|
+
if (!total) return 0
|
|
2098
|
+
return Math.round((value / total) * 10000) / 10000
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
function turnWallMs(turn) {
|
|
2102
|
+
const end = turn.status === 'open' ? Math.max(turn.endTime || 0, turn.lastTime || 0) : (turn.endTime || 0)
|
|
2103
|
+
return Math.max(0, end - (turn.startTime || 0))
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/** 统计视图:把聚合量换算成占比。 */
|
|
2107
|
+
function statsView(agg, totalAgg) {
|
|
2108
|
+
return {
|
|
2109
|
+
turnCount: agg.turnCount,
|
|
2110
|
+
tokens: agg.tokens,
|
|
2111
|
+
tokenRatio: ratio(agg.tokens.total, totalAgg.tokens.total),
|
|
2112
|
+
toolCalls: agg.toolCalls,
|
|
2113
|
+
toolRatio: ratio(agg.toolCalls.total, totalAgg.toolCalls.total),
|
|
2114
|
+
wallMs: agg.wallMs,
|
|
2115
|
+
wallRatio: ratio(agg.wallMs, totalAgg.wallMs),
|
|
2116
|
+
agentMs: agg.agentMs,
|
|
2117
|
+
agentRatio: ratio(agg.agentMs, totalAgg.agentMs),
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
function topicTurnRange(g, topic, children) {
|
|
2122
|
+
const nums = allTurnNums(g, topic, children)
|
|
2123
|
+
return nums.length ? { start: nums[0], end: nums[nums.length - 1], count: nums.length } : { start: null, end: null, count: 0 }
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
/** 话题的 ownTurns 最早轮次(用于排序)。 */
|
|
2127
|
+
function minOwnTurn(topic) {
|
|
2128
|
+
return Array.isArray(topic.ownTurns) && topic.ownTurns.length ? topic.ownTurns[0] : Infinity
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/** 构建话题 / 阶段 / 轮次三类统计(不含 Edge / 子智能体)。 */
|
|
2132
|
+
function computeGraphStats(g, turns) {
|
|
2133
|
+
const totalAgg = emptyAggregate()
|
|
2134
|
+
for (const turn of turns) addTurnToAggregate(totalAgg, turn)
|
|
2135
|
+
|
|
2136
|
+
const children = childrenIndex(g)
|
|
2137
|
+
const topicAgg = new Map()
|
|
2138
|
+
for (const topic of g.topics) {
|
|
2139
|
+
const agg = emptyAggregate()
|
|
2140
|
+
for (const n of allTurnNums(g, topic, children)) {
|
|
2141
|
+
const turn = g.turnIndex.get(n)
|
|
2142
|
+
if (turn) addTurnToAggregate(agg, turn)
|
|
2143
|
+
}
|
|
2144
|
+
topicAgg.set(topic.id, agg)
|
|
2145
|
+
}
|
|
2146
|
+
const stageAgg = new Map()
|
|
2147
|
+
for (const stage of g.stages) {
|
|
2148
|
+
const agg = emptyAggregate()
|
|
2149
|
+
for (const topic of g.topics) {
|
|
2150
|
+
if (topic.parentId != null || topic.stageId !== stage.id) continue
|
|
2151
|
+
for (const n of allTurnNums(g, topic, children)) {
|
|
2152
|
+
const turn = g.turnIndex.get(n)
|
|
2153
|
+
if (turn) addTurnToAggregate(agg, turn)
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
stageAgg.set(stage.id, agg)
|
|
2157
|
+
}
|
|
2158
|
+
return { totalAgg, topicAgg, stageAgg }
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
/** 每条话题链(next)、阶段 / 父话题归属(belongTo)、AI 关系(has_relation)、子智能体委托(delegates)。 */
|
|
2162
|
+
/** 标题相似度(字符二元组 Dice 系数),用于确定性识别「回访」。 */
|
|
2163
|
+
function titleSimilarity(a, b) {
|
|
2164
|
+
const norm = (value) => String(value || '').replace(/[\s\p{P}\p{S}]/gu, '').toLowerCase()
|
|
2165
|
+
const x = norm(a)
|
|
2166
|
+
const y = norm(b)
|
|
2167
|
+
if (!x || !y) return 0
|
|
2168
|
+
if (x === y) return 1
|
|
2169
|
+
const grams = (value) => {
|
|
2170
|
+
const set = new Set()
|
|
2171
|
+
for (let i = 0; i < value.length - 1; i += 1) set.add(value.slice(i, i + 2))
|
|
2172
|
+
return set
|
|
2173
|
+
}
|
|
2174
|
+
const gx = grams(x)
|
|
2175
|
+
const gy = grams(y)
|
|
2176
|
+
if (!gx.size || !gy.size) return 0
|
|
2177
|
+
let inter = 0
|
|
2178
|
+
for (const gram of gx) if (gy.has(gram)) inter += 1
|
|
2179
|
+
return (2 * inter) / (gx.size + gy.size)
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
const REVISIT_TITLE_THRESHOLD = 0.7
|
|
2183
|
+
|
|
2184
|
+
/** 确定性「回访」边:标题高度相似、但被切成两个话题时兜底补一条(AI 可能漏判)。 */
|
|
2185
|
+
function deriveRevisitRelations(g) {
|
|
2186
|
+
const ordered = g.topics.slice().sort((a, b) => minOwnTurn(a) - minOwnTurn(b))
|
|
2187
|
+
const existing = new Set()
|
|
2188
|
+
for (const rel of g.relations || []) {
|
|
2189
|
+
existing.add(`${rel.from}>${rel.to}`)
|
|
2190
|
+
existing.add(`${rel.to}>${rel.from}`)
|
|
2191
|
+
}
|
|
2192
|
+
const out = []
|
|
2193
|
+
for (let i = 0; i < ordered.length && out.length < 12; i += 1) {
|
|
2194
|
+
for (let j = i + 1; j < ordered.length && out.length < 12; j += 1) {
|
|
2195
|
+
const a = ordered[i]
|
|
2196
|
+
const b = ordered[j]
|
|
2197
|
+
if (a.parentId === b.id || b.parentId === a.id) continue
|
|
2198
|
+
if (existing.has(`${a.id}>${b.id}`)) continue
|
|
2199
|
+
if (titleSimilarity(a.title, b.title) < REVISIT_TITLE_THRESHOLD) continue
|
|
2200
|
+
out.push({ from: a.id, to: b.id, label: '回访' })
|
|
2201
|
+
existing.add(`${a.id}>${b.id}`)
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
return out
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
/** 确定性「延展」边:父话题 -> 它的每个子话题(和层级 belongTo 并存,用于时间线/结构图展示)。 */
|
|
2208
|
+
function deriveExtensionRelations(g) {
|
|
2209
|
+
const byId = topicsById(g)
|
|
2210
|
+
const out = []
|
|
2211
|
+
for (const topic of g.topics) {
|
|
2212
|
+
if (topic.parentId == null) continue
|
|
2213
|
+
const parent = byId.get(topic.parentId)
|
|
2214
|
+
if (!parent) continue
|
|
2215
|
+
out.push({ from: parent.id, to: topic.id, label: '延展', kind: 'extension' })
|
|
2216
|
+
}
|
|
2217
|
+
return out
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
/** 合并 AI 关系与确定性回访边(去重、保序)。 */
|
|
2221
|
+
function mergeRelations(base, extra) {
|
|
2222
|
+
const merged = []
|
|
2223
|
+
const seen = new Set()
|
|
2224
|
+
for (const rel of [...(base || []), ...(extra || [])]) {
|
|
2225
|
+
const key = `${rel.from}>${rel.to}:${rel.label}`
|
|
2226
|
+
if (seen.has(key)) continue
|
|
2227
|
+
seen.add(key)
|
|
2228
|
+
merged.push(rel)
|
|
2229
|
+
}
|
|
2230
|
+
return merged
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
function buildEdges(g, branches, relations) {
|
|
2234
|
+
const edges = []
|
|
2235
|
+
const roots = g.topics.filter((topic) => topic.parentId == null)
|
|
2236
|
+
const ordered = roots.slice().sort((a, b) => minOwnTurn(a) - minOwnTurn(b))
|
|
2237
|
+
for (let i = 0; i < ordered.length - 1; i += 1) {
|
|
2238
|
+
edges.push({
|
|
2239
|
+
id: `next:${ordered[i].id}>${ordered[i + 1].id}`,
|
|
2240
|
+
type: 'next',
|
|
2241
|
+
from: ordered[i].id,
|
|
2242
|
+
to: ordered[i + 1].id,
|
|
2243
|
+
})
|
|
2244
|
+
}
|
|
2245
|
+
for (const topic of roots) {
|
|
2246
|
+
if (!topic.stageId) continue
|
|
2247
|
+
edges.push({ id: `belong:${topic.id}`, type: 'belongTo', kind: 'stage', from: topic.id, to: topic.stageId })
|
|
2248
|
+
}
|
|
2249
|
+
for (const topic of g.topics) {
|
|
2250
|
+
if (!topic.parentId) continue
|
|
2251
|
+
edges.push({ id: `belong:${topic.id}`, type: 'belongTo', kind: 'topic', from: topic.id, to: topic.parentId })
|
|
2252
|
+
}
|
|
2253
|
+
for (const rel of relations || g.relations || []) {
|
|
2254
|
+
edges.push({ id: `rel:${rel.from}>${rel.to}:${rel.label}`, type: 'has_relation', from: rel.from, to: rel.to, label: rel.label, kind: rel.kind })
|
|
2255
|
+
}
|
|
2256
|
+
for (const branch of branches || []) {
|
|
2257
|
+
if (!branch.parentTopicId) continue
|
|
2258
|
+
edges.push({
|
|
2259
|
+
id: `deleg:${branch.parentTopicId}>${branch.sessionId}`,
|
|
2260
|
+
type: 'delegates',
|
|
2261
|
+
from: branch.parentTopicId,
|
|
2262
|
+
to: branch.sessionId,
|
|
2263
|
+
label: '子智能体',
|
|
2264
|
+
})
|
|
2265
|
+
}
|
|
2266
|
+
return edges
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
/** 把子智能体分支的 token / 时间汇总到父话题上。 */
|
|
2270
|
+
function attachBranchesToTopics(topicsPayload, branches) {
|
|
2271
|
+
const byTopic = new Map()
|
|
2272
|
+
for (const branch of branches || []) {
|
|
2273
|
+
if (!branch.parentTopicId) continue
|
|
2274
|
+
if (!byTopic.has(branch.parentTopicId)) byTopic.set(branch.parentTopicId, [])
|
|
2275
|
+
byTopic.get(branch.parentTopicId).push(branch)
|
|
2276
|
+
}
|
|
2277
|
+
for (const topic of topicsPayload) {
|
|
2278
|
+
const attached = byTopic.get(topic.id)
|
|
2279
|
+
if (!attached || !attached.length) continue
|
|
2280
|
+
const branchAgg = emptyAggregate()
|
|
2281
|
+
for (const branch of attached) {
|
|
2282
|
+
const totals = branch.totals || {}
|
|
2283
|
+
const tokens = totals.tokens || {}
|
|
2284
|
+
for (const key of Object.keys(branchAgg.tokens)) branchAgg.tokens[key] += numOr0(tokens[key])
|
|
2285
|
+
branchAgg.turnCount += numOr0(totals.turnCount)
|
|
2286
|
+
branchAgg.toolCalls.total += numOr0(totals.toolCalls?.total)
|
|
2287
|
+
branchAgg.wallMs += numOr0(totals.wallMs)
|
|
2288
|
+
branchAgg.agentMs += numOr0(totals.agentMs)
|
|
2289
|
+
}
|
|
2290
|
+
topic.branchIds = attached.map((b) => b.sessionId)
|
|
2291
|
+
topic.branchStats = {
|
|
2292
|
+
branchCount: attached.length,
|
|
2293
|
+
turnCount: branchAgg.turnCount,
|
|
2294
|
+
tokens: branchAgg.tokens,
|
|
2295
|
+
toolCalls: branchAgg.toolCalls,
|
|
2296
|
+
wallMs: branchAgg.wallMs,
|
|
2297
|
+
agentMs: branchAgg.agentMs,
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
function buildGraphPayload(g, title, branches) {
|
|
2303
|
+
const branchList = Array.isArray(branches) ? branches : []
|
|
2304
|
+
const turns = snapshotTurns(g)
|
|
2305
|
+
const stats = computeGraphStats(g, turns)
|
|
2306
|
+
const { totalAgg, topicAgg, stageAgg } = stats
|
|
2307
|
+
|
|
2308
|
+
const children = childrenIndex(g)
|
|
2309
|
+
const byId = topicsById(g)
|
|
2310
|
+
const topicsPayload = g.topics.map((topic) => {
|
|
2311
|
+
const range = topicTurnRange(g, topic, children)
|
|
2312
|
+
const agg = topicAgg.get(topic.id) || emptyAggregate()
|
|
2313
|
+
return {
|
|
2314
|
+
id: topic.id,
|
|
2315
|
+
title: topic.title,
|
|
2316
|
+
summary: topic.summary,
|
|
2317
|
+
parentId: topic.parentId,
|
|
2318
|
+
stageId: topic.stageId,
|
|
2319
|
+
status: topic.status,
|
|
2320
|
+
source: topic.source,
|
|
2321
|
+
level: topicDepth(byId, topic),
|
|
2322
|
+
childIds: (children.get(topic.id) || []).map((t) => t.id),
|
|
2323
|
+
turnStart: range.start,
|
|
2324
|
+
turnEnd: range.end,
|
|
2325
|
+
ownTurns: topic.ownTurns.slice(),
|
|
2326
|
+
stats: statsView(agg, totalAgg),
|
|
2327
|
+
}
|
|
2328
|
+
})
|
|
2329
|
+
attachBranchesToTopics(topicsPayload, branchList)
|
|
2330
|
+
|
|
2331
|
+
const stagesPayload = g.stages.map((stage) => {
|
|
2332
|
+
const rootIds = g.topics.filter((t) => t.parentId == null && t.stageId === stage.id).map((t) => t.id)
|
|
2333
|
+
const agg = stageAgg.get(stage.id) || emptyAggregate()
|
|
2334
|
+
return {
|
|
2335
|
+
id: stage.id,
|
|
2336
|
+
title: stage.title,
|
|
2337
|
+
summary: stage.summary,
|
|
2338
|
+
order: stage.order,
|
|
2339
|
+
topicIds: rootIds,
|
|
2340
|
+
stats: statsView(agg, totalAgg),
|
|
2341
|
+
}
|
|
2342
|
+
}).sort((a, b) => a.order - b.order)
|
|
2343
|
+
|
|
2344
|
+
const mergedRelations = mergeRelations(
|
|
2345
|
+
mergeRelations(g.relations, deriveRevisitRelations(g)),
|
|
2346
|
+
deriveExtensionRelations(g),
|
|
2347
|
+
)
|
|
2348
|
+
const lastTurn = turns.length ? turns[turns.length - 1] : null
|
|
2349
|
+
const settled = lastSettledTurn(g)
|
|
2350
|
+
const subagentAgg = emptyAggregate()
|
|
2351
|
+
for (const branch of branchList) {
|
|
2352
|
+
const totals = branch.totals || {}
|
|
2353
|
+
const tokens = totals.tokens || {}
|
|
2354
|
+
for (const key of Object.keys(subagentAgg.tokens)) subagentAgg.tokens[key] += numOr0(tokens[key])
|
|
2355
|
+
subagentAgg.turnCount += numOr0(totals.turnCount)
|
|
2356
|
+
subagentAgg.toolCalls.total += numOr0(totals.toolCalls?.total)
|
|
2357
|
+
subagentAgg.wallMs += numOr0(totals.wallMs)
|
|
2358
|
+
subagentAgg.agentMs += numOr0(totals.agentMs)
|
|
2359
|
+
}
|
|
2360
|
+
const totalToolCalls = totalAgg.toolCalls.total || 0
|
|
2361
|
+
const totalTurnMs = totalAgg.wallMs || 0
|
|
2362
|
+
const totalAgentMs = totalAgg.agentMs || 0
|
|
2363
|
+
|
|
2364
|
+
return {
|
|
2365
|
+
ok: true,
|
|
2366
|
+
version: g.version,
|
|
2367
|
+
sessionId: g.sessionId,
|
|
2368
|
+
key: g.key,
|
|
2369
|
+
title: title || '',
|
|
2370
|
+
parentSessionId: g.parentSessionId || null,
|
|
2371
|
+
depth: Number(g.depth) || 0,
|
|
2372
|
+
updatedAt: g.updatedAt || '',
|
|
2373
|
+
ai: {
|
|
2374
|
+
mode: cfgRef.segmentMode,
|
|
2375
|
+
relationMode: cfgRef.relationMode,
|
|
2376
|
+
status: g.aiStatus,
|
|
2377
|
+
error: g.aiError,
|
|
2378
|
+
relationError: g.relationError || '',
|
|
2379
|
+
lastErrorAt: g.llmLastErrorAt || '',
|
|
2380
|
+
tokens: g.aiTokens,
|
|
2381
|
+
calls: g.aiCalls,
|
|
2382
|
+
progress: g.aiProgress || 0,
|
|
2383
|
+
pendingTurns: Math.max(0, settled - g.segmentedThroughTurn),
|
|
2384
|
+
segmentedThroughTurn: g.segmentedThroughTurn,
|
|
2385
|
+
lastAt: g.lastAiAt,
|
|
2386
|
+
provider: g.llmProvider,
|
|
2387
|
+
model: g.llmModel,
|
|
2388
|
+
},
|
|
2389
|
+
totals: {
|
|
2390
|
+
turnCount: turns.length,
|
|
2391
|
+
settledTurnCount: settled,
|
|
2392
|
+
tokens: totalAgg.tokens,
|
|
2393
|
+
toolCalls: totalAgg.toolCalls,
|
|
2394
|
+
wallMs: totalAgg.wallMs,
|
|
2395
|
+
agentMs: totalAgg.agentMs,
|
|
2396
|
+
requests: totalAgg.requests,
|
|
2397
|
+
toolErrors: totalAgg.toolErrors,
|
|
2398
|
+
openTurn: lastTurn && lastTurn.status === 'open' ? lastTurn.turn : null,
|
|
2399
|
+
topicCount: g.topics.filter((topic) => topic.parentId == null).length,
|
|
2400
|
+
subtopicCount: g.topics.filter((topic) => topic.parentId != null).length,
|
|
2401
|
+
stageCount: g.stages.length,
|
|
2402
|
+
relationCount: (g.relations || []).length,
|
|
2403
|
+
subagent: {
|
|
2404
|
+
branchCount: branchList.length,
|
|
2405
|
+
turnCount: subagentAgg.turnCount,
|
|
2406
|
+
tokens: subagentAgg.tokens,
|
|
2407
|
+
toolCalls: subagentAgg.toolCalls,
|
|
2408
|
+
wallMs: subagentAgg.wallMs,
|
|
2409
|
+
agentMs: subagentAgg.agentMs,
|
|
2410
|
+
},
|
|
2411
|
+
},
|
|
2412
|
+
turns: turns.map((turn) => ({
|
|
2413
|
+
turn: turn.turn,
|
|
2414
|
+
startTime: turn.startTime,
|
|
2415
|
+
endTime: turn.status === 'open' ? Math.max(turn.endTime || 0, turn.lastTime || 0) : turn.endTime,
|
|
2416
|
+
status: turn.status,
|
|
2417
|
+
prompt: summarize(turn.prompt, TURN_PROMPT_PREVIEW),
|
|
2418
|
+
assistant: summarize(turn.assistant, TURN_ASSISTANT_PREVIEW),
|
|
2419
|
+
promptFull: turn.prompt.length > TURN_PROMPT_PREVIEW,
|
|
2420
|
+
assistantFull: turn.assistant.length > TURN_ASSISTANT_PREVIEW,
|
|
2421
|
+
tokens: turn.tokens,
|
|
2422
|
+
tokenRatio: ratio(turn.tokens.total, totalAgg.tokens.total),
|
|
2423
|
+
toolCalls: turn.toolCalls,
|
|
2424
|
+
toolRatio: ratio(turn.toolCalls.total, totalToolCalls),
|
|
2425
|
+
wallMs: turnWallMs(turn),
|
|
2426
|
+
wallRatio: ratio(turnWallMs(turn), totalTurnMs),
|
|
2427
|
+
agentMs: Math.max(0, turn.agentMs),
|
|
2428
|
+
agentRatio: ratio(turn.agentMs, totalAgentMs),
|
|
2429
|
+
steps: turn.steps,
|
|
2430
|
+
requests: turn.requests,
|
|
2431
|
+
toolErrors: turn.toolErrors,
|
|
2432
|
+
reasoningChars: turn.reasoningChars,
|
|
2433
|
+
provider: turn.provider,
|
|
2434
|
+
model: turn.model,
|
|
2435
|
+
})),
|
|
2436
|
+
topics: topicsPayload,
|
|
2437
|
+
stages: stagesPayload,
|
|
2438
|
+
relations: mergedRelations.slice(),
|
|
2439
|
+
turnAnalyses: g.turnAnalyses || {},
|
|
2440
|
+
branches: branchList,
|
|
2441
|
+
edges: buildEdges(g, branchList, mergedRelations),
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
// ── 子智能体分支 ───────────────────────────────────────────────────────────
|
|
2446
|
+
|
|
2447
|
+
/** 子智能体会话的轻量分支视图:主题、统计、与父话题的委托关系。 */
|
|
2448
|
+
function buildBranchPayload(childGraph, parentGraph, meta, depth, title) {
|
|
2449
|
+
const childTurns = snapshotTurns(childGraph)
|
|
2450
|
+
const stats = computeGraphStats(childGraph, childTurns)
|
|
2451
|
+
const { totalAgg, topicAgg, stageAgg } = stats
|
|
2452
|
+
const firstTime = childTurns.length ? (childTurns[0].startTime || meta.createdAt || 0) : (meta.createdAt || 0)
|
|
2453
|
+
const parentTurn = findParentTurnForTime(parentGraph, firstTime)
|
|
2454
|
+
const parentTopic = parentTurn != null
|
|
2455
|
+
? (topicOwningTurn(parentGraph, parentTurn) || lastTopicInOrder(parentGraph))
|
|
2456
|
+
: lastTopicInOrder(parentGraph)
|
|
2457
|
+
const lastTurn = childTurns.length ? childTurns[childTurns.length - 1] : null
|
|
2458
|
+
return {
|
|
2459
|
+
sessionId: childGraph.sessionId,
|
|
2460
|
+
key: childGraph.key,
|
|
2461
|
+
title: title || meta.title || '',
|
|
2462
|
+
depth,
|
|
2463
|
+
parentSessionId: parentGraph.sessionId,
|
|
2464
|
+
parentTurn,
|
|
2465
|
+
parentTopicId: parentTopic ? parentTopic.id : null,
|
|
2466
|
+
version: childGraph.version,
|
|
2467
|
+
pending: childTurns.length > 0 && childGraph.topics.length === 0,
|
|
2468
|
+
ai: {
|
|
2469
|
+
status: childGraph.aiStatus,
|
|
2470
|
+
error: childGraph.aiError,
|
|
2471
|
+
pendingTurns: Math.max(0, lastSettledTurn(childGraph) - childGraph.segmentedThroughTurn),
|
|
2472
|
+
segmentedThroughTurn: childGraph.segmentedThroughTurn,
|
|
2473
|
+
},
|
|
2474
|
+
totals: {
|
|
2475
|
+
turnCount: childTurns.length,
|
|
2476
|
+
settledTurnCount: lastSettledTurn(childGraph),
|
|
2477
|
+
tokens: totalAgg.tokens,
|
|
2478
|
+
toolCalls: totalAgg.toolCalls,
|
|
2479
|
+
wallMs: totalAgg.wallMs,
|
|
2480
|
+
agentMs: totalAgg.agentMs,
|
|
2481
|
+
openTurn: lastTurn && lastTurn.status === 'open' ? lastTurn.turn : null,
|
|
2482
|
+
},
|
|
2483
|
+
topics: childGraph.topics.map((topic) => {
|
|
2484
|
+
const range = topicTurnRange(childGraph, topic, childrenIndex(childGraph))
|
|
2485
|
+
return {
|
|
2486
|
+
id: topic.id,
|
|
2487
|
+
title: topic.title,
|
|
2488
|
+
summary: topic.summary,
|
|
2489
|
+
parentId: topic.parentId,
|
|
2490
|
+
stageId: topic.stageId,
|
|
2491
|
+
status: topic.status,
|
|
2492
|
+
turnStart: range.start,
|
|
2493
|
+
turnEnd: range.end,
|
|
2494
|
+
stats: statsView(topicAgg.get(topic.id) || emptyAggregate(), totalAgg),
|
|
2495
|
+
}
|
|
2496
|
+
}),
|
|
2497
|
+
stages: childGraph.stages.map((stage) => ({
|
|
2498
|
+
id: stage.id,
|
|
2499
|
+
title: stage.title,
|
|
2500
|
+
summary: stage.summary,
|
|
2501
|
+
order: stage.order,
|
|
2502
|
+
topicIds: childGraph.topics.filter((t) => t.parentId == null && t.stageId === stage.id).map((t) => t.id),
|
|
2503
|
+
stats: statsView(stageAgg.get(stage.id) || emptyAggregate(), totalAgg),
|
|
2504
|
+
})).sort((a, b) => a.order - b.order),
|
|
2505
|
+
relations: (childGraph.relations || []).slice(),
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
/** 为父图谱收集子智能体分支(含嵌套子会话)。 */
|
|
2510
|
+
async function buildBranches(ctx, parentGraph) {
|
|
2511
|
+
if (!cfgRef.includeSubagents) return []
|
|
2512
|
+
const state = stateOf()
|
|
2513
|
+
// 客户端可能先请求 /graph 再请求 /sessions:这里补一次血缘加载,否则看不到子智能体
|
|
2514
|
+
if (!state.lineageAt || Date.now() - state.lineageAt > 60000) {
|
|
2515
|
+
try { await listSessionsPayload(ctx) } catch { /* 血缘加载失败不影响主图 */ }
|
|
2516
|
+
}
|
|
2517
|
+
const maxDepth = clampInt(cfgRef.maxSubagentDepth, 2, 1, 5)
|
|
2518
|
+
const maxBranches = clampInt(cfgRef.maxBranches, 20, 1, 60)
|
|
2519
|
+
const descendants = collectDescendants(parentGraph.key, maxDepth).slice(0, maxBranches)
|
|
2520
|
+
if (!descendants.length) return []
|
|
2521
|
+
let titles = new Map()
|
|
2522
|
+
try { titles = await readTitles(ctx, descendants.map((item) => item.id)) } catch { /* 标题只是展示 */ }
|
|
2523
|
+
const branches = await Promise.all(descendants.map(async (item) => {
|
|
2524
|
+
const title = titles.get(item.id) || item.meta.title || ''
|
|
2525
|
+
let childGraph = null
|
|
2526
|
+
try { childGraph = await ensureGraph(ctx, item.id) } catch { childGraph = null }
|
|
2527
|
+
if (!childGraph || !childGraph.ready) {
|
|
2528
|
+
return {
|
|
2529
|
+
sessionId: item.id,
|
|
2530
|
+
key: item.key,
|
|
2531
|
+
title,
|
|
2532
|
+
depth: item.depth,
|
|
2533
|
+
parentSessionId: parentGraph.sessionId,
|
|
2534
|
+
parentTurn: null,
|
|
2535
|
+
parentTopicId: null,
|
|
2536
|
+
version: 0,
|
|
2537
|
+
pending: true,
|
|
2538
|
+
ai: { status: 'idle', error: '', pendingTurns: 0, segmentedThroughTurn: 0 },
|
|
2539
|
+
totals: { turnCount: 0, settledTurnCount: 0, tokens: emptyTokens(), toolCalls: { total: 0, byName: {} }, wallMs: 0, agentMs: 0, openTurn: null },
|
|
2540
|
+
topics: [],
|
|
2541
|
+
stages: [],
|
|
2542
|
+
relations: [],
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
return buildBranchPayload(childGraph, parentGraph, item.meta, item.depth, title)
|
|
2546
|
+
}))
|
|
2547
|
+
return branches
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// ── 标题 / 会话列表 ─────────────────────────────────────────────────────────
|
|
2551
|
+
|
|
2552
|
+
async function readTitles(ctx, ids) {
|
|
2553
|
+
const state = stateOf()
|
|
2554
|
+
const now = Date.now()
|
|
2555
|
+
const missing = []
|
|
2556
|
+
for (const id of ids) {
|
|
2557
|
+
const cached = state.titleCache.get(normSessionId(id))
|
|
2558
|
+
if (!cached || now - cached.at > TITLE_TTL_MS) missing.push(id)
|
|
2559
|
+
}
|
|
2560
|
+
if (missing.length && ctx.sessionQuery && typeof ctx.sessionQuery.readTitleSnapshots === 'function') {
|
|
2561
|
+
try {
|
|
2562
|
+
const results = await ctx.sessionQuery.readTitleSnapshots(missing)
|
|
2563
|
+
const { fulfilled } = groupTitleResults(results)
|
|
2564
|
+
for (const [id, value] of fulfilled) {
|
|
2565
|
+
state.titleCache.set(normSessionId(id), { title: value, at: now })
|
|
2566
|
+
}
|
|
2567
|
+
capTitleCache(state)
|
|
2568
|
+
} catch { /* 忽略:标题只是展示信息 */ }
|
|
2569
|
+
}
|
|
2570
|
+
const out = new Map()
|
|
2571
|
+
for (const id of ids) {
|
|
2572
|
+
const cached = state.titleCache.get(normSessionId(id))
|
|
2573
|
+
if (cached) out.set(id, cached.title)
|
|
2574
|
+
}
|
|
2575
|
+
return out
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
function groupTitleResults(results) {
|
|
2579
|
+
const fulfilled = new Map()
|
|
2580
|
+
if (!Array.isArray(results)) return { fulfilled }
|
|
2581
|
+
for (const item of results) {
|
|
2582
|
+
if (!item || item.status !== 'fulfilled' || !item.value) continue
|
|
2583
|
+
const id = item.sessionId || item.value.session?.id
|
|
2584
|
+
const title = item.value.title?.title
|
|
2585
|
+
if (id && typeof title === 'string' && title) fulfilled.set(id, title)
|
|
2586
|
+
}
|
|
2587
|
+
return { fulfilled }
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
async function listSessionsPayload(ctx) {
|
|
2591
|
+
const state = stateOf()
|
|
2592
|
+
const now = Date.now()
|
|
2593
|
+
if (state.sessionsCache && now - state.sessionsCache.at < SESSIONS_TTL_MS) return state.sessionsCache.value
|
|
2594
|
+
const value = { version: 0, sessions: [], current: null }
|
|
2595
|
+
try {
|
|
2596
|
+
const records = await ctx.sessionQuery.listSessions()
|
|
2597
|
+
const allRecords = (Array.isArray(records) ? records : []).filter((record) => record && record.header)
|
|
2598
|
+
// 会话血缘(父子 / 委托深度)必须先于列表过滤记录,供子智能体分支使用
|
|
2599
|
+
const state = stateOf()
|
|
2600
|
+
for (const record of allRecords) {
|
|
2601
|
+
updateSessionMeta(record.header.id, record.header)
|
|
2602
|
+
const meta = state.sessionParents.get(normSessionId(record.header.id))
|
|
2603
|
+
if (meta) meta.lastActiveAt = Math.max(meta.lastActiveAt || 0, state.lastSeen.get(meta.key) || 0)
|
|
2604
|
+
}
|
|
2605
|
+
const filtered = allRecords
|
|
2606
|
+
.filter((record) => record && record.header)
|
|
2607
|
+
.filter((record) => cfgRef.showSubagents || !isSubagentHeader(record.header))
|
|
2608
|
+
.sort((a, b) => (b.header.createdAt || 0) - (a.header.createdAt || 0))
|
|
2609
|
+
.slice(0, SESSIONS_LIMIT)
|
|
2610
|
+
const ids = filtered.map((record) => record.header.id)
|
|
2611
|
+
const titles = await readTitles(ctx, ids)
|
|
2612
|
+
const liveIds = new Set()
|
|
2613
|
+
try {
|
|
2614
|
+
const sessionsService = getHostService(ctx, 'sessions')
|
|
2615
|
+
const live = sessionsService && typeof sessionsService.list === 'function' ? sessionsService.list() : []
|
|
2616
|
+
for (const session of live || []) liveIds.add(normSessionId(session.id))
|
|
2617
|
+
} catch { /* 忽略 */ }
|
|
2618
|
+
const rows = filtered.map((record) => {
|
|
2619
|
+
const id = record.header.id
|
|
2620
|
+
const key = normSessionId(id)
|
|
2621
|
+
const title = titles.get(id) || titles.get(key) || ''
|
|
2622
|
+
const graph = state.graphs.get(key)
|
|
2623
|
+
const parentId = record.header.parentSession || null
|
|
2624
|
+
return {
|
|
2625
|
+
id,
|
|
2626
|
+
key,
|
|
2627
|
+
title,
|
|
2628
|
+
cwd: record.header.cwd || '',
|
|
2629
|
+
createdAt: record.header.createdAt || 0,
|
|
2630
|
+
lastActiveAt: state.lastSeen.get(key) || graph?.lastEventAt || 0,
|
|
2631
|
+
live: record.live === true || liveIds.has(key),
|
|
2632
|
+
archived: false,
|
|
2633
|
+
parentId,
|
|
2634
|
+
depth: Number(record.header.delegationDepth) || 0,
|
|
2635
|
+
isSubagent: isSubagentHeader(record.header),
|
|
2636
|
+
turns: graph && graph.ready ? graph.turns.length : 0,
|
|
2637
|
+
hasSegment: state.segmentKeys.has(key) || (graph && graph.topics.length > 0) || false,
|
|
2638
|
+
}
|
|
2639
|
+
})
|
|
2640
|
+
rows.sort((a, b) => {
|
|
2641
|
+
const aAt = a.lastActiveAt || a.createdAt
|
|
2642
|
+
const bAt = b.lastActiveAt || b.createdAt
|
|
2643
|
+
return bAt - aAt
|
|
2644
|
+
})
|
|
2645
|
+
value.sessions = rows
|
|
2646
|
+
const mains = rows.filter((row) => !row.isSubagent)
|
|
2647
|
+
value.current = (mains.length ? mains[0] : rows[0])?.id || null
|
|
2648
|
+
value.version = state.dataVersion
|
|
2649
|
+
state.lineageAt = Date.now()
|
|
2650
|
+
} catch (error) {
|
|
2651
|
+
value.error = summarize(String(error?.message || error), 160)
|
|
2652
|
+
}
|
|
2653
|
+
state.sessionsCache = { at: now, value }
|
|
2654
|
+
return value
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
async function currentSessionId(ctx) {
|
|
2658
|
+
const payload = await listSessionsPayload(ctx)
|
|
2659
|
+
return payload.current || null
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
// ── 浏览器路由 ──────────────────────────────────────────────────────────────
|
|
2663
|
+
|
|
2664
|
+
function registerRoutes(ctx) {
|
|
2665
|
+
const dispose = ctx.webServer.register({
|
|
2666
|
+
kind: 'prefix',
|
|
2667
|
+
path: ROUTE_BASE,
|
|
2668
|
+
handler: async (req, res) => {
|
|
2669
|
+
try {
|
|
2670
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
2671
|
+
const sub = url.pathname.slice(ROUTE_BASE.length).replace(/\/+$/, '') || '/'
|
|
2672
|
+
if (sub === '/ping') {
|
|
2673
|
+
sendJson(res, 200, { ok: true, name, version: '0.6.41', dataDir: dataRoot() })
|
|
2674
|
+
return
|
|
2675
|
+
}
|
|
2676
|
+
if (sub === '/sessions') {
|
|
2677
|
+
const payload = await listSessionsPayload(ctx)
|
|
2678
|
+
sendJson(res, 200, payload)
|
|
2679
|
+
return
|
|
2680
|
+
}
|
|
2681
|
+
if (sub === '/graph') {
|
|
2682
|
+
let sessionId = url.searchParams.get('sessionId') || ''
|
|
2683
|
+
if (!sessionId) sessionId = await currentSessionId(ctx)
|
|
2684
|
+
if (!sessionId) {
|
|
2685
|
+
sendJson(res, 200, { ok: false, empty: true, message: '没有可用的会话', sessions: [] })
|
|
2686
|
+
return
|
|
2687
|
+
}
|
|
2688
|
+
const g = await ensureGraph(ctx, sessionId)
|
|
2689
|
+
// 子智能体分支(含嵌套);只有在 includeSubagents 打开时才收集
|
|
2690
|
+
const branches = await buildBranches(ctx, g)
|
|
2691
|
+
const compositeVersion = g.version * 1000003
|
|
2692
|
+
+ branches.reduce((sum, branch) => sum + (Number(branch.version) || 0), 0)
|
|
2693
|
+
const versionParam = url.searchParams.get('version')
|
|
2694
|
+
if (versionParam != null && Number(versionParam) === compositeVersion) {
|
|
2695
|
+
sendJson(res, 200, { ok: true, unchanged: true, version: compositeVersion })
|
|
2696
|
+
return
|
|
2697
|
+
}
|
|
2698
|
+
const titles = await readTitles(ctx, [g.sessionId, g.key])
|
|
2699
|
+
const title = titles.get(g.sessionId) || titles.get(g.key) || ''
|
|
2700
|
+
if (!g.aiError && !g.segRunning && lastSettledTurn(g) > g.segmentedThroughTurn) {
|
|
2701
|
+
scheduleSegment(ctx, g, {})
|
|
2702
|
+
}
|
|
2703
|
+
const payload = buildGraphPayload(g, title, branches)
|
|
2704
|
+
payload.version = compositeVersion
|
|
2705
|
+
payload.graphVersion = g.version
|
|
2706
|
+
sendJson(res, 200, payload)
|
|
2707
|
+
return
|
|
2708
|
+
}
|
|
2709
|
+
if (sub === '/segment') {
|
|
2710
|
+
if (req.method !== 'POST') {
|
|
2711
|
+
sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2712
|
+
return
|
|
2713
|
+
}
|
|
2714
|
+
const body = await readJsonBody(req)
|
|
2715
|
+
let sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
|
|
2716
|
+
if (!sessionId) sessionId = await currentSessionId(ctx)
|
|
2717
|
+
if (!sessionId) {
|
|
2718
|
+
sendJson(res, 400, { ok: false, error: 'sessionId required' })
|
|
2719
|
+
return
|
|
2720
|
+
}
|
|
2721
|
+
const g = await ensureGraph(ctx, sessionId)
|
|
2722
|
+
const full = body.full === true
|
|
2723
|
+
scheduleSegment(ctx, g, { full, force: true })
|
|
2724
|
+
if (full) {
|
|
2725
|
+
// 立刻开始,不等防抖(手动操作需要即时反馈)
|
|
2726
|
+
clearTimeout(g.segmentTimer)
|
|
2727
|
+
g.segmentTimer = setTimeout(() => { g.segmentTimer = null; void runSegment(ctx, g, { full: true }) }, 30)
|
|
2728
|
+
}
|
|
2729
|
+
sendJson(res, 200, { ok: true, sessionId: g.sessionId, queued: true, full })
|
|
2730
|
+
return
|
|
2731
|
+
}
|
|
2732
|
+
if (sub === '/models') {
|
|
2733
|
+
const llm = getHostService(ctx, 'llm')
|
|
2734
|
+
const providers = []
|
|
2735
|
+
try {
|
|
2736
|
+
let listed = llm && typeof llm.listProviders === 'function' ? llm.listProviders() : []
|
|
2737
|
+
if (listed && typeof listed.then === 'function') listed = await listed
|
|
2738
|
+
for (const item of (Array.isArray(listed) ? listed : [])) {
|
|
2739
|
+
if (!item || !item.id) continue
|
|
2740
|
+
let models = []
|
|
2741
|
+
try {
|
|
2742
|
+
const m = await llm.listModels(item.id)
|
|
2743
|
+
models = (Array.isArray(m) ? m : [])
|
|
2744
|
+
.filter((x) => x && x.id)
|
|
2745
|
+
.map((x) => ({ id: x.id, name: x.name || x.id }))
|
|
2746
|
+
} catch { models = [] }
|
|
2747
|
+
providers.push({ id: item.id, name: item.name || item.id, models })
|
|
2748
|
+
}
|
|
2749
|
+
} catch { /* 列表失败返回空 */ }
|
|
2750
|
+
sendJson(res, 200, { ok: true, providers, current: { provider: cfgRef.provider || '', model: cfgRef.model || '' } })
|
|
2751
|
+
return
|
|
2752
|
+
}
|
|
2753
|
+
if (sub === '/model') {
|
|
2754
|
+
if (req.method !== 'POST') {
|
|
2755
|
+
sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2756
|
+
return
|
|
2757
|
+
}
|
|
2758
|
+
const body = await readJsonBody(req, 16 * 1024)
|
|
2759
|
+
const provider = typeof body?.provider === 'string' ? body.provider.trim() : ''
|
|
2760
|
+
const model = typeof body?.model === 'string' ? body.model.trim() : ''
|
|
2761
|
+
if (!provider || !model) {
|
|
2762
|
+
sendJson(res, 400, { ok: false, error: 'provider/model required' })
|
|
2763
|
+
return
|
|
2764
|
+
}
|
|
2765
|
+
cfgRef.provider = provider
|
|
2766
|
+
cfgRef.model = model
|
|
2767
|
+
try { await saveModelOverride(provider, model) } catch { /* 覆盖文件写失败不致命 */ }
|
|
2768
|
+
sendJson(res, 200, { ok: true, provider, model })
|
|
2769
|
+
return
|
|
2770
|
+
}
|
|
2771
|
+
if (sub === '/model-clear') {
|
|
2772
|
+
if (req.method !== 'POST') {
|
|
2773
|
+
sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2774
|
+
return
|
|
2775
|
+
}
|
|
2776
|
+
cfgRef.provider = baseCfg.provider
|
|
2777
|
+
cfgRef.model = baseCfg.model
|
|
2778
|
+
try { await unlink(modelFile()) } catch { /* 没有覆盖文件 */ }
|
|
2779
|
+
sendJson(res, 200, { ok: true, provider: cfgRef.provider, model: cfgRef.model })
|
|
2780
|
+
return
|
|
2781
|
+
}
|
|
2782
|
+
if (sub === '/analyze-turn') {
|
|
2783
|
+
if (req.method !== 'POST') {
|
|
2784
|
+
sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
2785
|
+
return
|
|
2786
|
+
}
|
|
2787
|
+
const body = await readJsonBody(req, 64 * 1024)
|
|
2788
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
|
|
2789
|
+
const turn = clampInt(body?.turn, 0, 1, 1000000)
|
|
2790
|
+
const force = body?.force === true
|
|
2791
|
+
if (!sessionId || !turn) {
|
|
2792
|
+
sendJson(res, 400, { ok: false, error: 'sessionId/turn required' })
|
|
2793
|
+
return
|
|
2794
|
+
}
|
|
2795
|
+
try {
|
|
2796
|
+
const analysis = await analyzeTurn(ctx, sessionId, turn, { force })
|
|
2797
|
+
sendJson(res, 200, { ok: true, sessionId, turn, analysis })
|
|
2798
|
+
} catch (error) {
|
|
2799
|
+
const code = error && error.code === 'PARSE_OUTPUT' ? 'PARSE_OUTPUT' : 'ANALYZE'
|
|
2800
|
+
const message = code === 'PARSE_OUTPUT' ? 'AI 分析输出无法解析,请重试' : describeLlmError(error)
|
|
2801
|
+
sendJson(res, 500, { ok: false, error: message, code })
|
|
2802
|
+
}
|
|
2803
|
+
return
|
|
2804
|
+
}
|
|
2805
|
+
sendJson(res, 404, { ok: false, error: 'not found' })
|
|
2806
|
+
} catch (error) {
|
|
2807
|
+
try {
|
|
2808
|
+
sendJson(res, 500, { ok: false, error: summarize(String(error?.stack || error?.message || error), 400) })
|
|
2809
|
+
} catch { /* response already sent */ }
|
|
2810
|
+
}
|
|
2811
|
+
},
|
|
2812
|
+
})
|
|
2813
|
+
return dispose
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// ── 插件入口 ────────────────────────────────────────────────────────────────
|
|
2817
|
+
|
|
2818
|
+
let baseCfg = { provider: '', model: '' }
|
|
2819
|
+
let cfgRef = {
|
|
2820
|
+
segmentMode: 'auto',
|
|
2821
|
+
provider: '',
|
|
2822
|
+
model: '',
|
|
2823
|
+
batchTurns: 6,
|
|
2824
|
+
maxInputChars: 8000,
|
|
2825
|
+
debounceMs: 2500,
|
|
2826
|
+
minGapMs: 8000,
|
|
2827
|
+
autoSegment: true,
|
|
2828
|
+
showSubagents: false,
|
|
2829
|
+
includeSubagents: true,
|
|
2830
|
+
maxSubagentDepth: 2,
|
|
2831
|
+
maxBranches: 20,
|
|
2832
|
+
relationMode: 'titles',
|
|
2833
|
+
reasoningEffort: 'off',
|
|
2834
|
+
ruleTurnsPerTopic: 3,
|
|
2835
|
+
ruleTopicsPerStage: 5,
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
export function apply(ctx, config) {
|
|
2839
|
+
cfgRef = {
|
|
2840
|
+
segmentMode: ['auto', 'llm', 'rule'].includes(config?.segmentMode) ? config.segmentMode : 'auto',
|
|
2841
|
+
provider: typeof config?.provider === 'string' ? config.provider : '',
|
|
2842
|
+
model: typeof config?.model === 'string' ? config.model : '',
|
|
2843
|
+
batchTurns: clampInt(config?.batchTurns, 6, 1, 30),
|
|
2844
|
+
maxInputChars: clampInt(config?.maxInputChars, 8000, 2000, 60000),
|
|
2845
|
+
debounceMs: clampInt(config?.debounceMs, 2500, 300, 60000),
|
|
2846
|
+
minGapMs: clampInt(config?.minGapMs, 8000, 0, 300000),
|
|
2847
|
+
autoSegment: config?.autoSegment !== false,
|
|
2848
|
+
showSubagents: config?.showSubagents === true,
|
|
2849
|
+
includeSubagents: config?.includeSubagents !== false,
|
|
2850
|
+
maxSubagentDepth: clampInt(config?.maxSubagentDepth, 2, 1, 5),
|
|
2851
|
+
maxBranches: clampInt(config?.maxBranches, 20, 1, 60),
|
|
2852
|
+
relationMode: ['titles', 'inline', 'off'].includes(config?.relationMode) ? config.relationMode : 'titles',
|
|
2853
|
+
reasoningEffort: ['off', 'low', 'high', 'max'].includes(config?.reasoningEffort) ? config.reasoningEffort : 'off',
|
|
2854
|
+
ruleTurnsPerTopic: clampInt(config?.ruleTurnsPerTopic, 3, 1, 20),
|
|
2855
|
+
ruleTopicsPerStage: clampInt(config?.ruleTopicsPerStage, 5, 1, 50),
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
stateRef = createState()
|
|
2859
|
+
baseCfg = { provider: cfgRef.provider, model: cfgRef.model }
|
|
2860
|
+
|
|
2861
|
+
// 载入用户上次选择的切分模型(异步,不阻塞插件就绪)
|
|
2862
|
+
void loadModelOverride().catch(() => {})
|
|
2863
|
+
|
|
2864
|
+
// 载入磁盘上已有的切分结果索引(只记录 key,具体内容按需读取)
|
|
2865
|
+
void (async () => {
|
|
2866
|
+
try {
|
|
2867
|
+
await mkdir(dataRoot(), { recursive: true })
|
|
2868
|
+
const files = await readdir(dataRoot())
|
|
2869
|
+
for (const file of files) {
|
|
2870
|
+
if (!file.endsWith('.json')) continue
|
|
2871
|
+
stateOf().segmentKeys.add(file.slice(0, -'.json'.length))
|
|
2872
|
+
}
|
|
2873
|
+
} catch { /* 数据目录不可用不致命 */ }
|
|
2874
|
+
})()
|
|
2875
|
+
|
|
2876
|
+
// 实时事件:折叠进图状态 + 在轮次结束时触发增量切分
|
|
2877
|
+
const disposeSessionEvent = ctx.on('session/event', (session, event) => {
|
|
2878
|
+
if (!session || !event) return
|
|
2879
|
+
const sessionId = session.id
|
|
2880
|
+
if (!sessionId) return
|
|
2881
|
+
// 子代理会话默认也纳入(作为父会话的分支显示),关闭 includeSubagents 后完全忽略
|
|
2882
|
+
if (isSubagentSession(session) && !cfgRef.includeSubagents) return
|
|
2883
|
+
const key = normSessionId(sessionId)
|
|
2884
|
+
const state = stateOf()
|
|
2885
|
+
const eventTime = normalizeTime(event.time) || Date.now()
|
|
2886
|
+
state.lastSeen.set(key, eventTime)
|
|
2887
|
+
updateSessionMeta(session.id, session.header)
|
|
2888
|
+
const meta = state.sessionParents.get(key)
|
|
2889
|
+
if (meta) meta.lastActiveAt = Math.max(meta.lastActiveAt || 0, eventTime)
|
|
2890
|
+
let g = state.graphs.get(key)
|
|
2891
|
+
if (!g) {
|
|
2892
|
+
g = createGraph(sessionId)
|
|
2893
|
+
state.graphs.set(key, g)
|
|
2894
|
+
g.pending.push(event)
|
|
2895
|
+
void ensureGraph(ctx, sessionId).catch(() => {})
|
|
2896
|
+
return
|
|
2897
|
+
}
|
|
2898
|
+
if (!g.ready) {
|
|
2899
|
+
g.pending.push(event)
|
|
2900
|
+
return
|
|
2901
|
+
}
|
|
2902
|
+
if (applyEvent(g, event)) {
|
|
2903
|
+
g.version += 1
|
|
2904
|
+
touchGraph(g)
|
|
2905
|
+
state.dataVersion += 1
|
|
2906
|
+
state.sessionsCache = null
|
|
2907
|
+
evictGraphsIfNeeded(state)
|
|
2908
|
+
if (event.type === 'turn/end' || (event.type === 'turn/start' && Number(event.data?.turn) > 1)) {
|
|
2909
|
+
scheduleSegment(ctx, g, {})
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
})
|
|
2913
|
+
|
|
2914
|
+
const disposeRoutes = registerRoutes(ctx)
|
|
2915
|
+
|
|
2916
|
+
// 卸载:清定时器 + 尽力落盘
|
|
2917
|
+
return () => {
|
|
2918
|
+
disposeSessionEvent()
|
|
2919
|
+
disposeRoutes()
|
|
2920
|
+
for (const g of stateOf().graphs.values()) {
|
|
2921
|
+
clearTimeout(g.saveTimer)
|
|
2922
|
+
clearTimeout(g.segmentTimer)
|
|
2923
|
+
if (g.ready && g.topics.length) void saveGraphNow(g)
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
// ── 测试钩子(仅供本地单元测试调用;Loader 只读取 name/inject/apply) ──────
|
|
2929
|
+
|
|
2930
|
+
export const __test = {
|
|
2931
|
+
createGraph,
|
|
2932
|
+
titleSimilarity,
|
|
2933
|
+
deriveRevisitRelations,
|
|
2934
|
+
deriveExtensionRelations,
|
|
2935
|
+
mergeRelations,
|
|
2936
|
+
applyEvent,
|
|
2937
|
+
rebuildFromEvents,
|
|
2938
|
+
applyAiStructure,
|
|
2939
|
+
applyRuleStructure,
|
|
2940
|
+
applyExtendFallback,
|
|
2941
|
+
collectBatch,
|
|
2942
|
+
lastSettledTurn,
|
|
2943
|
+
buildGraphPayload,
|
|
2944
|
+
buildTurnTrace,
|
|
2945
|
+
renderTurnTrace,
|
|
2946
|
+
analyzeTurn,
|
|
2947
|
+
parseTurnAnalysisJson,
|
|
2948
|
+
createState,
|
|
2949
|
+
parseGraphJson,
|
|
2950
|
+
runSegment,
|
|
2951
|
+
buildSegmentInput,
|
|
2952
|
+
getHostService,
|
|
2953
|
+
resolveLlmTarget,
|
|
2954
|
+
stateOf,
|
|
2955
|
+
setConfig(next) {
|
|
2956
|
+
cfgRef = { ...cfgRef, ...(next || {}) }
|
|
2957
|
+
},
|
|
2958
|
+
getConfig() { return { ...cfgRef } },
|
|
2959
|
+
}
|