@wonanut/dsh-agent-lab 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRACT.md +276 -0
- package/LICENSE +21 -0
- package/README.md +299 -2
- package/build.mjs +89 -0
- package/client.js +7871 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +26 -0
- package/index.js +390 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -4
- package/preview/dataflow.png +0 -0
- package/preview/inspector-context.png +0 -0
- package/preview/model-switch.png +0 -0
- package/preview/overview-dark.png +0 -0
- package/preview/overview-light.png +0 -0
- package/preview/principles.png +0 -0
- package/preview/source.png +0 -0
- package/src/00-core.js +209 -0
- package/src/10-engine.js +1960 -0
- package/src/20-tools.js +600 -0
- package/src/30-llm.js +495 -0
- package/src/40-ui-kit.js +596 -0
- package/src/45-real-client.js +190 -0
- package/src/50-tui.js +472 -0
- package/src/60-inspector.js +621 -0
- package/src/65-dataflow.js +341 -0
- package/src/70-docs.js +1226 -0
- package/src/80-source.js +576 -0
- package/src/90-page.js +476 -0
- package/src/95-apply.js +58 -0
- package/test/engine.test.mjs +800 -0
- package/test/extra-tools.test.mjs +168 -0
- package/test/real-mode.test.mjs +177 -0
package/src/10-engine.js
ADDED
|
@@ -0,0 +1,1960 @@
|
|
|
1
|
+
/* ── 10-engine.js · 会话 / 记忆 / 上下文 / 压缩 / 持久化 / createLab ──
|
|
2
|
+
*
|
|
3
|
+
* 这里实现插件里真正在跑的那个「迷你 Agent 引擎」:
|
|
4
|
+
* - 会话与 transcript(每会话独立消息、共享长期记忆)
|
|
5
|
+
* - 上下文组装:system + summary + memory + recent + input(token 预算真实计算)
|
|
6
|
+
* - 自动 / 手动压缩:把旧消息抽取式折叠进 summary
|
|
7
|
+
* - 一轮 turn 的 agent loop:input → context → (compress) → model ↔ tool → output
|
|
8
|
+
* - 事件订阅 + 不可变 snapshot(每次调用返回新对象,可安全交给 React)
|
|
9
|
+
* - storage 持久化(跨实例恢复)
|
|
10
|
+
*
|
|
11
|
+
* 约束:普通脚本;不直接访问 window/document;localStorage 通过注入的 adapter 访问,
|
|
12
|
+
* 默认用 try/catch 保护 globalThis.localStorage,不可用时退化为内存 Map。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/* ─────────────────────────────────────────────────────────────
|
|
16
|
+
* storage 适配器
|
|
17
|
+
* ─────────────────────────────────────────────────────────────
|
|
18
|
+
* 优先使用 options.storage;否则尝试受保护的 globalThis.localStorage;
|
|
19
|
+
* 隐私模式 / sandbox 中访问抛异常时退化为内存态。
|
|
20
|
+
*/
|
|
21
|
+
function labResolveStorage(provided) {
|
|
22
|
+
if (provided && typeof provided.getItem === 'function' && typeof provided.setItem === 'function') {
|
|
23
|
+
return provided;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const local = typeof globalThis !== 'undefined' ? globalThis.localStorage : null;
|
|
27
|
+
if (local && typeof local.getItem === 'function' && typeof local.setItem === 'function') {
|
|
28
|
+
const probe = '__dsh_agent_lab_probe__';
|
|
29
|
+
local.setItem(probe, '1');
|
|
30
|
+
if (typeof local.removeItem === 'function') local.removeItem(probe);
|
|
31
|
+
return local;
|
|
32
|
+
}
|
|
33
|
+
} catch (err) {
|
|
34
|
+
/* localStorage 不可用(隐私模式等):静默退化为内存态 */
|
|
35
|
+
}
|
|
36
|
+
return labMemoryStorage();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function labMemoryStorage() {
|
|
40
|
+
const data = Object.create(null);
|
|
41
|
+
return {
|
|
42
|
+
getItem(key) {
|
|
43
|
+
return Object.prototype.hasOwnProperty.call(data, key) ? data[key] : null;
|
|
44
|
+
},
|
|
45
|
+
setItem(key, value) {
|
|
46
|
+
data[key] = String(value);
|
|
47
|
+
},
|
|
48
|
+
removeItem(key) {
|
|
49
|
+
delete data[key];
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* ─────────────────────────────────────────────────────────────
|
|
55
|
+
* createLab(options)
|
|
56
|
+
* ─────────────────────────────────────────────────────────────
|
|
57
|
+
*/
|
|
58
|
+
function createLab(options) {
|
|
59
|
+
const opts = options && typeof options === 'object' ? options : {};
|
|
60
|
+
const now = typeof opts.now === 'function' ? opts.now : function () {
|
|
61
|
+
return Date.now();
|
|
62
|
+
};
|
|
63
|
+
const sleep =
|
|
64
|
+
typeof opts.sleep === 'function'
|
|
65
|
+
? opts.sleep
|
|
66
|
+
: function (ms) {
|
|
67
|
+
return new Promise(function (resolve) {
|
|
68
|
+
setTimeout(resolve, ms);
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
const makeId = typeof opts.id === 'function' ? opts.id : uid;
|
|
72
|
+
let budget =
|
|
73
|
+
typeof opts.budget === 'number' && isFinite(opts.budget) && opts.budget >= 80
|
|
74
|
+
? Math.floor(opts.budget)
|
|
75
|
+
: LAB_BUDGET;
|
|
76
|
+
let budgetCustom = false;
|
|
77
|
+
const storage = labResolveStorage(opts.storage);
|
|
78
|
+
const listeners = [];
|
|
79
|
+
/* 真实模型模式:adapter 由 Client 注入;provider/model 信息由 Client 查询后回填。 */
|
|
80
|
+
let realAdapter = typeof opts.modelAdapter === 'function' ? opts.modelAdapter : null;
|
|
81
|
+
const modelInfo = {
|
|
82
|
+
provider: '',
|
|
83
|
+
model: '',
|
|
84
|
+
modelName: '',
|
|
85
|
+
signedIn: null,
|
|
86
|
+
available: null,
|
|
87
|
+
contextWindow: null,
|
|
88
|
+
maxOutputTokens: null,
|
|
89
|
+
lastUsage: null,
|
|
90
|
+
lastLatencyMs: null,
|
|
91
|
+
lastError: null,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/* ── 会话 / 消息 / transcript 构造 ── */
|
|
95
|
+
|
|
96
|
+
function makeLine(kind, text, extra) {
|
|
97
|
+
const line = {
|
|
98
|
+
id: makeId('l'),
|
|
99
|
+
kind: kind,
|
|
100
|
+
text: text === null || text === undefined ? '' : String(text),
|
|
101
|
+
at: now(),
|
|
102
|
+
};
|
|
103
|
+
if (extra && extra.streaming) line.streaming = true;
|
|
104
|
+
return line;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function makeMessage(role, content, extra) {
|
|
108
|
+
const text = content === null || content === undefined ? '' : String(content);
|
|
109
|
+
const message = {
|
|
110
|
+
id: makeId('m'),
|
|
111
|
+
role: role,
|
|
112
|
+
content: text,
|
|
113
|
+
tokens: estimateTokens(text) + LAB_MSG_OVERHEAD,
|
|
114
|
+
at: now(),
|
|
115
|
+
};
|
|
116
|
+
if (extra) {
|
|
117
|
+
if (extra.kind) message.kind = extra.kind;
|
|
118
|
+
if (extra.toolName) message.toolName = extra.toolName;
|
|
119
|
+
if (extra.toolCallId) message.toolCallId = extra.toolCallId;
|
|
120
|
+
if (extra.toolArgs) message.toolArgs = extra.toolArgs;
|
|
121
|
+
if (extra.turnId) message.turnId = extra.turnId;
|
|
122
|
+
}
|
|
123
|
+
return message;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function createEmptySummary() {
|
|
127
|
+
return { keyPoints: [], openTasks: [], nextTask: '', folded: 0 };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function cloneSummary(summary) {
|
|
131
|
+
const source = summary && typeof summary === 'object' ? summary : createEmptySummary();
|
|
132
|
+
return {
|
|
133
|
+
keyPoints: Array.isArray(source.keyPoints) ? source.keyPoints.slice(0, 8) : [],
|
|
134
|
+
openTasks: Array.isArray(source.openTasks) ? source.openTasks.slice(0, 8) : [],
|
|
135
|
+
nextTask: typeof source.nextTask === 'string' ? source.nextTask : '',
|
|
136
|
+
folded: safeNonNegative(source.folded),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function renderSummary(summary) {
|
|
141
|
+
const value = summary || createEmptySummary();
|
|
142
|
+
if (!value.keyPoints.length && !value.openTasks.length && !value.nextTask) return '';
|
|
143
|
+
const lines = ['【关键内容】'];
|
|
144
|
+
if (value.keyPoints.length) {
|
|
145
|
+
for (let i = 0; i < value.keyPoints.length; i += 1) lines.push('- ' + value.keyPoints[i]);
|
|
146
|
+
} else {
|
|
147
|
+
lines.push('- (暂无)');
|
|
148
|
+
}
|
|
149
|
+
lines.push('【未完成任务】');
|
|
150
|
+
if (value.openTasks.length) {
|
|
151
|
+
for (let i = 0; i < value.openTasks.length; i += 1) lines.push('- ' + value.openTasks[i]);
|
|
152
|
+
} else {
|
|
153
|
+
lines.push('- (暂无)');
|
|
154
|
+
}
|
|
155
|
+
lines.push('【下一个任务】');
|
|
156
|
+
lines.push(value.nextTask || '等待用户下一步指令');
|
|
157
|
+
return lines.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function summaryTokenCount(summary) {
|
|
161
|
+
const text = renderSummary(summary);
|
|
162
|
+
return text ? estimateTokens(text) + LAB_MSG_OVERHEAD : 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function trimSummaryObject(summary, cap) {
|
|
166
|
+
const value = summary || createEmptySummary();
|
|
167
|
+
let guard = 0;
|
|
168
|
+
while (summaryTokenCount(value) > cap && guard < 200) {
|
|
169
|
+
guard += 1;
|
|
170
|
+
if (value.keyPoints.length) {
|
|
171
|
+
value.keyPoints.shift();
|
|
172
|
+
} else if (value.openTasks.length) {
|
|
173
|
+
value.openTasks.shift();
|
|
174
|
+
} else if (value.nextTask) {
|
|
175
|
+
const tokens = estimateTokens(value.nextTask);
|
|
176
|
+
value.nextTask = tokens <= 6 ? '' : labTruncateToTokens(value.nextTask, Math.max(4, tokens - 4));
|
|
177
|
+
} else {
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (summaryTokenCount(value) > cap) return createEmptySummary();
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function pushLimited(list, item, limit) {
|
|
186
|
+
if (!item) return;
|
|
187
|
+
for (let i = 0; i < list.length; i += 1) if (list[i] === item) return;
|
|
188
|
+
list.push(item);
|
|
189
|
+
while (list.length > limit) list.shift();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function summaryNoise(text) {
|
|
193
|
+
return /^(你好|您好|hi|hello|帮助|\/help|工具|你有哪些工具|你是谁|讲讲你是怎么工作的)[\s!!。.~~]*$/i.test(text);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function summaryTaskLike(text) {
|
|
197
|
+
if (summaryNoise(text)) return false;
|
|
198
|
+
return /帮我|请|要|需要|把|计算|算|记住|记下|生成|换算|查|写|安排|处理|选|掷|密码|多少|是什么|怎么|如何|下一步|然后|待办|todo/i.test(text);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function summaryDone(text) {
|
|
202
|
+
return /已经|已完成|完成了|好了|搞定|已记住|已保存|已生成|生成好了|我选|已换算|算好|记得|结果是/.test(text);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function summaryTaskLabel(text) {
|
|
206
|
+
const first = String(text || '').split(/[。!?!?\n]/)[0];
|
|
207
|
+
return truncate(labCleanText(first, 80), 42);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function makeSession() {
|
|
211
|
+
const ts = now();
|
|
212
|
+
return {
|
|
213
|
+
id: makeId('s'),
|
|
214
|
+
title: '新会话',
|
|
215
|
+
createdAt: ts,
|
|
216
|
+
updatedAt: ts,
|
|
217
|
+
messages: [],
|
|
218
|
+
transcript: [makeLine('banner', LAB_BANNER)],
|
|
219
|
+
summary: createEmptySummary(),
|
|
220
|
+
compressions: 0,
|
|
221
|
+
turns: 0,
|
|
222
|
+
context: null,
|
|
223
|
+
lastTurn: null,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function createStats() {
|
|
228
|
+
return { turns: 0, toolCalls: 0, compressions: 0, tokensSaved: 0, totalTokens: 0 };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function activeSession() {
|
|
232
|
+
for (let i = 0; i < state.sessions.length; i += 1) {
|
|
233
|
+
if (state.sessions[i].id === state.activeId) return state.sessions[i];
|
|
234
|
+
}
|
|
235
|
+
return state.sessions[0] || null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function busy() {
|
|
239
|
+
return !!(state.turn && state.turn.status === 'running');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* ── 持久化 ── */
|
|
243
|
+
|
|
244
|
+
function safeNonNegative(value) {
|
|
245
|
+
const number = Number(value);
|
|
246
|
+
return isFinite(number) && number >= 0 ? number : 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function loadState() {
|
|
250
|
+
let raw = null;
|
|
251
|
+
try {
|
|
252
|
+
raw = storage.getItem(LAB_STORAGE_KEY);
|
|
253
|
+
} catch (err) {
|
|
254
|
+
raw = null;
|
|
255
|
+
}
|
|
256
|
+
if (!raw) return null;
|
|
257
|
+
let data = null;
|
|
258
|
+
try {
|
|
259
|
+
data = JSON.parse(raw);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
if (!data || !Array.isArray(data.sessions) || !data.sessions.length) return null;
|
|
264
|
+
|
|
265
|
+
const sessions = [];
|
|
266
|
+
for (let i = 0; i < data.sessions.length && sessions.length < LAB_MAX_SESSIONS; i += 1) {
|
|
267
|
+
const rawSession = data.sessions[i];
|
|
268
|
+
if (!rawSession || typeof rawSession.id !== 'string' || !rawSession.id) continue;
|
|
269
|
+
const session = {
|
|
270
|
+
id: rawSession.id,
|
|
271
|
+
title: typeof rawSession.title === 'string' && rawSession.title ? rawSession.title : '新会话',
|
|
272
|
+
createdAt: safeNonNegative(rawSession.createdAt) || now(),
|
|
273
|
+
updatedAt: safeNonNegative(rawSession.updatedAt) || now(),
|
|
274
|
+
messages: [],
|
|
275
|
+
transcript: [],
|
|
276
|
+
summary: createEmptySummary(),
|
|
277
|
+
compressions: safeNonNegative(rawSession.compressions),
|
|
278
|
+
turns: safeNonNegative(rawSession.turns),
|
|
279
|
+
context: null,
|
|
280
|
+
lastTurn: null,
|
|
281
|
+
};
|
|
282
|
+
/* 新格式:结构化 summary 对象;旧格式(summaryLines / 字符串)迁移为关键内容 */
|
|
283
|
+
if (rawSession.summary && typeof rawSession.summary === 'object' && !Array.isArray(rawSession.summary)) {
|
|
284
|
+
session.summary = cloneSummary(rawSession.summary);
|
|
285
|
+
} else if (Array.isArray(rawSession.summaryLines)) {
|
|
286
|
+
const points = [];
|
|
287
|
+
for (let j = 0; j < rawSession.summaryLines.length; j += 1) {
|
|
288
|
+
const text = rawSession.summaryLines[j];
|
|
289
|
+
if (typeof text === 'string' && text) points.push(truncate(text, 60));
|
|
290
|
+
}
|
|
291
|
+
session.summary = { keyPoints: points.slice(-6), openTasks: [], nextTask: '继续回答用户最近的问题', folded: points.length };
|
|
292
|
+
} else if (typeof rawSession.summary === 'string' && rawSession.summary) {
|
|
293
|
+
session.summary = { keyPoints: rawSession.summary.split(';').filter(function (line) { return line; }).slice(-6), openTasks: [], nextTask: '继续回答用户最近的问题', folded: 0 };
|
|
294
|
+
}
|
|
295
|
+
session.summary = trimSummaryObject(session.summary, Math.max(32, Math.floor(budget * LAB_SUMMARY_TOKEN_RATIO)));
|
|
296
|
+
|
|
297
|
+
const rawMessages = Array.isArray(rawSession.messages)
|
|
298
|
+
? rawSession.messages.slice(-LAB_MAX_MESSAGES)
|
|
299
|
+
: [];
|
|
300
|
+
for (let j = 0; j < rawMessages.length; j += 1) {
|
|
301
|
+
const rawMessage = rawMessages[j];
|
|
302
|
+
if (!rawMessage || typeof rawMessage.content !== 'string') continue;
|
|
303
|
+
const role =
|
|
304
|
+
['user', 'assistant', 'tool', 'system'].indexOf(rawMessage.role) >= 0 ? rawMessage.role : 'assistant';
|
|
305
|
+
const message = {
|
|
306
|
+
id: typeof rawMessage.id === 'string' && rawMessage.id ? rawMessage.id : makeId('m'),
|
|
307
|
+
role: role,
|
|
308
|
+
content: rawMessage.content,
|
|
309
|
+
tokens: estimateTokens(rawMessage.content) + LAB_MSG_OVERHEAD,
|
|
310
|
+
at: safeNonNegative(rawMessage.at) || now(),
|
|
311
|
+
};
|
|
312
|
+
if (typeof rawMessage.kind === 'string' && rawMessage.kind) message.kind = rawMessage.kind;
|
|
313
|
+
if (typeof rawMessage.toolName === 'string' && rawMessage.toolName) message.toolName = rawMessage.toolName;
|
|
314
|
+
if (typeof rawMessage.toolCallId === 'string' && rawMessage.toolCallId) message.toolCallId = rawMessage.toolCallId;
|
|
315
|
+
if (typeof rawMessage.toolArgs === 'string' && rawMessage.toolArgs) message.toolArgs = rawMessage.toolArgs;
|
|
316
|
+
session.messages.push(message);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const rawLines = Array.isArray(rawSession.transcript)
|
|
320
|
+
? rawSession.transcript.slice(-LAB_MAX_TRANSCRIPT)
|
|
321
|
+
: [];
|
|
322
|
+
for (let j = 0; j < rawLines.length; j += 1) {
|
|
323
|
+
const rawLine = rawLines[j];
|
|
324
|
+
if (!rawLine || typeof rawLine.text !== 'string') continue;
|
|
325
|
+
session.transcript.push({
|
|
326
|
+
id: typeof rawLine.id === 'string' && rawLine.id ? rawLine.id : makeId('l'),
|
|
327
|
+
kind: typeof rawLine.kind === 'string' && rawLine.kind ? rawLine.kind : 'info',
|
|
328
|
+
text: rawLine.text,
|
|
329
|
+
at: safeNonNegative(rawLine.at) || now(),
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
if (!session.transcript.length) session.transcript.push(makeLine('banner', LAB_BANNER));
|
|
333
|
+
|
|
334
|
+
sessions.push(session);
|
|
335
|
+
}
|
|
336
|
+
if (!sessions.length) return null;
|
|
337
|
+
|
|
338
|
+
const memory = [];
|
|
339
|
+
const rawMemory = Array.isArray(data.memory) ? data.memory : [];
|
|
340
|
+
for (let i = 0; i < rawMemory.length && memory.length < LAB_MEMORY_LIMIT; i += 1) {
|
|
341
|
+
const rawItem = rawMemory[i];
|
|
342
|
+
if (!rawItem || typeof rawItem.key !== 'string' || typeof rawItem.value !== 'string') continue;
|
|
343
|
+
if (!rawItem.key || !rawItem.value) continue;
|
|
344
|
+
memory.push({
|
|
345
|
+
id: typeof rawItem.id === 'string' && rawItem.id ? rawItem.id : makeId('mem'),
|
|
346
|
+
key: rawItem.key,
|
|
347
|
+
value: rawItem.value,
|
|
348
|
+
source: typeof rawItem.source === 'string' && rawItem.source ? rawItem.source : '对话',
|
|
349
|
+
sessionId: typeof rawItem.sessionId === 'string' ? rawItem.sessionId : '',
|
|
350
|
+
createdAt: safeNonNegative(rawItem.createdAt) || now(),
|
|
351
|
+
updatedAt: safeNonNegative(rawItem.updatedAt) || now(),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const rawStats = data.stats && typeof data.stats === 'object' ? data.stats : {};
|
|
356
|
+
const stats = {
|
|
357
|
+
turns: safeNonNegative(rawStats.turns),
|
|
358
|
+
toolCalls: safeNonNegative(rawStats.toolCalls),
|
|
359
|
+
compressions: safeNonNegative(rawStats.compressions),
|
|
360
|
+
tokensSaved: safeNonNegative(rawStats.tokensSaved),
|
|
361
|
+
totalTokens: safeNonNegative(rawStats.totalTokens),
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const activeId = sessions.some(function (session) {
|
|
365
|
+
return session.id === data.activeId;
|
|
366
|
+
})
|
|
367
|
+
? data.activeId
|
|
368
|
+
: sessions[0].id;
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
pace: labNormalizePace(data.pace),
|
|
372
|
+
modelMode: data.modelMode === 'real' ? 'real' : 'sim',
|
|
373
|
+
budget: Number.isFinite(data.budget) ? data.budget : LAB_BUDGET,
|
|
374
|
+
budgetCustom: data.budgetCustom === true,
|
|
375
|
+
activeId: activeId,
|
|
376
|
+
sessions: sessions,
|
|
377
|
+
memory: memory,
|
|
378
|
+
stats: stats,
|
|
379
|
+
turn: null,
|
|
380
|
+
streaming: null,
|
|
381
|
+
lastInput: typeof data.lastInput === 'string' ? data.lastInput : '',
|
|
382
|
+
lastCompression: null,
|
|
383
|
+
disposed: false,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function persist() {
|
|
388
|
+
if (state.disposed) return;
|
|
389
|
+
const payload = {
|
|
390
|
+
v: 1,
|
|
391
|
+
pace: state.pace,
|
|
392
|
+
modelMode: state.modelMode,
|
|
393
|
+
budget: budget,
|
|
394
|
+
budgetCustom: budgetCustom,
|
|
395
|
+
activeId: state.activeId,
|
|
396
|
+
lastInput: state.lastInput,
|
|
397
|
+
stats: {
|
|
398
|
+
turns: state.stats.turns,
|
|
399
|
+
toolCalls: state.stats.toolCalls,
|
|
400
|
+
compressions: state.stats.compressions,
|
|
401
|
+
tokensSaved: state.stats.tokensSaved,
|
|
402
|
+
totalTokens: state.stats.totalTokens,
|
|
403
|
+
},
|
|
404
|
+
memory: state.memory.map(function (item) {
|
|
405
|
+
return {
|
|
406
|
+
id: item.id,
|
|
407
|
+
key: item.key,
|
|
408
|
+
value: item.value,
|
|
409
|
+
source: item.source,
|
|
410
|
+
sessionId: item.sessionId,
|
|
411
|
+
createdAt: item.createdAt,
|
|
412
|
+
updatedAt: item.updatedAt,
|
|
413
|
+
};
|
|
414
|
+
}),
|
|
415
|
+
sessions: state.sessions.map(function (session) {
|
|
416
|
+
return {
|
|
417
|
+
id: session.id,
|
|
418
|
+
title: session.title,
|
|
419
|
+
createdAt: session.createdAt,
|
|
420
|
+
updatedAt: session.updatedAt,
|
|
421
|
+
compressions: session.compressions,
|
|
422
|
+
turns: session.turns,
|
|
423
|
+
summary: cloneSummary(session.summary),
|
|
424
|
+
messages: session.messages.slice(-LAB_MAX_MESSAGES).map(snapshotMessage),
|
|
425
|
+
transcript: session.transcript.slice(-LAB_MAX_TRANSCRIPT).map(snapshotLine),
|
|
426
|
+
};
|
|
427
|
+
}),
|
|
428
|
+
};
|
|
429
|
+
try {
|
|
430
|
+
storage.setItem(LAB_STORAGE_KEY, JSON.stringify(payload));
|
|
431
|
+
} catch (err) {
|
|
432
|
+
/* 存储写满 / 被禁用时静默失败,引擎继续工作 */
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/* ── 初始状态 ── */
|
|
437
|
+
|
|
438
|
+
const loaded = loadState();
|
|
439
|
+
const state = loaded || {
|
|
440
|
+
pace: labNormalizePace(opts.pace),
|
|
441
|
+
modelMode: 'sim',
|
|
442
|
+
activeId: '',
|
|
443
|
+
sessions: [],
|
|
444
|
+
memory: [],
|
|
445
|
+
stats: createStats(),
|
|
446
|
+
turn: null,
|
|
447
|
+
streaming: null,
|
|
448
|
+
lastInput: '',
|
|
449
|
+
lastCompression: null,
|
|
450
|
+
disposed: false,
|
|
451
|
+
};
|
|
452
|
+
if (loaded && loaded.budgetCustom === true && Number.isFinite(loaded.budget)) {
|
|
453
|
+
const savedBudget = Math.floor(loaded.budget);
|
|
454
|
+
if (savedBudget >= 800 && savedBudget <= 1048576) {
|
|
455
|
+
budget = savedBudget;
|
|
456
|
+
budgetCustom = true;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
state.disposed = false;
|
|
460
|
+
state.turn = null; /* 上一轮 turn 不持久化:重建后从空闲开始 */
|
|
461
|
+
state.streaming = null;
|
|
462
|
+
state.lastCompression = null;
|
|
463
|
+
if (state.modelMode !== 'real') state.modelMode = 'sim';
|
|
464
|
+
if (!Array.isArray(state.memory)) state.memory = [];
|
|
465
|
+
if (!state.stats) state.stats = createStats();
|
|
466
|
+
if (!state.sessions.length) {
|
|
467
|
+
const firstSession = makeSession();
|
|
468
|
+
state.sessions = [firstSession];
|
|
469
|
+
state.activeId = firstSession.id;
|
|
470
|
+
}
|
|
471
|
+
if (state.pace !== 'fast' && state.pace !== 'normal' && state.pace !== 'slow') state.pace = 'normal';
|
|
472
|
+
for (let i = 0; i < state.sessions.length; i += 1) {
|
|
473
|
+
if (!state.sessions[i].context) {
|
|
474
|
+
state.sessions[i].context = assembleContext(state.sessions[i], '', {}).context;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (!activeSession()) state.activeId = state.sessions[0].id;
|
|
478
|
+
|
|
479
|
+
/* ── 事件 ── */
|
|
480
|
+
|
|
481
|
+
function emit(type, payload) {
|
|
482
|
+
if (state.disposed) return;
|
|
483
|
+
if (!listeners.length) return;
|
|
484
|
+
const event = { type: type };
|
|
485
|
+
if (payload && typeof payload === 'object') {
|
|
486
|
+
const keys = Object.keys(payload);
|
|
487
|
+
for (let i = 0; i < keys.length; i += 1) event[keys[i]] = payload[keys[i]];
|
|
488
|
+
}
|
|
489
|
+
for (let i = 0; i < listeners.length; i += 1) {
|
|
490
|
+
try {
|
|
491
|
+
listeners[i](event);
|
|
492
|
+
} catch (err) {
|
|
493
|
+
/* UI 回调异常不能影响引擎 */
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function onEvent(fn) {
|
|
499
|
+
if (typeof fn !== 'function') return function () {};
|
|
500
|
+
listeners.push(fn);
|
|
501
|
+
return function unsubscribe() {
|
|
502
|
+
const index = listeners.indexOf(fn);
|
|
503
|
+
if (index >= 0) listeners.splice(index, 1);
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/* ── transcript / message 追加 ── */
|
|
508
|
+
|
|
509
|
+
function pushTranscript(session, line) {
|
|
510
|
+
session.transcript.push(line);
|
|
511
|
+
while (session.transcript.length > LAB_MAX_TRANSCRIPT) session.transcript.shift();
|
|
512
|
+
session.updatedAt = now();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function appendMessage(session, role, content, extra) {
|
|
516
|
+
const message = makeMessage(role, content, extra);
|
|
517
|
+
session.messages.push(message);
|
|
518
|
+
while (session.messages.length > LAB_MAX_MESSAGES) session.messages.shift();
|
|
519
|
+
session.updatedAt = now();
|
|
520
|
+
emit('message');
|
|
521
|
+
return message;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function appendInfoLine(session, text) {
|
|
525
|
+
const line = makeLine('info', text);
|
|
526
|
+
pushTranscript(session, line);
|
|
527
|
+
emit('line');
|
|
528
|
+
return line;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/* ─────────────────────────────────────────────────────────────
|
|
532
|
+
* 长期记忆
|
|
533
|
+
* ─────────────────────────────────────────────────────────────
|
|
534
|
+
*/
|
|
535
|
+
|
|
536
|
+
function memoryWords(text) {
|
|
537
|
+
const source = String(text || '').toLowerCase();
|
|
538
|
+
const words = [];
|
|
539
|
+
const latin = source.match(/[a-z0-9_]{2,}/g) || [];
|
|
540
|
+
for (let i = 0; i < latin.length; i += 1) words.push(latin[i]);
|
|
541
|
+
const chunks = source.replace(/[^\u2e80-\u9fff\uf900-\ufaff\uff00-\uffef]+/g, ' ').split(/\s+/);
|
|
542
|
+
const stop = {
|
|
543
|
+
什么: 1,
|
|
544
|
+
怎么: 1,
|
|
545
|
+
我的: 1,
|
|
546
|
+
你的: 1,
|
|
547
|
+
一下: 1,
|
|
548
|
+
记住: 1,
|
|
549
|
+
记忆: 1,
|
|
550
|
+
记得: 1,
|
|
551
|
+
告诉: 1,
|
|
552
|
+
请问: 1,
|
|
553
|
+
帮我: 1,
|
|
554
|
+
现在: 1,
|
|
555
|
+
这个: 1,
|
|
556
|
+
那个: 1,
|
|
557
|
+
多少: 1,
|
|
558
|
+
还是: 1,
|
|
559
|
+
};
|
|
560
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
561
|
+
const chunk = chunks[i];
|
|
562
|
+
if (!chunk) continue;
|
|
563
|
+
if (chunk.length >= 2) words.push(chunk);
|
|
564
|
+
if (chunk.length >= 3) {
|
|
565
|
+
for (let j = 0; j + 2 <= chunk.length; j += 1) words.push(chunk.slice(j, j + 2));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const seen = Object.create(null);
|
|
569
|
+
const unique = [];
|
|
570
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
571
|
+
const word = words[i];
|
|
572
|
+
if (!word || word.length < 2) continue;
|
|
573
|
+
if (stop[word]) continue;
|
|
574
|
+
if (seen[word]) continue;
|
|
575
|
+
seen[word] = true;
|
|
576
|
+
unique.push(word);
|
|
577
|
+
}
|
|
578
|
+
return unique;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function memoryScore(item, text, words) {
|
|
582
|
+
const haystack = String(text || '').toLowerCase();
|
|
583
|
+
const key = String(item.key || '').toLowerCase();
|
|
584
|
+
const value = String(item.value || '').toLowerCase();
|
|
585
|
+
let score = 0;
|
|
586
|
+
if (key && haystack.indexOf(key) >= 0) score += 6;
|
|
587
|
+
if (key && words.indexOf(key) >= 0) score += 4;
|
|
588
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
589
|
+
const word = words[i];
|
|
590
|
+
if (!word) continue;
|
|
591
|
+
if (key.indexOf(word) >= 0) score += 3;
|
|
592
|
+
else if (value.indexOf(word) >= 0) score += 1;
|
|
593
|
+
}
|
|
594
|
+
if (value && haystack.indexOf(value) >= 0) score += 2;
|
|
595
|
+
return score;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function recallMemory(input) {
|
|
599
|
+
const text = labCleanText(input, 200);
|
|
600
|
+
if (!text || !state.memory.length) return [];
|
|
601
|
+
const words = memoryWords(text);
|
|
602
|
+
const scored = [];
|
|
603
|
+
for (let i = 0; i < state.memory.length; i += 1) {
|
|
604
|
+
const score = memoryScore(state.memory[i], text, words);
|
|
605
|
+
if (score > 0) scored.push({ score: score, item: state.memory[i] });
|
|
606
|
+
}
|
|
607
|
+
scored.sort(function (a, b) {
|
|
608
|
+
return b.score - a.score || b.item.updatedAt - a.item.updatedAt;
|
|
609
|
+
});
|
|
610
|
+
return scored.slice(0, 3).map(function (entry) {
|
|
611
|
+
return entry.item;
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function plainMemory(item) {
|
|
616
|
+
return {
|
|
617
|
+
id: item.id,
|
|
618
|
+
key: item.key,
|
|
619
|
+
value: item.value,
|
|
620
|
+
source: item.source,
|
|
621
|
+
sessionId: item.sessionId,
|
|
622
|
+
createdAt: item.createdAt,
|
|
623
|
+
updatedAt: item.updatedAt,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function searchMemory(query) {
|
|
628
|
+
const text = labCleanText(query, 120);
|
|
629
|
+
if (!state.memory.length) return [];
|
|
630
|
+
if (!text) {
|
|
631
|
+
return state.memory
|
|
632
|
+
.slice()
|
|
633
|
+
.sort(function (a, b) {
|
|
634
|
+
return b.updatedAt - a.updatedAt;
|
|
635
|
+
})
|
|
636
|
+
.slice(0, 5)
|
|
637
|
+
.map(plainMemory);
|
|
638
|
+
}
|
|
639
|
+
const words = memoryWords(text);
|
|
640
|
+
const scored = [];
|
|
641
|
+
for (let i = 0; i < state.memory.length; i += 1) {
|
|
642
|
+
const score = memoryScore(state.memory[i], text, words);
|
|
643
|
+
if (score > 0) scored.push({ score: score, item: state.memory[i] });
|
|
644
|
+
}
|
|
645
|
+
scored.sort(function (a, b) {
|
|
646
|
+
return b.score - a.score || b.item.updatedAt - a.item.updatedAt;
|
|
647
|
+
});
|
|
648
|
+
return scored.slice(0, 3).map(function (entry) {
|
|
649
|
+
return plainMemory(entry.item);
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function writeMemory(record) {
|
|
654
|
+
const source = record && typeof record === 'object' ? record : {};
|
|
655
|
+
const key = labCleanText(source.key, 40);
|
|
656
|
+
const value = labCleanText(source.value, 240);
|
|
657
|
+
if (!key || !value) return null;
|
|
658
|
+
const timestamp = now();
|
|
659
|
+
let item = null;
|
|
660
|
+
for (let i = 0; i < state.memory.length; i += 1) {
|
|
661
|
+
if (state.memory[i].key === key) {
|
|
662
|
+
item = state.memory[i];
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (item) {
|
|
667
|
+
item.value = value;
|
|
668
|
+
item.updatedAt = timestamp;
|
|
669
|
+
if (source.source) item.source = source.source;
|
|
670
|
+
if (source.sessionId) item.sessionId = source.sessionId;
|
|
671
|
+
} else {
|
|
672
|
+
item = {
|
|
673
|
+
id: makeId('mem'),
|
|
674
|
+
key: key,
|
|
675
|
+
value: value,
|
|
676
|
+
source: source.source || '对话',
|
|
677
|
+
sessionId: source.sessionId || state.activeId,
|
|
678
|
+
createdAt: timestamp,
|
|
679
|
+
updatedAt: timestamp,
|
|
680
|
+
};
|
|
681
|
+
state.memory.push(item);
|
|
682
|
+
while (state.memory.length > LAB_MEMORY_LIMIT) state.memory.shift();
|
|
683
|
+
}
|
|
684
|
+
emit('memory');
|
|
685
|
+
persist();
|
|
686
|
+
return { id: item.id, key: item.key, value: item.value };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function deleteMemory(id) {
|
|
690
|
+
let index = -1;
|
|
691
|
+
for (let i = 0; i < state.memory.length; i += 1) {
|
|
692
|
+
if (state.memory[i].id === id) {
|
|
693
|
+
index = i;
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (index < 0) return false;
|
|
698
|
+
state.memory.splice(index, 1);
|
|
699
|
+
emit('memory');
|
|
700
|
+
persist();
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function clearMemory() {
|
|
705
|
+
const count = state.memory.length;
|
|
706
|
+
state.memory = [];
|
|
707
|
+
emit('memory');
|
|
708
|
+
persist();
|
|
709
|
+
return count;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/* ─────────────────────────────────────────────────────────────
|
|
713
|
+
* 上下文组装
|
|
714
|
+
* ─────────────────────────────────────────────────────────────
|
|
715
|
+
* 返回 { context, modelMessages, hits, summaryText, recent, input, used, limit }。
|
|
716
|
+
* context.used 严格等于 segments 的 token 之和。
|
|
717
|
+
*/
|
|
718
|
+
|
|
719
|
+
function assembleContext(session, rawInput, options) {
|
|
720
|
+
const settings = options || {};
|
|
721
|
+
const excludeIds = Array.isArray(settings.excludeIds) ? settings.excludeIds : [];
|
|
722
|
+
const inputRaw = labCleanText(rawInput, LAB_MAX_INPUT_CHARS);
|
|
723
|
+
const hitsAll = Array.isArray(settings.hits) ? settings.hits.slice(0, 3) : recallMemory(inputRaw);
|
|
724
|
+
const candidates = [];
|
|
725
|
+
for (let i = 0; i < session.messages.length; i += 1) {
|
|
726
|
+
const message = session.messages[i];
|
|
727
|
+
if (excludeIds.indexOf(message.id) >= 0) continue;
|
|
728
|
+
candidates.push(message);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
let systemText = LAB_SYSTEM_PROMPT;
|
|
732
|
+
let summary = cloneSummary(session.summary);
|
|
733
|
+
let hits = hitsAll.slice(0, 3);
|
|
734
|
+
let modelInput = inputRaw;
|
|
735
|
+
let recent = candidates.slice();
|
|
736
|
+
|
|
737
|
+
/* rawUsed:未裁剪前真正想塞给模型的总量。压缩阈值看它,
|
|
738
|
+
* context.used 则始终限制在预算内(局部裁剪后模型实际看到的量)。 */
|
|
739
|
+
let rawUsed = estimateTokens(systemText) + LAB_MSG_OVERHEAD;
|
|
740
|
+
const fullSummaryText = renderSummary(summary);
|
|
741
|
+
if (fullSummaryText) rawUsed += estimateTokens(fullSummaryText) + LAB_MSG_OVERHEAD;
|
|
742
|
+
for (let i = 0; i < hitsAll.length && i < 3; i += 1) {
|
|
743
|
+
rawUsed += estimateTokens('记忆:' + hitsAll[i].key + ' = ' + hitsAll[i].value) + LAB_MSG_OVERHEAD;
|
|
744
|
+
}
|
|
745
|
+
for (let i = 0; i < candidates.length; i += 1) {
|
|
746
|
+
rawUsed += candidates[i].tokens || estimateTokens(candidates[i].content) + LAB_MSG_OVERHEAD;
|
|
747
|
+
}
|
|
748
|
+
if (inputRaw) rawUsed += estimateTokens(inputRaw) + LAB_MSG_OVERHEAD;
|
|
749
|
+
|
|
750
|
+
function measure() {
|
|
751
|
+
const summaryText = renderSummary(summary);
|
|
752
|
+
const systemTokens = estimateTokens(systemText) + LAB_MSG_OVERHEAD;
|
|
753
|
+
const summaryTokens = summaryText ? estimateTokens(summaryText) + LAB_MSG_OVERHEAD : 0;
|
|
754
|
+
let memoryTokens = 0;
|
|
755
|
+
for (let i = 0; i < hits.length; i += 1) {
|
|
756
|
+
memoryTokens += estimateTokens('记忆:' + hits[i].key + ' = ' + hits[i].value) + LAB_MSG_OVERHEAD;
|
|
757
|
+
}
|
|
758
|
+
let recentTokens = 0;
|
|
759
|
+
for (let i = 0; i < recent.length; i += 1) {
|
|
760
|
+
recentTokens += recent[i].tokens || estimateTokens(recent[i].content) + LAB_MSG_OVERHEAD;
|
|
761
|
+
}
|
|
762
|
+
const inputTokens = modelInput ? estimateTokens(modelInput) + LAB_MSG_OVERHEAD : 0;
|
|
763
|
+
const used = systemTokens + summaryTokens + memoryTokens + recentTokens + inputTokens;
|
|
764
|
+
return {
|
|
765
|
+
systemTokens: systemTokens,
|
|
766
|
+
summaryTokens: summaryTokens,
|
|
767
|
+
memoryTokens: memoryTokens,
|
|
768
|
+
recentTokens: recentTokens,
|
|
769
|
+
inputTokens: inputTokens,
|
|
770
|
+
used: used,
|
|
771
|
+
summaryText: summaryText,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
let measured = measure();
|
|
776
|
+
|
|
777
|
+
/* 极小 budget 兜底:先裁剪 system prompt */
|
|
778
|
+
if (measured.used > budget && measured.systemTokens > budget) {
|
|
779
|
+
systemText = labTruncateToTokens(LAB_SYSTEM_PROMPT, Math.max(8, budget - LAB_MSG_OVERHEAD));
|
|
780
|
+
measured = measure();
|
|
781
|
+
}
|
|
782
|
+
/* 再从最旧的消息开始缩小「模型视图」(session.messages 本身不变),
|
|
783
|
+
* 优先保留压缩后的摘要,避免摘要数字来回跳动 */
|
|
784
|
+
while (measured.used > budget && recent.length > 0) {
|
|
785
|
+
recent.shift();
|
|
786
|
+
measured = measure();
|
|
787
|
+
}
|
|
788
|
+
/* 仍超预算时再精简摘要:先丢最旧的关键内容,再丢未完成任务,最后缩短下一步 */
|
|
789
|
+
let summaryGuard = 0;
|
|
790
|
+
while (measured.used > budget && summaryGuard < 200) {
|
|
791
|
+
summaryGuard += 1;
|
|
792
|
+
if (summary.keyPoints.length) {
|
|
793
|
+
summary.keyPoints.shift();
|
|
794
|
+
} else if (summary.openTasks.length) {
|
|
795
|
+
summary.openTasks.shift();
|
|
796
|
+
} else if (summary.nextTask) {
|
|
797
|
+
const tokens = estimateTokens(summary.nextTask);
|
|
798
|
+
summary.nextTask = tokens <= 6 ? '' : labTruncateToTokens(summary.nextTask, Math.max(4, tokens - 4));
|
|
799
|
+
} else {
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
measured = measure();
|
|
803
|
+
}
|
|
804
|
+
/* 再减少召回的记忆条数 */
|
|
805
|
+
while (measured.used > budget && hits.length > 0) {
|
|
806
|
+
hits = hits.slice(0, hits.length - 1);
|
|
807
|
+
measured = measure();
|
|
808
|
+
}
|
|
809
|
+
/* 最后裁剪本轮输入 */
|
|
810
|
+
if (measured.used > budget) {
|
|
811
|
+
const other = measured.used - measured.inputTokens;
|
|
812
|
+
const maxInput = budget - other - LAB_MSG_OVERHEAD;
|
|
813
|
+
modelInput = maxInput > 0 ? labTruncateToTokens(inputRaw, maxInput) : '';
|
|
814
|
+
measured = measure();
|
|
815
|
+
}
|
|
816
|
+
/* 极端兜底:清空摘要与记忆,只保留 system + 极短输入 */
|
|
817
|
+
if (measured.used > budget) {
|
|
818
|
+
summary = createEmptySummary();
|
|
819
|
+
hits = [];
|
|
820
|
+
const maxInput = budget - measured.systemTokens - LAB_MSG_OVERHEAD;
|
|
821
|
+
modelInput = maxInput > 0 ? labTruncateToTokens(inputRaw, maxInput) : '';
|
|
822
|
+
measured = measure();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const segments = [
|
|
826
|
+
{ id: 'system', label: '系统提示', tokens: measured.systemTokens },
|
|
827
|
+
{ id: 'summary', label: '历史摘要', tokens: measured.summaryTokens },
|
|
828
|
+
{ id: 'memory', label: '长期记忆', tokens: measured.memoryTokens },
|
|
829
|
+
{ id: 'recent', label: '最近消息', tokens: measured.recentTokens },
|
|
830
|
+
{ id: 'input', label: '本轮输入', tokens: measured.inputTokens },
|
|
831
|
+
];
|
|
832
|
+
|
|
833
|
+
const modelMessages = [];
|
|
834
|
+
modelMessages.push({ role: 'system', content: systemText });
|
|
835
|
+
if (measured.summaryText) {
|
|
836
|
+
modelMessages.push({ role: 'system', content: '【历史摘要】\n' + measured.summaryText });
|
|
837
|
+
}
|
|
838
|
+
if (hits.length) {
|
|
839
|
+
const hitLines = [];
|
|
840
|
+
for (let i = 0; i < hits.length; i += 1) hitLines.push('- ' + hits[i].key + ':' + hits[i].value);
|
|
841
|
+
modelMessages.push({ role: 'system', content: '【长期记忆】\n' + hitLines.join('\n') });
|
|
842
|
+
}
|
|
843
|
+
/* 当前轮已经产生的 assistant/tool 消息必须排在本轮用户输入之后:
|
|
844
|
+
* 真实 LLM 的 tool-call 协议要求 user → assistant(tool_calls) → tool。
|
|
845
|
+
* 之前的实现把 input 统一追加在末尾,会得到 assistant → tool → user 的非法顺序。 */
|
|
846
|
+
const currentTurnId = typeof settings.turnId === 'string' ? settings.turnId : '';
|
|
847
|
+
let currentTurnStart = recent.length;
|
|
848
|
+
if (currentTurnId) {
|
|
849
|
+
for (let i = 0; i < recent.length; i += 1) {
|
|
850
|
+
if (recent[i].turnId === currentTurnId) {
|
|
851
|
+
currentTurnStart = i;
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
function pushModelMessage(message) {
|
|
857
|
+
const entry = { role: message.role, content: message.content };
|
|
858
|
+
if (message.toolName) entry.toolName = message.toolName;
|
|
859
|
+
if (message.kind === 'tool_call') {
|
|
860
|
+
entry.kind = 'tool_call';
|
|
861
|
+
if (message.toolCallId) entry.toolCallId = message.toolCallId;
|
|
862
|
+
if (message.toolArgs) entry.toolArgs = message.toolArgs;
|
|
863
|
+
}
|
|
864
|
+
if (message.role === 'tool' && message.toolCallId) entry.toolCallId = message.toolCallId;
|
|
865
|
+
modelMessages.push(entry);
|
|
866
|
+
}
|
|
867
|
+
for (let i = 0; i < currentTurnStart; i += 1) pushModelMessage(recent[i]);
|
|
868
|
+
if (modelInput) modelMessages.push({ role: 'user', content: modelInput });
|
|
869
|
+
for (let i = currentTurnStart; i < recent.length; i += 1) pushModelMessage(recent[i]);
|
|
870
|
+
|
|
871
|
+
const context = {
|
|
872
|
+
limit: budget,
|
|
873
|
+
used: measured.used,
|
|
874
|
+
segments: segments,
|
|
875
|
+
hits: hits.map(function (hit) {
|
|
876
|
+
return { id: hit.id, key: hit.key, value: hit.value };
|
|
877
|
+
}),
|
|
878
|
+
summary: measured.summaryText || null,
|
|
879
|
+
messageCount: recent.length,
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
context: context,
|
|
884
|
+
modelMessages: modelMessages,
|
|
885
|
+
hits: hits,
|
|
886
|
+
summaryText: measured.summaryText,
|
|
887
|
+
recent: recent,
|
|
888
|
+
input: modelInput,
|
|
889
|
+
used: measured.used,
|
|
890
|
+
rawUsed: Math.max(measured.used, rawUsed),
|
|
891
|
+
limit: budget,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/* ─────────────────────────────────────────────────────────────
|
|
896
|
+
* 压缩
|
|
897
|
+
* ─────────────────────────────────────────────────────────────
|
|
898
|
+
*/
|
|
899
|
+
|
|
900
|
+
/* 把被折叠的消息抽取成结构化摘要:
|
|
901
|
+
* 关键内容(用户意图 / 助手结论 / 工具结果)、未完成任务、下一个任务。
|
|
902
|
+
* 这是启发式总结:模型只看到结果,不会看到这些抽取规则本身。 */
|
|
903
|
+
function foldIntoSummary(session, messages) {
|
|
904
|
+
const summary = cloneSummary(session.summary);
|
|
905
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
906
|
+
const message = messages[i];
|
|
907
|
+
const content = String(message.content || '');
|
|
908
|
+
if (message.role === 'user') {
|
|
909
|
+
const clean = labCleanText(content, 120);
|
|
910
|
+
if (clean && !summaryNoise(clean)) {
|
|
911
|
+
pushLimited(summary.keyPoints, '用户:' + truncate(clean, 38), 5);
|
|
912
|
+
if (summaryTaskLike(clean)) pushLimited(summary.openTasks, summaryTaskLabel(clean), 4);
|
|
913
|
+
}
|
|
914
|
+
} else if (message.role === 'assistant' && (message.kind === 'final' || message.kind === undefined)) {
|
|
915
|
+
const clean = labCleanText(content, 160);
|
|
916
|
+
if (clean) {
|
|
917
|
+
pushLimited(summary.keyPoints, '助手:' + truncate(clean, 42), 5);
|
|
918
|
+
/* 一个任务通常由同一轮助手回答结束:普通回答 → 视为完成;
|
|
919
|
+
* 助手反问 / 要求用户补充 → 保留在「未完成任务」里;
|
|
920
|
+
* 用户明确说了「还没/待办/下一步/稍后」的任务也保留。 */
|
|
921
|
+
if (summary.openTasks.length) {
|
|
922
|
+
const lastTask = summary.openTasks[summary.openTasks.length - 1];
|
|
923
|
+
const explicitPending = /还没|待办|下一步|之后|稍后|明天|提醒我|等我|先别/.test(lastTask);
|
|
924
|
+
const asksBack = /[??]|吗|要不要|是否需要|请告诉我|你想|可以吗|还是/.test(clean);
|
|
925
|
+
if (!explicitPending && (summaryDone(clean) || !asksBack)) summary.openTasks.pop();
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
} else if (message.role === 'tool') {
|
|
929
|
+
const clean = labCleanText(content, 100);
|
|
930
|
+
if (clean) pushLimited(summary.keyPoints, '工具 ' + (message.toolName || '执行') + ':' + truncate(clean, 28), 5);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
summary.folded += messages.length;
|
|
934
|
+
summary.nextTask = summary.openTasks.length
|
|
935
|
+
? summary.openTasks[summary.openTasks.length - 1]
|
|
936
|
+
: (summary.keyPoints.length ? '继续回答用户最近的问题' : '等待用户下一步指令');
|
|
937
|
+
session.summary = trimSummaryObject(summary, Math.max(32, Math.floor(budget * LAB_SUMMARY_TOKEN_RATIO)));
|
|
938
|
+
return messages.length;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/* 任务可能在「折叠边界」被拆开:任务进了摘要,回答还留在最近消息里。
|
|
942
|
+
* 用最近消息里的助手最终回答回补一次,避免把已完成任务误报为未完成。 */
|
|
943
|
+
function reconcileSummaryTasks(session) {
|
|
944
|
+
if (!session.summary || !session.summary.openTasks.length) return;
|
|
945
|
+
for (let i = session.messages.length - 1; i >= 0; i -= 1) {
|
|
946
|
+
const message = session.messages[i];
|
|
947
|
+
if (message.role !== 'assistant' || (message.kind !== 'final' && message.kind !== undefined)) continue;
|
|
948
|
+
const text = String(message.content || '');
|
|
949
|
+
const lastTask = session.summary.openTasks[session.summary.openTasks.length - 1];
|
|
950
|
+
const explicitPending = /还没|待办|下一步|之后|稍后|明天|提醒我|等我|先别/.test(lastTask);
|
|
951
|
+
const asksBack = /[??]|吗|要不要|是否需要|请告诉我|你想|可以吗|还是/.test(text);
|
|
952
|
+
if (!explicitPending && !asksBack) session.summary.openTasks.pop();
|
|
953
|
+
break;
|
|
954
|
+
}
|
|
955
|
+
session.summary.nextTask = session.summary.openTasks.length
|
|
956
|
+
? session.summary.openTasks[session.summary.openTasks.length - 1]
|
|
957
|
+
: '继续回答用户最近的问题';
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function compressSession(session, options) {
|
|
961
|
+
const settings = options || {};
|
|
962
|
+
const turn = settings.turn || null;
|
|
963
|
+
const input = settings.input || '';
|
|
964
|
+
const manual = !!settings.manual;
|
|
965
|
+
const excludeIds = turn && turn.inputMessageId ? [turn.inputMessageId] : [];
|
|
966
|
+
const beforeAsm = assembleContext(session, input, { excludeIds: excludeIds });
|
|
967
|
+
|
|
968
|
+
let keepCount = LAB_RECENT_KEEP;
|
|
969
|
+
if (manual && session.messages.length <= LAB_RECENT_KEEP) {
|
|
970
|
+
keepCount = Math.max(1, Math.floor(session.messages.length / 2));
|
|
971
|
+
}
|
|
972
|
+
const keepStart = Math.max(0, session.messages.length - keepCount);
|
|
973
|
+
const fold = [];
|
|
974
|
+
for (let i = 0; i < session.messages.length; i += 1) {
|
|
975
|
+
const message = session.messages[i];
|
|
976
|
+
const isCurrentTurnMessage = turn && message.turnId === turn.id;
|
|
977
|
+
/* 保留最近消息;一轮 turn 内的消息不在轮内被折叠 */
|
|
978
|
+
if (i >= keepStart || isCurrentTurnMessage) continue;
|
|
979
|
+
fold.push(message);
|
|
980
|
+
}
|
|
981
|
+
if (!fold.length) {
|
|
982
|
+
return { compressed: false, before: Math.max(beforeAsm.used, beforeAsm.rawUsed), after: Math.max(beforeAsm.used, beforeAsm.rawUsed), folded: 0, context: beforeAsm.context };
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/* 先记下折叠前的状态:如果压缩不能真实降低上下文需求,就回滚,
|
|
986
|
+
* 避免出现「418 → 386」「419 → 418」这类没有意义的压缩记录 */
|
|
987
|
+
const messagesBefore = session.messages.slice();
|
|
988
|
+
const summaryBefore = cloneSummary(session.summary);
|
|
989
|
+
|
|
990
|
+
const folded = foldIntoSummary(session, fold);
|
|
991
|
+
reconcileSummaryTasks(session);
|
|
992
|
+
const foldIds = [];
|
|
993
|
+
for (let i = 0; i < fold.length; i += 1) foldIds.push(fold[i].id);
|
|
994
|
+
session.messages = session.messages.filter(function (message) {
|
|
995
|
+
return foldIds.indexOf(message.id) < 0;
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
let afterAsm = assembleContext(session, input, { excludeIds: excludeIds });
|
|
999
|
+
/* 压缩后若仍高于目标水位,继续精简摘要,避免下一轮立刻再次压缩 */
|
|
1000
|
+
let guard = 0;
|
|
1001
|
+
while (afterAsm.context.used > budget * LAB_COMPRESS_TARGET_RATIO && guard < 60) {
|
|
1002
|
+
guard += 1;
|
|
1003
|
+
const summary = session.summary || createEmptySummary();
|
|
1004
|
+
if (summary.keyPoints.length) summary.keyPoints.shift();
|
|
1005
|
+
else if (summary.openTasks.length) summary.openTasks.shift();
|
|
1006
|
+
else if (summary.nextTask) {
|
|
1007
|
+
const tokens = estimateTokens(summary.nextTask);
|
|
1008
|
+
summary.nextTask = tokens <= 6 ? '' : labTruncateToTokens(summary.nextTask, Math.max(4, tokens - 4));
|
|
1009
|
+
} else break;
|
|
1010
|
+
session.summary = trimSummaryObject(summary, Math.max(32, Math.floor(budget * LAB_SUMMARY_TOKEN_RATIO)));
|
|
1011
|
+
afterAsm = assembleContext(session, input, { excludeIds: excludeIds });
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/* before / after 都按「未裁剪的真实上下文需求」计,保证 after < before 才记录 */
|
|
1015
|
+
const before = Math.max(beforeAsm.used, beforeAsm.rawUsed);
|
|
1016
|
+
const after = Math.max(afterAsm.used, afterAsm.rawUsed);
|
|
1017
|
+
if (after >= before) {
|
|
1018
|
+
session.messages = messagesBefore;
|
|
1019
|
+
session.summary = summaryBefore;
|
|
1020
|
+
session.context = beforeAsm.context;
|
|
1021
|
+
return { compressed: false, before: before, after: before, folded: 0, context: beforeAsm.context };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
session.context = afterAsm.context;
|
|
1025
|
+
session.compressions += 1;
|
|
1026
|
+
state.stats.compressions += 1;
|
|
1027
|
+
state.stats.tokensSaved += Math.max(0, before - after);
|
|
1028
|
+
state.lastCompression = { before: before, after: after, folded: folded, at: now() };
|
|
1029
|
+
|
|
1030
|
+
/* 注意:TUI 会按 kind 加行首符号(⇣),所以 transcript.text 保持纯文本 */
|
|
1031
|
+
pushTranscript(session, makeLine('compress', '上下文已压缩 · ' + before + ' → ' + after + ' tok'));
|
|
1032
|
+
emit('compression');
|
|
1033
|
+
emit('line');
|
|
1034
|
+
if (turn && !settings.silentStage) {
|
|
1035
|
+
const stage = beginStage(turn, 'compress', '压缩上下文', before + ' → ' + after + ' tok', {
|
|
1036
|
+
before: before,
|
|
1037
|
+
after: after,
|
|
1038
|
+
folded: folded,
|
|
1039
|
+
/* inspector(60-inspector.js)读的是 removed;folded 留给其它消费者 */
|
|
1040
|
+
removed: folded,
|
|
1041
|
+
keep: session.messages.length,
|
|
1042
|
+
limit: budget,
|
|
1043
|
+
reason: manual ? 'manual' : 'auto',
|
|
1044
|
+
summary: afterAsm.context.summary,
|
|
1045
|
+
});
|
|
1046
|
+
endStage(stage);
|
|
1047
|
+
}
|
|
1048
|
+
session.updatedAt = now();
|
|
1049
|
+
return { compressed: true, before: before, after: after, folded: folded, context: afterAsm.context };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function maybeAutoCompress(session, turn, input, asm) {
|
|
1053
|
+
let current = asm;
|
|
1054
|
+
let attempts = 0;
|
|
1055
|
+
while (
|
|
1056
|
+
(current.rawUsed > budget * LAB_COMPRESS_RATIO || current.context.used > budget * LAB_COMPRESS_RATIO) &&
|
|
1057
|
+
attempts < 2
|
|
1058
|
+
) {
|
|
1059
|
+
attempts += 1;
|
|
1060
|
+
const result = compressSession(session, { turn: turn, input: input, manual: false });
|
|
1061
|
+
if (!result.compressed) break;
|
|
1062
|
+
current = assembleContext(session, input, {
|
|
1063
|
+
excludeIds: turn && turn.inputMessageId ? [turn.inputMessageId] : [],
|
|
1064
|
+
});
|
|
1065
|
+
session.context = current.context;
|
|
1066
|
+
emit('context');
|
|
1067
|
+
}
|
|
1068
|
+
return current;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/* 一轮结束就检查:已超过 90% 预算时立即折叠历史,
|
|
1072
|
+
* 而不是等用户再发一条消息。这样底部的百分比不会长期停在 90%+。 */
|
|
1073
|
+
function maybeCompressAfterTurn(session, turn, input) {
|
|
1074
|
+
const used = session.context ? session.context.used : 0;
|
|
1075
|
+
if (used <= budget * LAB_COMPRESS_RATIO) return;
|
|
1076
|
+
compressSession(session, { turn: turn, input: input, manual: false, silentStage: true });
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/* ─────────────────────────────────────────────────────────────
|
|
1080
|
+
* snapshot(每次返回全新对象)
|
|
1081
|
+
* ─────────────────────────────────────────────────────────────
|
|
1082
|
+
*/
|
|
1083
|
+
|
|
1084
|
+
function snapshotSession(session) {
|
|
1085
|
+
let tokens = 0;
|
|
1086
|
+
for (let i = 0; i < session.messages.length; i += 1) tokens += session.messages[i].tokens;
|
|
1087
|
+
return {
|
|
1088
|
+
id: session.id,
|
|
1089
|
+
title: session.title,
|
|
1090
|
+
createdAt: session.createdAt,
|
|
1091
|
+
updatedAt: session.updatedAt,
|
|
1092
|
+
messageCount: session.messages.length,
|
|
1093
|
+
tokens: tokens,
|
|
1094
|
+
compressions: session.compressions,
|
|
1095
|
+
turns: session.turns,
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function snapshotMessage(message) {
|
|
1100
|
+
const out = {
|
|
1101
|
+
id: message.id,
|
|
1102
|
+
role: message.role,
|
|
1103
|
+
content: message.content,
|
|
1104
|
+
tokens: message.tokens,
|
|
1105
|
+
at: message.at,
|
|
1106
|
+
};
|
|
1107
|
+
if (message.kind) out.kind = message.kind;
|
|
1108
|
+
if (message.toolName) out.toolName = message.toolName;
|
|
1109
|
+
if (message.toolCallId) out.toolCallId = message.toolCallId;
|
|
1110
|
+
if (message.toolArgs) out.toolArgs = message.toolArgs;
|
|
1111
|
+
return out;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function snapshotLine(line) {
|
|
1115
|
+
const out = { id: line.id, kind: line.kind, text: line.text, at: line.at };
|
|
1116
|
+
if (line.streaming) out.streaming = true;
|
|
1117
|
+
return out;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function snapshotContext(session) {
|
|
1121
|
+
const context =
|
|
1122
|
+
session && session.context
|
|
1123
|
+
? session.context
|
|
1124
|
+
: { limit: budget, used: 0, segments: [], hits: [], summary: null, messageCount: 0 };
|
|
1125
|
+
return {
|
|
1126
|
+
limit: context.limit,
|
|
1127
|
+
used: context.used,
|
|
1128
|
+
segments: context.segments.map(function (segment) {
|
|
1129
|
+
return { id: segment.id, label: segment.label, tokens: segment.tokens };
|
|
1130
|
+
}),
|
|
1131
|
+
hits: context.hits.map(function (hit) {
|
|
1132
|
+
return { id: hit.id, key: hit.key, value: hit.value };
|
|
1133
|
+
}),
|
|
1134
|
+
summary: context.summary,
|
|
1135
|
+
messageCount: context.messageCount,
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function snapshotTurn(turn) {
|
|
1140
|
+
return {
|
|
1141
|
+
id: turn.id,
|
|
1142
|
+
input: turn.input,
|
|
1143
|
+
status: turn.status,
|
|
1144
|
+
startedAt: turn.startedAt,
|
|
1145
|
+
endedAt: turn.endedAt,
|
|
1146
|
+
stages: turn.stages.map(function (stage) {
|
|
1147
|
+
return {
|
|
1148
|
+
id: stage.id,
|
|
1149
|
+
kind: stage.kind,
|
|
1150
|
+
title: stage.title,
|
|
1151
|
+
meta: stage.meta,
|
|
1152
|
+
status: stage.status,
|
|
1153
|
+
detail: labClone(stage.detail),
|
|
1154
|
+
at: stage.at,
|
|
1155
|
+
};
|
|
1156
|
+
}),
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function snapshot() {
|
|
1161
|
+
const session = activeSession();
|
|
1162
|
+
const turn = state.turn;
|
|
1163
|
+
return {
|
|
1164
|
+
busy: busy(),
|
|
1165
|
+
pace: state.pace,
|
|
1166
|
+
modelMode: state.modelMode,
|
|
1167
|
+
model: {
|
|
1168
|
+
provider: modelInfo.provider,
|
|
1169
|
+
model: modelInfo.model,
|
|
1170
|
+
modelName: modelInfo.modelName,
|
|
1171
|
+
signedIn: modelInfo.signedIn,
|
|
1172
|
+
available: modelInfo.available,
|
|
1173
|
+
contextWindow: modelInfo.contextWindow,
|
|
1174
|
+
maxOutputTokens: modelInfo.maxOutputTokens,
|
|
1175
|
+
lastUsage: modelInfo.lastUsage,
|
|
1176
|
+
lastLatencyMs: modelInfo.lastLatencyMs,
|
|
1177
|
+
lastError: modelInfo.lastError,
|
|
1178
|
+
},
|
|
1179
|
+
activeId: session ? session.id : '',
|
|
1180
|
+
sessions: state.sessions.map(snapshotSession),
|
|
1181
|
+
messages: session ? session.messages.map(snapshotMessage) : [],
|
|
1182
|
+
transcript: session ? session.transcript.map(snapshotLine) : [],
|
|
1183
|
+
memory: state.memory.map(plainMemory),
|
|
1184
|
+
context: snapshotContext(session),
|
|
1185
|
+
turn: turn ? snapshotTurn(turn) : null,
|
|
1186
|
+
streaming: state.streaming ? { lineId: state.streaming.lineId, text: state.streaming.text } : null,
|
|
1187
|
+
stats: {
|
|
1188
|
+
turns: state.stats.turns,
|
|
1189
|
+
toolCalls: state.stats.toolCalls,
|
|
1190
|
+
compressions: state.stats.compressions,
|
|
1191
|
+
tokensSaved: state.stats.tokensSaved,
|
|
1192
|
+
totalTokens: state.stats.totalTokens,
|
|
1193
|
+
},
|
|
1194
|
+
lastInput: state.lastInput,
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/* ─────────────────────────────────────────────────────────────
|
|
1199
|
+
* turn / stage
|
|
1200
|
+
* ─────────────────────────────────────────────────────────────
|
|
1201
|
+
*/
|
|
1202
|
+
|
|
1203
|
+
function makeTurn(input) {
|
|
1204
|
+
return {
|
|
1205
|
+
id: makeId('t'),
|
|
1206
|
+
input: input,
|
|
1207
|
+
status: 'running',
|
|
1208
|
+
startedAt: now(),
|
|
1209
|
+
endedAt: null,
|
|
1210
|
+
stages: [],
|
|
1211
|
+
inputMessageId: null,
|
|
1212
|
+
toolCalls: 0,
|
|
1213
|
+
cancelled: false,
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function beginStage(turn, kind, title, meta, detail) {
|
|
1218
|
+
const stage = {
|
|
1219
|
+
id: makeId('st'),
|
|
1220
|
+
kind: kind,
|
|
1221
|
+
title: title || '',
|
|
1222
|
+
meta: meta || '',
|
|
1223
|
+
status: 'active',
|
|
1224
|
+
detail: detail === undefined ? null : detail,
|
|
1225
|
+
at: now(),
|
|
1226
|
+
};
|
|
1227
|
+
turn.stages.push(stage);
|
|
1228
|
+
emit('stage');
|
|
1229
|
+
return stage;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function endStage(stage, patch) {
|
|
1233
|
+
if (!stage) return;
|
|
1234
|
+
const update = patch || {};
|
|
1235
|
+
if (update.meta !== undefined) stage.meta = update.meta;
|
|
1236
|
+
if (update.detail !== undefined) stage.detail = update.detail;
|
|
1237
|
+
if (update.title !== undefined) stage.title = update.title;
|
|
1238
|
+
stage.status = update.status || 'done';
|
|
1239
|
+
emit('stage');
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function shouldAbort(turn, session) {
|
|
1243
|
+
return state.disposed || !turn || turn.cancelled || state.sessions.indexOf(session) < 0;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function contextStageDetail(asm) {
|
|
1247
|
+
return {
|
|
1248
|
+
used: asm.context.used,
|
|
1249
|
+
limit: asm.context.limit,
|
|
1250
|
+
messageCount: asm.context.messageCount,
|
|
1251
|
+
segments: asm.context.segments.map(function (segment) {
|
|
1252
|
+
return { id: segment.id, label: segment.label, tokens: segment.tokens };
|
|
1253
|
+
}),
|
|
1254
|
+
hits: asm.context.hits.map(function (hit) {
|
|
1255
|
+
return { id: hit.id, key: hit.key, value: hit.value };
|
|
1256
|
+
}),
|
|
1257
|
+
summary: asm.context.summary,
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function findTool(name) {
|
|
1262
|
+
for (let i = 0; i < LAB_TOOLS.length; i += 1) {
|
|
1263
|
+
if (LAB_TOOLS[i].name === name) return LAB_TOOLS[i];
|
|
1264
|
+
}
|
|
1265
|
+
return null;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function toolContext(session) {
|
|
1269
|
+
return {
|
|
1270
|
+
now: now,
|
|
1271
|
+
memory: state.memory,
|
|
1272
|
+
sessionId: session.id,
|
|
1273
|
+
writeMemory: writeMemory,
|
|
1274
|
+
searchMemory: searchMemory,
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function normalizeToolOutcome(outcome) {
|
|
1279
|
+
const raw = outcome && typeof outcome === 'object' ? outcome : {};
|
|
1280
|
+
const ok = raw.ok === true;
|
|
1281
|
+
let result = raw.result;
|
|
1282
|
+
if (result === undefined) result = ok ? null : { error: '工具调用失败' };
|
|
1283
|
+
let display = typeof raw.display === 'string' ? raw.display : '';
|
|
1284
|
+
if (!display) {
|
|
1285
|
+
if (typeof result === 'string') display = result;
|
|
1286
|
+
else if (result && typeof result === 'object' && typeof result.error === 'string') display = '错误:' + result.error;
|
|
1287
|
+
else display = ok ? '(工具没有返回文本)' : '错误:工具调用失败';
|
|
1288
|
+
}
|
|
1289
|
+
return { ok: ok, result: result, display: display };
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/* ─────────────────────────────────────────────────────────────
|
|
1293
|
+
* 一轮 agent loop
|
|
1294
|
+
* ─────────────────────────────────────────────────────────────
|
|
1295
|
+
*/
|
|
1296
|
+
|
|
1297
|
+
async function runTurn(turn, session) {
|
|
1298
|
+
const input = turn.input;
|
|
1299
|
+
let lastTool = null;
|
|
1300
|
+
let steps = 0;
|
|
1301
|
+
let toolCalls = 0;
|
|
1302
|
+
let finalText = '';
|
|
1303
|
+
let converged = false;
|
|
1304
|
+
|
|
1305
|
+
try {
|
|
1306
|
+
/* 1. 输入阶段 */
|
|
1307
|
+
const inputStage = beginStage(
|
|
1308
|
+
turn,
|
|
1309
|
+
'input',
|
|
1310
|
+
'接收输入',
|
|
1311
|
+
labCharCount(input) + ' 字 · ' + estimateTokens(input) + ' tok',
|
|
1312
|
+
{ text: input, tokens: estimateTokens(input), chars: labCharCount(input) },
|
|
1313
|
+
);
|
|
1314
|
+
await sleep(labStageDelayMs(state.pace));
|
|
1315
|
+
if (shouldAbort(turn, session)) {
|
|
1316
|
+
endStage(inputStage, { status: 'error' });
|
|
1317
|
+
return finalizeCancelled(turn, session);
|
|
1318
|
+
}
|
|
1319
|
+
endStage(inputStage);
|
|
1320
|
+
|
|
1321
|
+
/* 2. 上下文阶段 */
|
|
1322
|
+
let asm = assembleContext(session, input, {
|
|
1323
|
+
excludeIds: turn.inputMessageId ? [turn.inputMessageId] : [],
|
|
1324
|
+
turnId: turn.id,
|
|
1325
|
+
});
|
|
1326
|
+
session.context = asm.context;
|
|
1327
|
+
emit('context');
|
|
1328
|
+
const contextStage = beginStage(
|
|
1329
|
+
turn,
|
|
1330
|
+
'context',
|
|
1331
|
+
'组装上下文',
|
|
1332
|
+
asm.context.messageCount + ' 条消息 · ' + asm.context.used + '/' + budget + ' tok',
|
|
1333
|
+
contextStageDetail(asm),
|
|
1334
|
+
);
|
|
1335
|
+
await sleep(labStageDelayMs(state.pace));
|
|
1336
|
+
if (shouldAbort(turn, session)) {
|
|
1337
|
+
endStage(contextStage, { status: 'error' });
|
|
1338
|
+
return finalizeCancelled(turn, session);
|
|
1339
|
+
}
|
|
1340
|
+
endStage(contextStage);
|
|
1341
|
+
|
|
1342
|
+
asm = maybeAutoCompress(session, turn, input, asm);
|
|
1343
|
+
if (shouldAbort(turn, session)) return finalizeCancelled(turn, session);
|
|
1344
|
+
|
|
1345
|
+
/* 3. 模型 ↔ 工具循环 */
|
|
1346
|
+
for (let step = 1; step <= LAB_MAX_STEPS; step += 1) {
|
|
1347
|
+
steps = step;
|
|
1348
|
+
if (step > 1) {
|
|
1349
|
+
/* 工具循环内只重新组装上下文,不再自动压缩:
|
|
1350
|
+
* 自动压缩只在一轮开始时(第一次模型调用前)做一次,
|
|
1351
|
+
* 否则当前轮消息受保护无法折叠,只会产生无意义的压缩记录。 */
|
|
1352
|
+
asm = assembleContext(session, input, {
|
|
1353
|
+
excludeIds: turn.inputMessageId ? [turn.inputMessageId] : [],
|
|
1354
|
+
turnId: turn.id,
|
|
1355
|
+
});
|
|
1356
|
+
session.context = asm.context;
|
|
1357
|
+
emit('context');
|
|
1358
|
+
if (shouldAbort(turn, session)) return finalizeCancelled(turn, session);
|
|
1359
|
+
}
|
|
1360
|
+
state.stats.totalTokens += asm.context.used;
|
|
1361
|
+
|
|
1362
|
+
const modelStage = beginStage(turn, 'model', '模型第 ' + step + ' 步', '思考中…', {
|
|
1363
|
+
step: step,
|
|
1364
|
+
messages: asm.modelMessages,
|
|
1365
|
+
toolSchemas: LAB_TOOL_SCHEMAS,
|
|
1366
|
+
response: null,
|
|
1367
|
+
});
|
|
1368
|
+
await sleep(labStageDelayMs(state.pace));
|
|
1369
|
+
if (shouldAbort(turn, session)) {
|
|
1370
|
+
endStage(modelStage, { status: 'error' });
|
|
1371
|
+
return finalizeCancelled(turn, session);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
let plan;
|
|
1375
|
+
if (state.modelMode === 'real') {
|
|
1376
|
+
if (typeof realAdapter !== 'function') {
|
|
1377
|
+
endStage(modelStage, { meta: '真实模型未接入', status: 'error' });
|
|
1378
|
+
return finalizeTurnError(turn, session, '真实模型适配器未连接,请刷新页面后重试');
|
|
1379
|
+
}
|
|
1380
|
+
modelStage.detail.real = true;
|
|
1381
|
+
try {
|
|
1382
|
+
plan = await realAdapter({
|
|
1383
|
+
input: input,
|
|
1384
|
+
step: step,
|
|
1385
|
+
messages: asm.modelMessages,
|
|
1386
|
+
tools: LAB_TOOL_SCHEMAS,
|
|
1387
|
+
hits: asm.hits,
|
|
1388
|
+
lastTool: lastTool,
|
|
1389
|
+
usage: { used: asm.context.used, limit: budget },
|
|
1390
|
+
cancelled: function () {
|
|
1391
|
+
return shouldAbort(turn, session);
|
|
1392
|
+
},
|
|
1393
|
+
});
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
const message = labErrorMessage(error);
|
|
1396
|
+
modelInfo.lastError = message;
|
|
1397
|
+
modelStage.detail.error = message;
|
|
1398
|
+
endStage(modelStage, { meta: '真实模型调用失败', status: 'error' });
|
|
1399
|
+
throw error;
|
|
1400
|
+
}
|
|
1401
|
+
if (shouldAbort(turn, session)) {
|
|
1402
|
+
endStage(modelStage, { status: 'error' });
|
|
1403
|
+
return finalizeCancelled(turn, session);
|
|
1404
|
+
}
|
|
1405
|
+
if (plan && typeof plan === 'object') {
|
|
1406
|
+
if (plan.provider) modelInfo.provider = String(plan.provider);
|
|
1407
|
+
if (plan.model) modelInfo.model = String(plan.model);
|
|
1408
|
+
if (plan.usage && typeof plan.usage === 'object') modelInfo.lastUsage = plan.usage;
|
|
1409
|
+
if (typeof plan.latencyMs === 'number') modelInfo.lastLatencyMs = plan.latencyMs;
|
|
1410
|
+
if (plan.usage || typeof plan.latencyMs === 'number') modelInfo.lastError = null;
|
|
1411
|
+
modelStage.detail.model = {
|
|
1412
|
+
provider: modelInfo.provider,
|
|
1413
|
+
model: modelInfo.model,
|
|
1414
|
+
latencyMs: typeof plan.latencyMs === 'number' ? Math.round(plan.latencyMs) : null,
|
|
1415
|
+
usage: plan.usage || null,
|
|
1416
|
+
};
|
|
1417
|
+
const bits = ['真实模型', modelInfo.model || '当前模型'];
|
|
1418
|
+
if (typeof plan.latencyMs === 'number') bits.push(Math.round(plan.latencyMs) + ' ms');
|
|
1419
|
+
if (plan.usage) bits.push('输入 ' + (plan.usage.inputTokens || 0) + ' / 输出 ' + (plan.usage.outputTokens || 0) + ' tok');
|
|
1420
|
+
pushTranscript(session, makeLine('meta', bits.join(' · ')));
|
|
1421
|
+
emit('line');
|
|
1422
|
+
emit('state');
|
|
1423
|
+
}
|
|
1424
|
+
} else {
|
|
1425
|
+
plan = labNextStep({
|
|
1426
|
+
input: input,
|
|
1427
|
+
step: step,
|
|
1428
|
+
messages: asm.modelMessages,
|
|
1429
|
+
hits: asm.hits,
|
|
1430
|
+
memory: state.memory,
|
|
1431
|
+
usage: { used: asm.context.used, limit: budget },
|
|
1432
|
+
compression: state.lastCompression,
|
|
1433
|
+
lastTool: lastTool,
|
|
1434
|
+
now: now,
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (!plan || (plan.type !== 'tool_call' && plan.type !== 'final')) {
|
|
1439
|
+
modelStage.detail.response = plan || null;
|
|
1440
|
+
endStage(modelStage, { meta: '返回无法识别的计划', status: 'error' });
|
|
1441
|
+
return finalizeTurnError(turn, session, '模型返回了无法识别的计划');
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
if (plan.type === 'tool_call') {
|
|
1445
|
+
modelStage.detail.response = plan;
|
|
1446
|
+
endStage(modelStage, { meta: '第 ' + step + ' 步 · 调用 ' + String(plan.name) });
|
|
1447
|
+
pushTranscript(session, makeLine('llm', '模型第 ' + step + ' 步 · 决定调用 ' + String(plan.name)));
|
|
1448
|
+
emit('line');
|
|
1449
|
+
|
|
1450
|
+
const tool = findTool(plan.name);
|
|
1451
|
+
const toolArgs = plan.args && typeof plan.args === 'object' ? plan.args : {};
|
|
1452
|
+
const toolStage = beginStage(turn, 'tool', '调用 ' + String(plan.name), '执行中…', {
|
|
1453
|
+
name: plan.name,
|
|
1454
|
+
args: labClone(toolArgs),
|
|
1455
|
+
});
|
|
1456
|
+
pushTranscript(session, makeLine('tool', String(plan.name) + ' ' + labJson(toolArgs)));
|
|
1457
|
+
const callId = makeId('call');
|
|
1458
|
+
appendMessage(session, 'assistant', String(plan.name) + '(' + labJson(toolArgs) + ')', {
|
|
1459
|
+
kind: 'tool_call',
|
|
1460
|
+
toolName: plan.name,
|
|
1461
|
+
toolCallId: callId,
|
|
1462
|
+
toolArgs: labJson(toolArgs),
|
|
1463
|
+
turnId: turn.id,
|
|
1464
|
+
});
|
|
1465
|
+
emit('tool_call', { name: plan.name });
|
|
1466
|
+
emit('line');
|
|
1467
|
+
await sleep(labStageDelayMs(state.pace));
|
|
1468
|
+
if (shouldAbort(turn, session)) {
|
|
1469
|
+
endStage(toolStage, { status: 'error' });
|
|
1470
|
+
return finalizeCancelled(turn, session);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
const startedAt = now();
|
|
1474
|
+
let outcome = null;
|
|
1475
|
+
if (!tool) {
|
|
1476
|
+
outcome = {
|
|
1477
|
+
ok: false,
|
|
1478
|
+
result: { error: '未知工具 ' + String(plan.name) },
|
|
1479
|
+
display: '错误:未知工具 ' + String(plan.name),
|
|
1480
|
+
};
|
|
1481
|
+
} else {
|
|
1482
|
+
try {
|
|
1483
|
+
outcome = await tool.run(toolArgs, toolContext(session));
|
|
1484
|
+
} catch (err) {
|
|
1485
|
+
outcome = { ok: false, result: { error: labErrorMessage(err) }, display: '错误:' + labErrorMessage(err) };
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
outcome = normalizeToolOutcome(outcome);
|
|
1489
|
+
const durationMs = Math.max(0, now() - startedAt);
|
|
1490
|
+
endStage(toolStage, {
|
|
1491
|
+
meta: String(plan.name) + ' · ' + (durationMs > 0 ? durationMs + ' ms' : '<1 ms'),
|
|
1492
|
+
detail: {
|
|
1493
|
+
name: plan.name,
|
|
1494
|
+
args: labClone(toolArgs),
|
|
1495
|
+
ok: outcome.ok,
|
|
1496
|
+
result: labClone(outcome.result),
|
|
1497
|
+
display: outcome.display,
|
|
1498
|
+
durationMs: durationMs,
|
|
1499
|
+
},
|
|
1500
|
+
});
|
|
1501
|
+
pushTranscript(session, makeLine('toolresult', outcome.display));
|
|
1502
|
+
appendMessage(session, 'tool', outcome.display, {
|
|
1503
|
+
kind: 'tool_result',
|
|
1504
|
+
toolName: plan.name,
|
|
1505
|
+
toolCallId: callId,
|
|
1506
|
+
turnId: turn.id,
|
|
1507
|
+
});
|
|
1508
|
+
emit('tool_result', { name: plan.name, ok: outcome.ok });
|
|
1509
|
+
emit('line');
|
|
1510
|
+
toolCalls += 1;
|
|
1511
|
+
turn.toolCalls = toolCalls;
|
|
1512
|
+
state.stats.toolCalls += 1;
|
|
1513
|
+
|
|
1514
|
+
if (plan.name === 'memory_write' && outcome.ok) {
|
|
1515
|
+
const saved = outcome.result && typeof outcome.result === 'object' ? outcome.result : {};
|
|
1516
|
+
const memoryStage = beginStage(
|
|
1517
|
+
turn,
|
|
1518
|
+
'memory',
|
|
1519
|
+
'写入长期记忆',
|
|
1520
|
+
String(saved.key) + ' = ' + String(saved.value),
|
|
1521
|
+
{ key: saved.key, value: saved.value, id: saved.id },
|
|
1522
|
+
);
|
|
1523
|
+
endStage(memoryStage);
|
|
1524
|
+
pushTranscript(
|
|
1525
|
+
session,
|
|
1526
|
+
makeLine('memory', '记忆已保存 · ' + String(saved.key) + ' = ' + String(saved.value)),
|
|
1527
|
+
);
|
|
1528
|
+
emit('line');
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
lastTool = {
|
|
1532
|
+
name: plan.name,
|
|
1533
|
+
args: toolArgs,
|
|
1534
|
+
ok: outcome.ok,
|
|
1535
|
+
result: outcome.result,
|
|
1536
|
+
display: outcome.display,
|
|
1537
|
+
};
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/* 最终回答 */
|
|
1542
|
+
finalText = typeof plan.text === 'string' ? plan.text : '(模型没有给出回答)';
|
|
1543
|
+
modelStage.detail.response = plan;
|
|
1544
|
+
endStage(modelStage, { meta: '第 ' + step + ' 步 · 生成回答' });
|
|
1545
|
+
pushTranscript(session, makeLine('llm', '模型第 ' + step + ' 步 · 生成回答'));
|
|
1546
|
+
emit('line');
|
|
1547
|
+
converged = true;
|
|
1548
|
+
break;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
if (!converged) return finalizeMaxSteps(turn, session, steps);
|
|
1552
|
+
if (shouldAbort(turn, session)) return finalizeCancelled(turn, session);
|
|
1553
|
+
|
|
1554
|
+
/* 4. 流式输出最终回答 */
|
|
1555
|
+
finalText = String(finalText || '(本轮没有生成回答)');
|
|
1556
|
+
const answerLine = makeLine('agent', '');
|
|
1557
|
+
answerLine.streaming = true;
|
|
1558
|
+
pushTranscript(session, answerLine);
|
|
1559
|
+
state.streaming = { lineId: answerLine.id, text: '' };
|
|
1560
|
+
emit('line');
|
|
1561
|
+
const streamed = await labStreamAnswer(finalText, {
|
|
1562
|
+
pace: state.pace,
|
|
1563
|
+
sleep: sleep,
|
|
1564
|
+
cancelled: function () {
|
|
1565
|
+
return shouldAbort(turn, session);
|
|
1566
|
+
},
|
|
1567
|
+
onToken: function (chunk) {
|
|
1568
|
+
answerLine.text += chunk;
|
|
1569
|
+
state.streaming = { lineId: answerLine.id, text: answerLine.text };
|
|
1570
|
+
emit('token', { text: chunk });
|
|
1571
|
+
},
|
|
1572
|
+
});
|
|
1573
|
+
answerLine.streaming = false;
|
|
1574
|
+
state.streaming = null;
|
|
1575
|
+
if (!streamed || shouldAbort(turn, session)) return finalizeCancelled(turn, session);
|
|
1576
|
+
appendMessage(session, 'assistant', finalText, { kind: 'final', turnId: turn.id });
|
|
1577
|
+
emit('line');
|
|
1578
|
+
|
|
1579
|
+
/* 5. 收尾:重新组装上下文(保留本轮召回记忆),补 output stage 与 meta 行 */
|
|
1580
|
+
const endAsm = assembleContext(session, input, {
|
|
1581
|
+
excludeIds: turn.inputMessageId ? [turn.inputMessageId] : [],
|
|
1582
|
+
turnId: turn.id,
|
|
1583
|
+
});
|
|
1584
|
+
session.context = endAsm.context;
|
|
1585
|
+
emit('context');
|
|
1586
|
+
const outputStage = beginStage(
|
|
1587
|
+
turn,
|
|
1588
|
+
'output',
|
|
1589
|
+
'生成输出',
|
|
1590
|
+
estimateTokens(finalText) + ' tok · ' + steps + ' 步 · ' + toolCalls + ' 次工具',
|
|
1591
|
+
{ text: finalText, tokens: estimateTokens(finalText), steps: steps, toolCalls: toolCalls },
|
|
1592
|
+
);
|
|
1593
|
+
endStage(outputStage);
|
|
1594
|
+
maybeCompressAfterTurn(session, turn, input);
|
|
1595
|
+
const finalUsed = session.context ? session.context.used : endAsm.context.used;
|
|
1596
|
+
pushTranscript(
|
|
1597
|
+
session,
|
|
1598
|
+
makeLine(
|
|
1599
|
+
'meta',
|
|
1600
|
+
'完成 · ' + steps + ' 步 · ' + toolCalls + ' 次工具 · 上下文 ' + finalUsed + '/' + budget + ' tok',
|
|
1601
|
+
),
|
|
1602
|
+
);
|
|
1603
|
+
emit('line');
|
|
1604
|
+
|
|
1605
|
+
turn.status = 'done';
|
|
1606
|
+
turn.endedAt = now();
|
|
1607
|
+
session.turns += 1;
|
|
1608
|
+
session.lastTurn = turn;
|
|
1609
|
+
state.stats.turns += 1;
|
|
1610
|
+
persist();
|
|
1611
|
+
emit('turn_end', { turnId: turn.id });
|
|
1612
|
+
emit('state');
|
|
1613
|
+
} catch (err) {
|
|
1614
|
+
finalizeTurnError(turn, session, labErrorMessage(err));
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
function markActiveStageError(turn) {
|
|
1619
|
+
for (let i = turn.stages.length - 1; i >= 0; i -= 1) {
|
|
1620
|
+
if (turn.stages[i].status === 'active') {
|
|
1621
|
+
turn.stages[i].status = 'error';
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function finalizeCancelled(turn, session) {
|
|
1628
|
+
if (!turn || turn.status !== 'running') return;
|
|
1629
|
+
turn.status = 'error';
|
|
1630
|
+
turn.endedAt = now();
|
|
1631
|
+
state.streaming = null;
|
|
1632
|
+
markActiveStageError(turn);
|
|
1633
|
+
const stillActive = state.sessions.indexOf(session) >= 0;
|
|
1634
|
+
if (stillActive) {
|
|
1635
|
+
pushTranscript(session, makeLine('info', '本轮已取消'));
|
|
1636
|
+
pushTranscript(session, makeLine('meta', '已取消'));
|
|
1637
|
+
session.lastTurn = turn;
|
|
1638
|
+
}
|
|
1639
|
+
persist();
|
|
1640
|
+
if (!state.disposed && stillActive) {
|
|
1641
|
+
emit('line');
|
|
1642
|
+
emit('turn_end', { turnId: turn.id });
|
|
1643
|
+
emit('state');
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
function finalizeTurnError(turn, session, message) {
|
|
1648
|
+
if (!turn || turn.status !== 'running') return;
|
|
1649
|
+
turn.status = 'error';
|
|
1650
|
+
turn.endedAt = now();
|
|
1651
|
+
state.streaming = null;
|
|
1652
|
+
markActiveStageError(turn);
|
|
1653
|
+
const stillActive = state.sessions.indexOf(session) >= 0;
|
|
1654
|
+
if (stillActive) {
|
|
1655
|
+
pushTranscript(session, makeLine('error', '发生错误:' + message));
|
|
1656
|
+
pushTranscript(session, makeLine('meta', '失败 · ' + truncate(message, 40)));
|
|
1657
|
+
session.turns += 1;
|
|
1658
|
+
session.lastTurn = turn;
|
|
1659
|
+
state.stats.turns += 1;
|
|
1660
|
+
}
|
|
1661
|
+
persist();
|
|
1662
|
+
if (!state.disposed && stillActive) {
|
|
1663
|
+
emit('error', { message: message });
|
|
1664
|
+
emit('line');
|
|
1665
|
+
emit('turn_end', { turnId: turn.id });
|
|
1666
|
+
emit('state');
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
function finalizeMaxSteps(turn, session, steps) {
|
|
1671
|
+
if (!turn || turn.status !== 'running') return;
|
|
1672
|
+
const message = '已达到单轮最大步数(' + LAB_MAX_STEPS + '),本轮未收敛';
|
|
1673
|
+
turn.status = 'error';
|
|
1674
|
+
turn.endedAt = now();
|
|
1675
|
+
state.streaming = null;
|
|
1676
|
+
markActiveStageError(turn);
|
|
1677
|
+
const stillActive = state.sessions.indexOf(session) >= 0;
|
|
1678
|
+
if (stillActive) {
|
|
1679
|
+
pushTranscript(session, makeLine('error', message));
|
|
1680
|
+
pushTranscript(session, makeLine('meta', '失败 · 超过最大步数 ' + steps));
|
|
1681
|
+
session.turns += 1;
|
|
1682
|
+
session.lastTurn = turn;
|
|
1683
|
+
state.stats.turns += 1;
|
|
1684
|
+
}
|
|
1685
|
+
persist();
|
|
1686
|
+
if (!state.disposed && stillActive) {
|
|
1687
|
+
emit('error', { message: message });
|
|
1688
|
+
emit('line');
|
|
1689
|
+
emit('turn_end', { turnId: turn.id });
|
|
1690
|
+
emit('state');
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/* ─────────────────────────────────────────────────────────────
|
|
1695
|
+
* 公开 API
|
|
1696
|
+
* ─────────────────────────────────────────────────────────────
|
|
1697
|
+
*/
|
|
1698
|
+
|
|
1699
|
+
function submit(rawText) {
|
|
1700
|
+
if (state.disposed) return Promise.resolve();
|
|
1701
|
+
if (busy()) return Promise.resolve(); /* 并发 submit:直接拒绝重复,不排队 */
|
|
1702
|
+
const text = labCleanText(rawText, LAB_MAX_INPUT_CHARS);
|
|
1703
|
+
if (!text) return Promise.resolve();
|
|
1704
|
+
const session = activeSession();
|
|
1705
|
+
if (!session) return Promise.resolve();
|
|
1706
|
+
|
|
1707
|
+
const turn = makeTurn(text);
|
|
1708
|
+
state.turn = turn;
|
|
1709
|
+
state.lastInput = text;
|
|
1710
|
+
state.streaming = null;
|
|
1711
|
+
|
|
1712
|
+
if (!session.title || session.title === '新会话') session.title = truncate(text, 20);
|
|
1713
|
+
session.lastTurn = turn;
|
|
1714
|
+
emit('turn_start', { turnId: turn.id });
|
|
1715
|
+
pushTranscript(session, makeLine('user', text));
|
|
1716
|
+
emit('line');
|
|
1717
|
+
emit('state');
|
|
1718
|
+
const userMessage = appendMessage(session, 'user', text, { turnId: turn.id });
|
|
1719
|
+
turn.inputMessageId = userMessage.id;
|
|
1720
|
+
|
|
1721
|
+
return Promise.resolve()
|
|
1722
|
+
.then(function () {
|
|
1723
|
+
return runTurn(turn, session);
|
|
1724
|
+
})
|
|
1725
|
+
.catch(function (err) {
|
|
1726
|
+
finalizeTurnError(turn, session, labErrorMessage(err));
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
function compact() {
|
|
1731
|
+
if (state.disposed) return false;
|
|
1732
|
+
const session = activeSession();
|
|
1733
|
+
if (!session) return false;
|
|
1734
|
+
if (busy()) {
|
|
1735
|
+
appendInfoLine(session, '本轮还在进行,结束后再压缩');
|
|
1736
|
+
return false;
|
|
1737
|
+
}
|
|
1738
|
+
if (session.messages.length < 3) {
|
|
1739
|
+
appendInfoLine(session, '消息太少,暂时不需要压缩');
|
|
1740
|
+
persist();
|
|
1741
|
+
return false;
|
|
1742
|
+
}
|
|
1743
|
+
const result = compressSession(session, { manual: true, input: '' });
|
|
1744
|
+
if (!result.compressed) {
|
|
1745
|
+
appendInfoLine(session, '暂无可折叠的历史消息');
|
|
1746
|
+
persist();
|
|
1747
|
+
return false;
|
|
1748
|
+
}
|
|
1749
|
+
session.context = result.context;
|
|
1750
|
+
emit('context');
|
|
1751
|
+
emit('state');
|
|
1752
|
+
persist();
|
|
1753
|
+
return true;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function newSession() {
|
|
1757
|
+
if (state.disposed || busy()) return false;
|
|
1758
|
+
const session = makeSession();
|
|
1759
|
+
state.sessions.unshift(session);
|
|
1760
|
+
while (state.sessions.length > LAB_MAX_SESSIONS) state.sessions.pop();
|
|
1761
|
+
state.activeId = session.id;
|
|
1762
|
+
state.turn = null;
|
|
1763
|
+
state.streaming = null;
|
|
1764
|
+
session.context = assembleContext(session, '', {}).context;
|
|
1765
|
+
emit('state');
|
|
1766
|
+
emit('context');
|
|
1767
|
+
persist();
|
|
1768
|
+
return true;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function switchSession(id) {
|
|
1772
|
+
if (state.disposed || busy()) return false;
|
|
1773
|
+
if (typeof id !== 'string') return false;
|
|
1774
|
+
let target = null;
|
|
1775
|
+
for (let i = 0; i < state.sessions.length; i += 1) {
|
|
1776
|
+
if (state.sessions[i].id === id) {
|
|
1777
|
+
target = state.sessions[i];
|
|
1778
|
+
break;
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
if (!target) return false;
|
|
1782
|
+
if (target.id === state.activeId) return true;
|
|
1783
|
+
state.activeId = target.id;
|
|
1784
|
+
state.turn = target.lastTurn || null;
|
|
1785
|
+
state.streaming = null;
|
|
1786
|
+
if (!target.context) target.context = assembleContext(target, '', {}).context;
|
|
1787
|
+
emit('state');
|
|
1788
|
+
emit('context');
|
|
1789
|
+
persist();
|
|
1790
|
+
return true;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
function deleteSession(id) {
|
|
1794
|
+
if (state.disposed || busy()) return false;
|
|
1795
|
+
if (typeof id !== 'string') return false;
|
|
1796
|
+
let index = -1;
|
|
1797
|
+
for (let i = 0; i < state.sessions.length; i += 1) {
|
|
1798
|
+
if (state.sessions[i].id === id) {
|
|
1799
|
+
index = i;
|
|
1800
|
+
break;
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
if (index < 0) return false;
|
|
1804
|
+
const wasActive = state.sessions[index].id === state.activeId;
|
|
1805
|
+
state.sessions.splice(index, 1);
|
|
1806
|
+
state.streaming = null;
|
|
1807
|
+
if (!state.sessions.length) {
|
|
1808
|
+
const session = makeSession();
|
|
1809
|
+
state.sessions.push(session);
|
|
1810
|
+
state.activeId = session.id;
|
|
1811
|
+
state.turn = null;
|
|
1812
|
+
session.context = assembleContext(session, '', {}).context;
|
|
1813
|
+
} else if (wasActive) {
|
|
1814
|
+
const next = state.sessions[Math.min(index, state.sessions.length - 1)];
|
|
1815
|
+
state.activeId = next.id;
|
|
1816
|
+
state.turn = next.lastTurn || null;
|
|
1817
|
+
if (!next.context) next.context = assembleContext(next, '', {}).context;
|
|
1818
|
+
}
|
|
1819
|
+
emit('state');
|
|
1820
|
+
emit('context');
|
|
1821
|
+
persist();
|
|
1822
|
+
return true;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
function setPace(pace) {
|
|
1826
|
+
const next = labNormalizePace(pace);
|
|
1827
|
+
if (state.pace === next) return true;
|
|
1828
|
+
state.pace = next;
|
|
1829
|
+
emit('state');
|
|
1830
|
+
persist();
|
|
1831
|
+
return true;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function setBudget(value) {
|
|
1835
|
+
if (state.disposed) return false;
|
|
1836
|
+
const next = Math.floor(Number(value));
|
|
1837
|
+
if (!isFinite(next) || next < 800 || next > 1048576) return false;
|
|
1838
|
+
if (next === budget) return true;
|
|
1839
|
+
if (busy()) return false;
|
|
1840
|
+
budget = next;
|
|
1841
|
+
budgetCustom = true;
|
|
1842
|
+
const session = activeSession();
|
|
1843
|
+
if (session) {
|
|
1844
|
+
session.context = assembleContext(session, '', {}).context;
|
|
1845
|
+
appendInfoLine(session, '教学上下文预算已调整为 ' + budget + ' tok');
|
|
1846
|
+
}
|
|
1847
|
+
emit('context');
|
|
1848
|
+
emit('state');
|
|
1849
|
+
persist();
|
|
1850
|
+
return true;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
function setModelMode(mode) {
|
|
1854
|
+
if (state.disposed) return false;
|
|
1855
|
+
const next = mode === 'real' ? 'real' : 'sim';
|
|
1856
|
+
if (next === state.modelMode) return true;
|
|
1857
|
+
if (busy()) return false;
|
|
1858
|
+
state.modelMode = next;
|
|
1859
|
+
if (next === 'sim') modelInfo.lastError = null;
|
|
1860
|
+
const session = activeSession();
|
|
1861
|
+
if (session) {
|
|
1862
|
+
const label =
|
|
1863
|
+
next === 'real'
|
|
1864
|
+
? '模型模式:真实 DeepSeek(' + (modelInfo.modelName || modelInfo.model || '跟随 DSH 当前模型') + ')'
|
|
1865
|
+
: '模型模式:本地规则模拟(离线、可复现)';
|
|
1866
|
+
pushTranscript(session, makeLine('info', label));
|
|
1867
|
+
emit('line');
|
|
1868
|
+
}
|
|
1869
|
+
emit('state');
|
|
1870
|
+
persist();
|
|
1871
|
+
return true;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
function setModelAdapter(adapter) {
|
|
1875
|
+
realAdapter = typeof adapter === 'function' ? adapter : null;
|
|
1876
|
+
emit('state');
|
|
1877
|
+
return true;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
function setModelInfo(info) {
|
|
1881
|
+
if (!info || typeof info !== 'object') return false;
|
|
1882
|
+
const changed =
|
|
1883
|
+
(info.provider !== undefined && String(info.provider) !== modelInfo.provider) ||
|
|
1884
|
+
(info.model !== undefined && String(info.model) !== modelInfo.model) ||
|
|
1885
|
+
(info.modelName !== undefined && String(info.modelName) !== modelInfo.modelName) ||
|
|
1886
|
+
(info.signedIn !== undefined && info.signedIn !== modelInfo.signedIn) ||
|
|
1887
|
+
(info.available !== undefined && info.available !== modelInfo.available) ||
|
|
1888
|
+
(info.contextWindow !== undefined && info.contextWindow !== modelInfo.contextWindow) ||
|
|
1889
|
+
(info.maxOutputTokens !== undefined && info.maxOutputTokens !== modelInfo.maxOutputTokens);
|
|
1890
|
+
if (info.provider !== undefined) modelInfo.provider = String(info.provider || '');
|
|
1891
|
+
if (info.model !== undefined) modelInfo.model = String(info.model || '');
|
|
1892
|
+
if (info.modelName !== undefined) modelInfo.modelName = String(info.modelName || '');
|
|
1893
|
+
if (info.signedIn !== undefined) modelInfo.signedIn = info.signedIn;
|
|
1894
|
+
if (info.available !== undefined) modelInfo.available = info.available;
|
|
1895
|
+
if (info.contextWindow !== undefined) modelInfo.contextWindow = Number.isFinite(info.contextWindow) ? info.contextWindow : null;
|
|
1896
|
+
if (info.maxOutputTokens !== undefined) modelInfo.maxOutputTokens = Number.isFinite(info.maxOutputTokens) ? info.maxOutputTokens : null;
|
|
1897
|
+
if (changed) emit('state');
|
|
1898
|
+
return changed;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
function resetAll() {
|
|
1902
|
+
if (state.turn && state.turn.status === 'running') state.turn.cancelled = true;
|
|
1903
|
+
state.turn = null;
|
|
1904
|
+
state.streaming = null;
|
|
1905
|
+
try {
|
|
1906
|
+
if (typeof storage.removeItem === 'function') storage.removeItem(LAB_STORAGE_KEY);
|
|
1907
|
+
else storage.setItem(LAB_STORAGE_KEY, '');
|
|
1908
|
+
} catch (err) {
|
|
1909
|
+
/* 忽略存储清理失败 */
|
|
1910
|
+
}
|
|
1911
|
+
const session = makeSession();
|
|
1912
|
+
state.sessions = [session];
|
|
1913
|
+
state.activeId = session.id;
|
|
1914
|
+
state.memory = [];
|
|
1915
|
+
state.stats = createStats();
|
|
1916
|
+
state.lastInput = '';
|
|
1917
|
+
state.lastCompression = null;
|
|
1918
|
+
state.modelMode = 'sim';
|
|
1919
|
+
budget = LAB_BUDGET;
|
|
1920
|
+
budgetCustom = false;
|
|
1921
|
+
modelInfo.lastUsage = null;
|
|
1922
|
+
modelInfo.lastLatencyMs = null;
|
|
1923
|
+
modelInfo.lastError = null;
|
|
1924
|
+
session.context = assembleContext(session, '', {}).context;
|
|
1925
|
+
state.disposed = false;
|
|
1926
|
+
emit('state');
|
|
1927
|
+
emit('memory');
|
|
1928
|
+
emit('context');
|
|
1929
|
+
persist();
|
|
1930
|
+
return true;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
function dispose() {
|
|
1934
|
+
if (state.disposed) return;
|
|
1935
|
+
if (state.turn && state.turn.status === 'running') state.turn.cancelled = true;
|
|
1936
|
+
persist();
|
|
1937
|
+
state.streaming = null;
|
|
1938
|
+
state.disposed = true;
|
|
1939
|
+
listeners.length = 0;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
return {
|
|
1943
|
+
snapshot: snapshot,
|
|
1944
|
+
onEvent: onEvent,
|
|
1945
|
+
submit: submit,
|
|
1946
|
+
compact: compact,
|
|
1947
|
+
newSession: newSession,
|
|
1948
|
+
switchSession: switchSession,
|
|
1949
|
+
deleteSession: deleteSession,
|
|
1950
|
+
clearMemory: clearMemory,
|
|
1951
|
+
deleteMemory: deleteMemory,
|
|
1952
|
+
resetAll: resetAll,
|
|
1953
|
+
setPace: setPace,
|
|
1954
|
+
setBudget: setBudget,
|
|
1955
|
+
setModelMode: setModelMode,
|
|
1956
|
+
setModelAdapter: setModelAdapter,
|
|
1957
|
+
setModelInfo: setModelInfo,
|
|
1958
|
+
dispose: dispose,
|
|
1959
|
+
};
|
|
1960
|
+
}
|