@myassis/gateway 1.0.78 → 1.0.82
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/dist/config/index.js +5 -1
- package/dist/services/agent/AgentManager.js +2 -1
- package/dist/services/memory/ContextBuilder.js +177 -32
- package/dist/services/memory/MemoryManager.js +193 -39
- package/dist/services/memory/ToolLedger.js +181 -0
- package/dist/services/session/Session.js +123 -0
- package/dist/services/session/SessionManager.js +2 -0
- package/dist/services/systemPrompt.js +62 -53
- package/dist/services/tools/index.js +2 -1
- package/dist/services/tools/plan.js +111 -0
- package/package.json +1 -1
package/dist/config/index.js
CHANGED
|
@@ -90,7 +90,11 @@ exports.appConfig = {
|
|
|
90
90
|
/** 超出 toolPairKeep 的旧工具调用内容截断长度 */
|
|
91
91
|
oldToolContentLimit: parseInt(process.env.OLD_TOOL_CONTENT_LIMIT || '500', 10),
|
|
92
92
|
/** 单次请求内最大工具调用轮数 */
|
|
93
|
-
maxToolRounds: parseInt(process.env.MAX_TOOL_ROUNDS || '
|
|
93
|
+
maxToolRounds: parseInt(process.env.MAX_TOOL_ROUNDS || '200', 10),
|
|
94
|
+
/** 是否启用后台预压缩:回复结束后预先生成摘要,降低下一轮首字延迟 */
|
|
95
|
+
enablePrecompression: process.env.ENABLE_PRECOMPRESSION !== 'false',
|
|
96
|
+
/** 回复结束后延迟多久启动预压缩(毫秒),留出时间给用户连续追问 */
|
|
97
|
+
precompressionDelay: parseInt(process.env.PRECOMPRESSION_DELAY || '3000', 10),
|
|
94
98
|
/** 触发上下文压缩的字符阈值 */
|
|
95
99
|
summaryTriggerChars: parseInt(process.env.SUMMARY_TRIGGER_CHARS || '60000', 10),
|
|
96
100
|
appName: '我的助手'
|
|
@@ -254,7 +254,8 @@ class AgentManager {
|
|
|
254
254
|
createdAt: s.createdAt,
|
|
255
255
|
updatedAt: s.updatedAt,
|
|
256
256
|
messageQueue: s.messageQueue,
|
|
257
|
-
messageQueueAutoExecute: s.messageQueueAutoExecute
|
|
257
|
+
messageQueueAutoExecute: s.messageQueueAutoExecute,
|
|
258
|
+
plan: s.plan
|
|
258
259
|
}));
|
|
259
260
|
}
|
|
260
261
|
/**
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.nextTrimLevel = exports.buildContext = exports.estimateTokens = exports.estimateChars = exports.TrimLevel = void 0;
|
|
4
4
|
const shared_1 = require("@myassis/shared");
|
|
5
5
|
const index_js_1 = require("../../config/index.js");
|
|
6
|
+
const ToolLedger_js_1 = require("./ToolLedger.js");
|
|
6
7
|
const logger = (0, shared_1.getLogger)('ContextBuilder');
|
|
7
8
|
/**
|
|
8
9
|
* 上下文构造与分级裁剪
|
|
@@ -14,17 +15,35 @@ const logger = (0, shared_1.getLogger)('ContextBuilder');
|
|
|
14
15
|
*/
|
|
15
16
|
/** 中英混合场景下的粗略 token 估算系数 */
|
|
16
17
|
const CHARS_PER_TOKEN = 1.6;
|
|
17
|
-
/**
|
|
18
|
+
/** 骨架化后单条工具结果保留的长度 */
|
|
19
|
+
const SKELETON_OUTPUT_LIMIT = 120;
|
|
20
|
+
/**
|
|
21
|
+
* 裁剪级别,逐级加重。
|
|
22
|
+
*
|
|
23
|
+
* 设计原则:**任何级别都不会删除工具调用记录**。
|
|
24
|
+
* 一旦删除,模型就看不到自己已经做过什么,会重复调用同一个工具
|
|
25
|
+
* (重复读文件、重复写入、重复执行命令),比截断内容的代价大得多。
|
|
26
|
+
* 因此加重裁剪只压缩「负载」(输出正文、参数值、附件),
|
|
27
|
+
* 始终保留「调用事实」(工具名 + 关键参数 + 成功/失败)。
|
|
28
|
+
*/
|
|
18
29
|
var TrimLevel;
|
|
19
30
|
(function (TrimLevel) {
|
|
20
31
|
/** 不裁剪 */
|
|
21
32
|
TrimLevel[TrimLevel["None"] = 0] = "None";
|
|
22
|
-
/** 截断超出 toolPairKeep
|
|
33
|
+
/** 截断超出 toolPairKeep 轮的旧工具输出 */
|
|
23
34
|
TrimLevel[TrimLevel["OldToolPayload"] = 1] = "OldToolPayload";
|
|
24
|
-
/**
|
|
35
|
+
/** 收紧保留轮数、截断参数值、降级旧附件为占位符 */
|
|
25
36
|
TrimLevel[TrimLevel["Aggressive"] = 2] = "Aggressive";
|
|
26
|
-
/**
|
|
27
|
-
TrimLevel[TrimLevel["
|
|
37
|
+
/** 旧工具调用退化为骨架:仅保留工具名、关键参数与结果摘要 */
|
|
38
|
+
TrimLevel[TrimLevel["SkeletonOldCalls"] = 3] = "SkeletonOldCalls";
|
|
39
|
+
/**
|
|
40
|
+
* 最后手段:把旧工具调用折叠进一份「已完成操作」账本。
|
|
41
|
+
*
|
|
42
|
+
* 消息条目本身被移除,但调用事实以清单形式并入首条 system 消息,
|
|
43
|
+
* 模型依然知道哪些操作已经做过 —— 这是「不丢事实」与「控制体积」的折中,
|
|
44
|
+
* 仅在骨架化后仍超预算(数百轮调用)时启用。
|
|
45
|
+
*/
|
|
46
|
+
TrimLevel[TrimLevel["FoldToLedger"] = 4] = "FoldToLedger";
|
|
28
47
|
})(TrimLevel || (exports.TrimLevel = TrimLevel = {}));
|
|
29
48
|
/** 估算单条消息的字符数(含附件与工具参数) */
|
|
30
49
|
function messageChars(message) {
|
|
@@ -164,6 +183,138 @@ function clipPair(messages, pair, limit) {
|
|
|
164
183
|
messages[i].content = clip(messages[i].content, limit);
|
|
165
184
|
}
|
|
166
185
|
}
|
|
186
|
+
/** 从工具参数中提取用于标识「这次调用做了什么」的关键字段 */
|
|
187
|
+
const IDENTITY_KEYS = [
|
|
188
|
+
'path', 'file', 'filePath', 'filename', 'dir', 'directory',
|
|
189
|
+
'command', 'cmd', 'query', 'pattern', 'url', 'name', 'key', 'id',
|
|
190
|
+
'action', 'method', 'sessionId', 'target',
|
|
191
|
+
];
|
|
192
|
+
/** 单个关键参数值的最大长度 */
|
|
193
|
+
const IDENTITY_VALUE_LIMIT = 200;
|
|
194
|
+
/**
|
|
195
|
+
* 将工具参数压成「身份摘要」:只留能标识调用对象的关键字段。
|
|
196
|
+
*
|
|
197
|
+
* 例如 write_file 的 4000 字 content 被丢掉,但 path 保留,
|
|
198
|
+
* 模型据此知道「src/a.ts 已经写过了」,不会重复写入。
|
|
199
|
+
*/
|
|
200
|
+
function skeletonArguments(args) {
|
|
201
|
+
let parsed;
|
|
202
|
+
if (typeof args === 'string') {
|
|
203
|
+
try {
|
|
204
|
+
parsed = JSON.parse(args);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return JSON.stringify({ _omitted: true });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else if (args && typeof args === 'object') {
|
|
211
|
+
parsed = args;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
return '{}';
|
|
215
|
+
}
|
|
216
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
217
|
+
return JSON.stringify({ _omitted: true });
|
|
218
|
+
}
|
|
219
|
+
const out = {};
|
|
220
|
+
for (const key of Object.keys(parsed)) {
|
|
221
|
+
if (!IDENTITY_KEYS.includes(key))
|
|
222
|
+
continue;
|
|
223
|
+
const value = parsed[key];
|
|
224
|
+
if (value == null)
|
|
225
|
+
continue;
|
|
226
|
+
if (typeof value === 'object')
|
|
227
|
+
continue;
|
|
228
|
+
const text = String(value);
|
|
229
|
+
out[key] = text.length > IDENTITY_VALUE_LIMIT
|
|
230
|
+
? text.slice(0, IDENTITY_VALUE_LIMIT) + '...'
|
|
231
|
+
: text;
|
|
232
|
+
}
|
|
233
|
+
// 标记参数已省略,避免模型误以为这是完整参数而照抄
|
|
234
|
+
out._omitted = true;
|
|
235
|
+
return JSON.stringify(out);
|
|
236
|
+
}
|
|
237
|
+
/** 从工具结果中提炼一行摘要,保留成功/失败信号 */
|
|
238
|
+
function skeletonOutput(content) {
|
|
239
|
+
const text = typeof content === 'string' ? content : JSON.stringify(content ?? '');
|
|
240
|
+
if (!text)
|
|
241
|
+
return '(已执行,结果略)';
|
|
242
|
+
let success = null;
|
|
243
|
+
let body = text;
|
|
244
|
+
try {
|
|
245
|
+
const parsed = JSON.parse(text);
|
|
246
|
+
if (parsed && typeof parsed === 'object') {
|
|
247
|
+
if (typeof parsed.success === 'boolean')
|
|
248
|
+
success = parsed.success;
|
|
249
|
+
if (parsed.output != null) {
|
|
250
|
+
body = typeof parsed.output === 'string' ? parsed.output : JSON.stringify(parsed.output);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// 非 JSON 结果,直接用原文
|
|
256
|
+
}
|
|
257
|
+
const head = body.replace(/\s+/g, ' ').slice(0, SKELETON_OUTPUT_LIMIT);
|
|
258
|
+
const flag = success === null ? '' : success ? '[成功] ' : '[失败] ';
|
|
259
|
+
return `${flag}${head}${body.length > SKELETON_OUTPUT_LIMIT ? '…' : ''}(完整结果已省略,勿重复调用)`;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* 将一个工具调用对退化为骨架。
|
|
263
|
+
*
|
|
264
|
+
* 保留 assistant.tool_calls 与配对的 tool 消息(协议要求且模型需要看到调用事实),
|
|
265
|
+
* 只把参数压成身份摘要、把输出压成一行结论。
|
|
266
|
+
*/
|
|
267
|
+
function skeletonizePair(messages, pair) {
|
|
268
|
+
const head = messages[pair.start];
|
|
269
|
+
head.content = clip(head.content, SKELETON_OUTPUT_LIMIT);
|
|
270
|
+
// 旧轮次的思维链对当前决策价值最低,直接丢弃
|
|
271
|
+
delete head.reasoning_content;
|
|
272
|
+
if (Array.isArray(head.tool_calls)) {
|
|
273
|
+
for (const tc of head.tool_calls) {
|
|
274
|
+
tc.input = skeletonArguments(tc.input);
|
|
275
|
+
if (tc.function) {
|
|
276
|
+
tc.function.arguments = skeletonArguments(tc.function.arguments);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
for (let i = pair.start + 1; i < pair.end; i++) {
|
|
281
|
+
messages[i].content = skeletonOutput(messages[i].content);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* 把最早的工具调用对折叠进账本。
|
|
286
|
+
*
|
|
287
|
+
* 与已废弃的「直接丢弃」不同:调用事实不会消失,而是转成
|
|
288
|
+
* 首条 system 消息中的「已完成操作」清单。
|
|
289
|
+
*/
|
|
290
|
+
function foldPairsToLedger(messages, pairs, foldCount, protectFrom) {
|
|
291
|
+
if (foldCount <= 0)
|
|
292
|
+
return messages;
|
|
293
|
+
const foldIndexes = new Set();
|
|
294
|
+
const folded = [];
|
|
295
|
+
for (let i = 0; i < foldCount; i++) {
|
|
296
|
+
for (let j = pairs[i].start; j < pairs[i].end; j++) {
|
|
297
|
+
foldIndexes.add(j);
|
|
298
|
+
folded.push(messages[j]);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const ledger = (0, ToolLedger_js_1.renderRequestLedger)(folded);
|
|
302
|
+
const kept = messages.filter((_, index) => !foldIndexes.has(index));
|
|
303
|
+
if (!ledger)
|
|
304
|
+
return kept;
|
|
305
|
+
// 并入首条 system 消息:部分厂商只允许 system 位于首位
|
|
306
|
+
if (kept[0]?.role === 'system' && typeof kept[0].content === 'string') {
|
|
307
|
+
kept[0] = { ...kept[0], content: `${kept[0].content}\n\n${ledger}` };
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
kept.splice(Math.min(protectFrom, kept.length), 0, {
|
|
311
|
+
role: 'assistant',
|
|
312
|
+
content: ledger,
|
|
313
|
+
attachments: [],
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return kept;
|
|
317
|
+
}
|
|
167
318
|
/** 将较早的图片 / 音频附件降级为文本占位符,避免 base64 永久占用上下文 */
|
|
168
319
|
function degradeAttachments(messages, keepLastN) {
|
|
169
320
|
const withAttachments = [];
|
|
@@ -202,39 +353,31 @@ function buildContext(messages, protectFrom, maxChars = index_js_1.appConfig.con
|
|
|
202
353
|
const keep = target >= TrimLevel.Aggressive
|
|
203
354
|
? index_js_1.appConfig.minToolPairKeep
|
|
204
355
|
: index_js_1.appConfig.toolPairKeep;
|
|
205
|
-
// 1)
|
|
356
|
+
// 1) 最重级别:把最早的工具调用退化为骨架(保留调用事实,丢弃负载)
|
|
357
|
+
if (target >= TrimLevel.SkeletonOldCalls) {
|
|
358
|
+
const skeletonUntil = Math.max(0, pairs.length - index_js_1.appConfig.minToolPairKeep);
|
|
359
|
+
for (let i = 0; i < skeletonUntil; i++) {
|
|
360
|
+
skeletonizePair(working, pairs[i]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// 2) 截断超出 keep 轮的旧工具负载(骨架化过的 pair 已经很小,重复处理无害)
|
|
206
364
|
const clipUntil = Math.max(0, pairs.length - keep);
|
|
365
|
+
const argLimit = target >= TrimLevel.Aggressive
|
|
366
|
+
? Math.max(200, Math.floor(index_js_1.appConfig.oldToolContentLimit / 2))
|
|
367
|
+
: index_js_1.appConfig.oldToolContentLimit;
|
|
207
368
|
for (let i = 0; i < clipUntil; i++) {
|
|
208
|
-
clipPair(working, pairs[i],
|
|
369
|
+
clipPair(working, pairs[i], argLimit);
|
|
209
370
|
}
|
|
210
|
-
//
|
|
371
|
+
// 3) 降级旧附件
|
|
211
372
|
if (target >= TrimLevel.Aggressive) {
|
|
212
373
|
degradeAttachments(working, 1);
|
|
213
374
|
}
|
|
214
|
-
//
|
|
215
|
-
if (target >= TrimLevel.
|
|
216
|
-
|
|
217
|
-
const dropIndexes = new Set();
|
|
218
|
-
for (let i = 0; i < dropCount; i++) {
|
|
219
|
-
for (let j = pairs[i].start; j < pairs[i].end; j++) {
|
|
220
|
-
dropIndexes.add(j);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
const dropped = working.filter((_, index) => !dropIndexes.has(index));
|
|
224
|
-
// 用提示说明被丢弃的轮次,避免模型认为自己没做过这些事。
|
|
225
|
-
// 注意:不能在对话中间插入 system 消息,部分厂商只允许 system 位于首位,
|
|
226
|
-
// 因此把提示并入首条 system 消息(没有则退化为 assistant 提示)。
|
|
227
|
-
const notice = `(已省略 ${dropCount} 轮较早的工具调用记录以控制上下文长度)`;
|
|
228
|
-
if (dropped[0]?.role === 'system' && typeof dropped[0].content === 'string') {
|
|
229
|
-
dropped[0] = { ...dropped[0], content: `${dropped[0].content}\n${notice}` };
|
|
230
|
-
}
|
|
231
|
-
else {
|
|
232
|
-
dropped.splice(protectFrom, 0, { role: 'assistant', content: notice, attachments: [] });
|
|
233
|
-
}
|
|
234
|
-
working = dropped;
|
|
375
|
+
// 4) 最后手段:折叠为账本(调用事实保留在 system 清单中)
|
|
376
|
+
if (target >= TrimLevel.FoldToLedger && pairs.length > index_js_1.appConfig.minToolPairKeep) {
|
|
377
|
+
working = foldPairsToLedger(working, pairs, pairs.length - index_js_1.appConfig.minToolPairKeep, protectFrom);
|
|
235
378
|
}
|
|
236
379
|
};
|
|
237
|
-
for (const target of [minLevel, TrimLevel.Aggressive, TrimLevel.
|
|
380
|
+
for (const target of [minLevel, TrimLevel.Aggressive, TrimLevel.SkeletonOldCalls, TrimLevel.FoldToLedger]) {
|
|
238
381
|
if (target < level)
|
|
239
382
|
continue;
|
|
240
383
|
applyLevel(target);
|
|
@@ -252,8 +395,10 @@ exports.buildContext = buildContext;
|
|
|
252
395
|
function nextTrimLevel(level) {
|
|
253
396
|
if (level < TrimLevel.Aggressive)
|
|
254
397
|
return TrimLevel.Aggressive;
|
|
255
|
-
if (level < TrimLevel.
|
|
256
|
-
return TrimLevel.
|
|
398
|
+
if (level < TrimLevel.SkeletonOldCalls)
|
|
399
|
+
return TrimLevel.SkeletonOldCalls;
|
|
400
|
+
if (level < TrimLevel.FoldToLedger)
|
|
401
|
+
return TrimLevel.FoldToLedger;
|
|
257
402
|
return null;
|
|
258
403
|
}
|
|
259
404
|
exports.nextTrimLevel = nextTrimLevel;
|
|
@@ -32,11 +32,21 @@ const index_js_1 = require("../../stores/index.js");
|
|
|
32
32
|
const LLMClient_js_1 = require("../llm/LLMClient.js");
|
|
33
33
|
const index_js_2 = require("../../config/index.js");
|
|
34
34
|
const ContextBuilder_js_1 = require("./ContextBuilder.js");
|
|
35
|
+
const ToolLedger_js_1 = require("./ToolLedger.js");
|
|
35
36
|
const DEFAULT_CONFIG = {
|
|
36
37
|
summaryThreshold: 10,
|
|
37
38
|
summaryTriggerChars: index_js_2.appConfig.summaryTriggerChars,
|
|
38
39
|
enabled: true,
|
|
39
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* 会话级压缩任务表:sessionId -> 进行中的压缩任务。
|
|
43
|
+
*
|
|
44
|
+
* 前台请求与后台预压缩共用这张表,确保同一会话同一时刻只有一次摘要生成:
|
|
45
|
+
* 否则两边会各自调用一次摘要模型(浪费配额),并竞争写入 lastMessageSummary。
|
|
46
|
+
*/
|
|
47
|
+
const compressionTasks = new Map();
|
|
48
|
+
/** 后台预压缩的中断控制器,用于会话删除时取消 */
|
|
49
|
+
const backgroundAborts = new Map();
|
|
40
50
|
/** SSE 辅助方法:res 为 null 时跳过写入(本地执行模式) */
|
|
41
51
|
const sendSSE = (res, data) => {
|
|
42
52
|
if (!res)
|
|
@@ -61,10 +71,19 @@ class MemoryManager {
|
|
|
61
71
|
this.childAgent = childAgent;
|
|
62
72
|
this.res = res;
|
|
63
73
|
}
|
|
64
|
-
|
|
74
|
+
/**
|
|
75
|
+
* 构造注入上下文的摘要消息。
|
|
76
|
+
*
|
|
77
|
+
* @param omitted 被摘要(即不再出现在消息数组中)的原始消息。
|
|
78
|
+
* 这些消息的工具调用会被提取为「已完成操作」账本附在摘要之后,
|
|
79
|
+
* 防止模型因为看不到调用记录而重复执行同一个工具。
|
|
80
|
+
*/
|
|
81
|
+
toSummaryMessage(summary, createdAt, modelName, omitted = []) {
|
|
82
|
+
const ledger = (0, ToolLedger_js_1.renderLedger)(omitted);
|
|
83
|
+
const content = `对话内容摘要:\n${summary}` + (ledger ? `\n\n${ledger}` : '');
|
|
65
84
|
return {
|
|
66
85
|
sessionId: this.session.id,
|
|
67
|
-
content
|
|
86
|
+
content,
|
|
68
87
|
role: 'system',
|
|
69
88
|
id: `summary_${createdAt}`,
|
|
70
89
|
createdAt,
|
|
@@ -93,7 +112,10 @@ class MemoryManager {
|
|
|
93
112
|
return fallback;
|
|
94
113
|
return summarized[summarized.length - 1].createdAt;
|
|
95
114
|
}
|
|
96
|
-
|
|
115
|
+
/**
|
|
116
|
+
* 制定压缩计划:只做判断与切分,不触发任何模型调用。
|
|
117
|
+
*/
|
|
118
|
+
plan() {
|
|
97
119
|
const keep = index_js_2.appConfig.historyKeep;
|
|
98
120
|
// 复制数组:getMessages() 返回的是会话内部的活引用,不可原地修改
|
|
99
121
|
let messages = [...this.session.getMessages()];
|
|
@@ -101,57 +123,189 @@ class MemoryManager {
|
|
|
101
123
|
// 子 agent 的最后一条消息由调用方单独拼接,此处排除
|
|
102
124
|
messages = messages.slice(0, -1);
|
|
103
125
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
126
|
+
const empty = {
|
|
127
|
+
action: 'none', toSummarize: [], ledger: [], retained: messages,
|
|
128
|
+
newBoundaryAt: 0, prevBoundaryAt: null, prevLedger: [], prevRetained: messages,
|
|
129
|
+
};
|
|
130
|
+
if (!this.shouldSummarize(messages) || messages.length <= keep) {
|
|
131
|
+
return empty;
|
|
109
132
|
}
|
|
110
|
-
|
|
111
|
-
if (!hasSummary) {
|
|
112
|
-
// 首次摘要生成
|
|
133
|
+
if (!this.session.lastMessageSummary) {
|
|
113
134
|
const toSummarize = messages.slice(0, -keep);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
this.toSummaryMessage(summary, boundary, ''),
|
|
125
|
-
...messages.slice(-keep),
|
|
126
|
-
];
|
|
135
|
+
return {
|
|
136
|
+
action: 'first',
|
|
137
|
+
toSummarize,
|
|
138
|
+
ledger: toSummarize,
|
|
139
|
+
retained: messages.slice(-keep),
|
|
140
|
+
newBoundaryAt: this.boundaryOf(toSummarize, Date.now()),
|
|
141
|
+
prevBoundaryAt: null,
|
|
142
|
+
prevLedger: [],
|
|
143
|
+
prevRetained: messages,
|
|
144
|
+
};
|
|
127
145
|
}
|
|
128
146
|
// 增量摘要:边界之后的消息才是“新消息”
|
|
129
147
|
const boundaryAt = this.session.lastMessageSummaryAt;
|
|
130
148
|
const newMessages = messages.filter((m) => m.createdAt > boundaryAt);
|
|
149
|
+
// 边界之前的消息已被摘要取代,其工具调用需通过账本保留
|
|
150
|
+
const omitted = messages.filter((m) => m.createdAt <= boundaryAt);
|
|
131
151
|
if (!this.shouldSummarize(newMessages) || newMessages.length <= keep) {
|
|
132
|
-
return
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
152
|
+
return {
|
|
153
|
+
action: 'reuse',
|
|
154
|
+
toSummarize: [], ledger: [], retained: [],
|
|
155
|
+
newBoundaryAt: boundaryAt,
|
|
156
|
+
prevBoundaryAt: boundaryAt,
|
|
157
|
+
prevLedger: omitted,
|
|
158
|
+
prevRetained: newMessages,
|
|
159
|
+
};
|
|
136
160
|
}
|
|
137
|
-
// 新消息超过阈值,基于旧摘要增量重算
|
|
138
161
|
const toSummarize = newMessages.slice(0, -keep);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
162
|
+
return {
|
|
163
|
+
action: 'incremental',
|
|
164
|
+
toSummarize,
|
|
165
|
+
ledger: [...omitted, ...toSummarize],
|
|
166
|
+
retained: newMessages.slice(-keep),
|
|
167
|
+
newBoundaryAt: this.boundaryOf(toSummarize, boundaryAt),
|
|
168
|
+
prevBoundaryAt: boundaryAt,
|
|
169
|
+
prevLedger: omitted,
|
|
170
|
+
prevRetained: newMessages,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* 执行压缩计划并写入会话。
|
|
175
|
+
*
|
|
176
|
+
* @returns 摘要文本;null 表示失败/中断(调用方应沿用旧摘要或原始历史)
|
|
177
|
+
*/
|
|
178
|
+
async runPlan(plan) {
|
|
179
|
+
if (plan.action !== 'first' && plan.action !== 'incremental')
|
|
180
|
+
return null;
|
|
181
|
+
const summary = await this.generateSummaryAsync(plan.toSummarize, plan.action === 'incremental' ? this.session.lastMessageSummary : null);
|
|
182
|
+
if (!summary)
|
|
183
|
+
return null;
|
|
184
|
+
// 并发保护:若边界已被其他压缩任务推进,说明有更新的摘要,放弃本次结果
|
|
185
|
+
if (plan.prevBoundaryAt !== null
|
|
186
|
+
&& this.session.lastMessageSummaryAt !== plan.prevBoundaryAt) {
|
|
187
|
+
logger.info('摘要边界已被其他任务推进,丢弃本次压缩结果');
|
|
188
|
+
return null;
|
|
145
189
|
}
|
|
146
|
-
const nextBoundary = this.boundaryOf(toSummarize, boundaryAt);
|
|
147
190
|
this.session.lastMessageSummary = summary;
|
|
148
|
-
this.session.lastMessageSummaryAt =
|
|
191
|
+
this.session.lastMessageSummaryAt = plan.newBoundaryAt;
|
|
149
192
|
this.session.save();
|
|
193
|
+
return summary;
|
|
194
|
+
}
|
|
195
|
+
async getHistoryMessagesAsync() {
|
|
196
|
+
// 若后台预压缩正在进行,等它完成即可直接复用结果,避免重复调用摘要模型
|
|
197
|
+
const pending = compressionTasks.get(this.session.id);
|
|
198
|
+
if (pending) {
|
|
199
|
+
logger.debug('等待进行中的压缩任务完成');
|
|
200
|
+
try {
|
|
201
|
+
await pending;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// 后台任务失败不影响前台,重新按当前状态决策
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const plan = this.plan();
|
|
208
|
+
if (plan.action === 'none') {
|
|
209
|
+
return plan.retained;
|
|
210
|
+
}
|
|
211
|
+
if (plan.action === 'reuse') {
|
|
212
|
+
return [
|
|
213
|
+
this.toSummaryMessage(this.session.lastMessageSummary, plan.prevBoundaryAt, '', plan.prevLedger),
|
|
214
|
+
...plan.prevRetained,
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
// 前台压缩同样登记到任务表,阻止后台预压缩重复执行
|
|
218
|
+
const task = this.runPlan(plan);
|
|
219
|
+
compressionTasks.set(this.session.id, task.then(() => undefined, () => undefined));
|
|
220
|
+
let summary = null;
|
|
221
|
+
try {
|
|
222
|
+
summary = await task;
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
compressionTasks.delete(this.session.id);
|
|
226
|
+
}
|
|
227
|
+
if (!summary) {
|
|
228
|
+
// 摘要失败/被中断:沿用旧摘要或退回原始历史,不写入残缺摘要
|
|
229
|
+
if (plan.action === 'incremental') {
|
|
230
|
+
return [
|
|
231
|
+
this.toSummaryMessage(this.session.lastMessageSummary, plan.prevBoundaryAt, '', plan.prevLedger),
|
|
232
|
+
...plan.prevRetained,
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
return plan.prevRetained;
|
|
236
|
+
}
|
|
150
237
|
return [
|
|
151
|
-
this.toSummaryMessage(summary,
|
|
152
|
-
...
|
|
238
|
+
this.toSummaryMessage(summary, plan.newBoundaryAt, '', plan.ledger),
|
|
239
|
+
...plan.retained,
|
|
153
240
|
];
|
|
154
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* 是否值得后台预压缩:仅当确实需要生成新摘要时才启动。
|
|
244
|
+
*/
|
|
245
|
+
needsPrecompression() {
|
|
246
|
+
const action = this.plan().action;
|
|
247
|
+
return action === 'first' || action === 'incremental';
|
|
248
|
+
}
|
|
249
|
+
// ========== 后台预压缩 ==========
|
|
250
|
+
/**
|
|
251
|
+
* 在上一轮回复结束后于后台预先生成摘要,让下一轮请求直接命中结果。
|
|
252
|
+
*
|
|
253
|
+
* 设计要点:
|
|
254
|
+
* - 与前台共用 compressionTasks 表,同一会话不会重复调用摘要模型;
|
|
255
|
+
* - 失败只记日志,绝不抛出:预压缩是纯优化,失败时前台会自行压缩;
|
|
256
|
+
* - 使用独立 AbortController,不受上一轮请求的 signal 影响
|
|
257
|
+
* (否则请求结束时 signal 被 abort,后台任务会立刻死掉)。
|
|
258
|
+
*/
|
|
259
|
+
static schedulePrecompression(session) {
|
|
260
|
+
if (!index_js_2.appConfig.enablePrecompression)
|
|
261
|
+
return;
|
|
262
|
+
if (compressionTasks.has(session.id))
|
|
263
|
+
return;
|
|
264
|
+
// 正在生成时不预压缩:消息还会继续追加,此刻的摘要边界会立即过期
|
|
265
|
+
if (session.isGenerating)
|
|
266
|
+
return;
|
|
267
|
+
const controller = new AbortController();
|
|
268
|
+
const manager = new MemoryManager(session, controller.signal, true, null);
|
|
269
|
+
if (!manager.needsPrecompression())
|
|
270
|
+
return;
|
|
271
|
+
logger.info(`启动后台预压缩: session=${session.id}`);
|
|
272
|
+
backgroundAborts.set(session.id, controller);
|
|
273
|
+
const task = (async () => {
|
|
274
|
+
const startedAt = Date.now();
|
|
275
|
+
try {
|
|
276
|
+
const plan = manager.plan();
|
|
277
|
+
if (plan.action !== 'first' && plan.action !== 'incremental')
|
|
278
|
+
return;
|
|
279
|
+
const summary = await manager.runPlan(plan);
|
|
280
|
+
if (summary) {
|
|
281
|
+
logger.info(`后台预压缩完成: session=${session.id} 耗时=${Date.now() - startedAt}ms`);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
logger.info(`后台预压缩未产生新摘要: session=${session.id}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
// 预压缩失败不影响任何用户可见行为
|
|
289
|
+
logger.warn(`后台预压缩失败: session=${session.id} ${error?.message}`);
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
compressionTasks.delete(session.id);
|
|
293
|
+
backgroundAborts.delete(session.id);
|
|
294
|
+
}
|
|
295
|
+
})();
|
|
296
|
+
compressionTasks.set(session.id, task);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* 取消会话的后台预压缩(会话删除或用户重新发起请求时调用)
|
|
300
|
+
*/
|
|
301
|
+
static cancelPrecompression(sessionId) {
|
|
302
|
+
const controller = backgroundAborts.get(sessionId);
|
|
303
|
+
if (controller) {
|
|
304
|
+
controller.abort();
|
|
305
|
+
backgroundAborts.delete(sessionId);
|
|
306
|
+
logger.debug(`已取消后台预压缩: session=${sessionId}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
155
309
|
/**
|
|
156
310
|
* 格式化工具调用内容,提取关键信息供摘要模型理解
|
|
157
311
|
*/
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderRequestLedger = exports.renderLedger = exports.renderOperations = exports.extractOperations = exports.extractOperationsFromRequest = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* 工具调用账本
|
|
6
|
+
*
|
|
7
|
+
* 当很早的工具调用连「骨架」都无法保留在消息数组中时(例如数百轮调用),
|
|
8
|
+
* 本模块把这些调用压成一份确定性的「已完成操作清单」,随摘要一起注入 system 消息。
|
|
9
|
+
*
|
|
10
|
+
* 为什么不交给 LLM 生成:
|
|
11
|
+
* 摘要由模型改写,操作清单一旦被模型漏写或改写,模型就会重复调用同一个工具。
|
|
12
|
+
* 因此账本从消息历史中**确定性提取**,每次实时重算,不依赖模型输出、也无需落库。
|
|
13
|
+
*/
|
|
14
|
+
/** 从参数中提取调用目标的关键字段,顺序即优先级 */
|
|
15
|
+
const TARGET_KEYS = [
|
|
16
|
+
'path', 'filePath', 'file', 'filename', 'dir', 'directory',
|
|
17
|
+
'command', 'cmd', 'query', 'pattern', 'url', 'name', 'target', 'key',
|
|
18
|
+
];
|
|
19
|
+
/** 账本最多渲染的条目数,超出只保留计数 */
|
|
20
|
+
const MAX_LEDGER_ENTRIES = 60;
|
|
21
|
+
/** 单个目标字符串的最大长度 */
|
|
22
|
+
const MAX_TARGET_LEN = 120;
|
|
23
|
+
/** 从工具参数中提取一个可读的调用目标 */
|
|
24
|
+
function extractTarget(input) {
|
|
25
|
+
if (input == null)
|
|
26
|
+
return '';
|
|
27
|
+
let parsed = input;
|
|
28
|
+
if (typeof input === 'string') {
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(input);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return input.length > MAX_TARGET_LEN ? input.slice(0, MAX_TARGET_LEN) + '…' : input;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (!parsed || typeof parsed !== 'object')
|
|
37
|
+
return '';
|
|
38
|
+
for (const key of TARGET_KEYS) {
|
|
39
|
+
const value = parsed[key];
|
|
40
|
+
if (value == null || typeof value === 'object')
|
|
41
|
+
continue;
|
|
42
|
+
const text = String(value).trim();
|
|
43
|
+
if (!text)
|
|
44
|
+
continue;
|
|
45
|
+
return text.length > MAX_TARGET_LEN ? text.slice(0, MAX_TARGET_LEN) + '…' : text;
|
|
46
|
+
}
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 从「请求态」消息数组中提取操作清单。
|
|
51
|
+
*
|
|
52
|
+
* 与 extractOperations 的区别在于数据形状:请求态消息用的是
|
|
53
|
+
* OpenAI 协议格式(assistant.tool_calls + 配对的 tool 消息),
|
|
54
|
+
* 而非会话持久化的 toolCalls 嵌套结构。
|
|
55
|
+
*/
|
|
56
|
+
function extractOperationsFromRequest(messages) {
|
|
57
|
+
const map = new Map();
|
|
58
|
+
// 先建立 tool_call_id -> 是否成功 的索引
|
|
59
|
+
const outcome = new Map();
|
|
60
|
+
for (const message of messages || []) {
|
|
61
|
+
if (message?.role !== 'tool' || !message.tool_call_id)
|
|
62
|
+
continue;
|
|
63
|
+
const text = typeof message.content === 'string' ? message.content : '';
|
|
64
|
+
let success = null;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(text);
|
|
67
|
+
if (parsed && typeof parsed.success === 'boolean')
|
|
68
|
+
success = parsed.success;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
if (text.includes('[失败]'))
|
|
72
|
+
success = false;
|
|
73
|
+
else if (text.includes('[成功]'))
|
|
74
|
+
success = true;
|
|
75
|
+
}
|
|
76
|
+
outcome.set(message.tool_call_id, success);
|
|
77
|
+
}
|
|
78
|
+
for (const message of messages || []) {
|
|
79
|
+
if (message?.role !== 'assistant' || !Array.isArray(message.tool_calls))
|
|
80
|
+
continue;
|
|
81
|
+
for (const tc of message.tool_calls) {
|
|
82
|
+
const name = tc?.toolName || tc?.function?.name;
|
|
83
|
+
if (!name)
|
|
84
|
+
continue;
|
|
85
|
+
const target = extractTarget(tc.input ?? tc.function?.arguments);
|
|
86
|
+
const key = `${name}\u0000${target}`;
|
|
87
|
+
const success = tc.id ? (outcome.get(tc.id) ?? null) : null;
|
|
88
|
+
const existing = map.get(key);
|
|
89
|
+
if (existing) {
|
|
90
|
+
existing.count++;
|
|
91
|
+
if (success !== null)
|
|
92
|
+
existing.lastSuccess = success;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
map.set(key, { tool: name, target, count: 1, lastSuccess: success });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return [...map.values()];
|
|
100
|
+
}
|
|
101
|
+
exports.extractOperationsFromRequest = extractOperationsFromRequest;
|
|
102
|
+
/** 判断一次工具调用是否成功 */
|
|
103
|
+
function isSuccess(item) {
|
|
104
|
+
if (item?.status === 'error')
|
|
105
|
+
return false;
|
|
106
|
+
if (item?.status === 'success')
|
|
107
|
+
return true;
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 从消息中提取去重后的操作清单。
|
|
112
|
+
*
|
|
113
|
+
* 同一 工具+目标 的多次调用合并为一条并累加次数,
|
|
114
|
+
* 这样「读了 30 次同一个文件」不会占据 30 行。
|
|
115
|
+
*/
|
|
116
|
+
function extractOperations(messages) {
|
|
117
|
+
const map = new Map();
|
|
118
|
+
for (const message of messages || []) {
|
|
119
|
+
for (const group of (message.toolCalls || [])) {
|
|
120
|
+
for (const item of (group?.toolCalls || [])) {
|
|
121
|
+
if (!item?.toolName)
|
|
122
|
+
continue;
|
|
123
|
+
const tool = item.actionName ? `${item.toolName}.${item.actionName}` : item.toolName;
|
|
124
|
+
const target = extractTarget(item.input);
|
|
125
|
+
const key = `${tool}\u0000${target}`;
|
|
126
|
+
const existing = map.get(key);
|
|
127
|
+
if (existing) {
|
|
128
|
+
existing.count++;
|
|
129
|
+
const success = isSuccess(item);
|
|
130
|
+
if (success !== null)
|
|
131
|
+
existing.lastSuccess = success;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
map.set(key, { tool, target, count: 1, lastSuccess: isSuccess(item) });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return [...map.values()];
|
|
140
|
+
}
|
|
141
|
+
exports.extractOperations = extractOperations;
|
|
142
|
+
/**
|
|
143
|
+
* 将操作清单渲染为文本。
|
|
144
|
+
*
|
|
145
|
+
* 返回空串表示没有需要记录的操作。
|
|
146
|
+
*/
|
|
147
|
+
function renderOperations(operations) {
|
|
148
|
+
if (operations.length === 0)
|
|
149
|
+
return '';
|
|
150
|
+
// 失败的操作最有价值(避免模型以为没做过而重试),优先展示;其次按调用次数
|
|
151
|
+
const sorted = operations.sort((a, b) => {
|
|
152
|
+
const aFailed = a.lastSuccess === false ? 1 : 0;
|
|
153
|
+
const bFailed = b.lastSuccess === false ? 1 : 0;
|
|
154
|
+
if (aFailed !== bFailed)
|
|
155
|
+
return bFailed - aFailed;
|
|
156
|
+
return b.count - a.count;
|
|
157
|
+
});
|
|
158
|
+
const shown = sorted.slice(0, MAX_LEDGER_ENTRIES);
|
|
159
|
+
const lines = shown.map((op) => {
|
|
160
|
+
const times = op.count > 1 ? ` ×${op.count}` : '';
|
|
161
|
+
const flag = op.lastSuccess === false ? ' [失败]' : '';
|
|
162
|
+
const target = op.target ? ` ${op.target}` : '';
|
|
163
|
+
return `- ${op.tool}${target}${times}${flag}`;
|
|
164
|
+
});
|
|
165
|
+
const rest = sorted.length - shown.length;
|
|
166
|
+
if (rest > 0) {
|
|
167
|
+
lines.push(`- …另有 ${rest} 项操作未列出`);
|
|
168
|
+
}
|
|
169
|
+
return `【已完成操作】以下工具调用**已经执行过**,除非需要确认最新状态,不要重复调用:\n${lines.join('\n')}`;
|
|
170
|
+
}
|
|
171
|
+
exports.renderOperations = renderOperations;
|
|
172
|
+
/** 从会话消息渲染账本 */
|
|
173
|
+
function renderLedger(messages) {
|
|
174
|
+
return renderOperations(extractOperations(messages));
|
|
175
|
+
}
|
|
176
|
+
exports.renderLedger = renderLedger;
|
|
177
|
+
/** 从请求态消息渲染账本 */
|
|
178
|
+
function renderRequestLedger(messages) {
|
|
179
|
+
return renderOperations(extractOperationsFromRequest(messages));
|
|
180
|
+
}
|
|
181
|
+
exports.renderRequestLedger = renderRequestLedger;
|
|
@@ -106,11 +106,16 @@ class Session {
|
|
|
106
106
|
lastMessageSummary = null;
|
|
107
107
|
lastMessageSummaryAt = null;
|
|
108
108
|
isCurrent;
|
|
109
|
+
// 计划模式:当前需求拆分出的执行计划
|
|
110
|
+
plan = [];
|
|
111
|
+
planUpdatedAt = null;
|
|
109
112
|
// 用于停止生成
|
|
110
113
|
isGenerating = false;
|
|
111
114
|
unreadCount = 0;
|
|
112
115
|
currentMessageId = null;
|
|
113
116
|
abortController = null;
|
|
117
|
+
/** 后台预压缩的延迟定时器 */
|
|
118
|
+
precompressionTimer = null;
|
|
114
119
|
toInsertMessages;
|
|
115
120
|
constructor(data) {
|
|
116
121
|
this.id = data.id;
|
|
@@ -437,6 +442,59 @@ class Session {
|
|
|
437
442
|
this.unreadCount++;
|
|
438
443
|
this.save();
|
|
439
444
|
}
|
|
445
|
+
// ========== 计划模式 ==========
|
|
446
|
+
/**
|
|
447
|
+
* 更新当前会话的执行计划(全量替换)
|
|
448
|
+
*/
|
|
449
|
+
updatePlan(steps) {
|
|
450
|
+
this.plan = steps.map(item => ({ step: item.step, status: item.status }));
|
|
451
|
+
this.planUpdatedAt = Date.now();
|
|
452
|
+
return this.plan;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* 清除当前会话的执行计划
|
|
456
|
+
*/
|
|
457
|
+
clearPlan() {
|
|
458
|
+
this.plan = [];
|
|
459
|
+
this.planUpdatedAt = null;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* 计划是否已全部完成
|
|
463
|
+
*/
|
|
464
|
+
isPlanCompleted() {
|
|
465
|
+
return this.plan.length > 0 && this.plan.every(item => item.status === 'completed');
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* 构造计划进度提醒。
|
|
469
|
+
*
|
|
470
|
+
* 模型倾向于只在任务开头和结尾调用 updatePlan,导致界面进度长期停在第一项。
|
|
471
|
+
* 因此当会话已有计划、且连续 roundsSincePlanUpdate 轮工具调用都没有同步进度时,
|
|
472
|
+
* 返回一条提醒文案注入上下文;无需提醒时返回 null。
|
|
473
|
+
*/
|
|
474
|
+
buildPlanReminder(roundsSincePlanUpdate) {
|
|
475
|
+
// 没有计划或计划已完成,无需提醒
|
|
476
|
+
if (this.plan.length === 0 || this.isPlanCompleted())
|
|
477
|
+
return null;
|
|
478
|
+
// 允许模型连续做 1 轮工具调用后再要求同步,避免过于频繁地打断
|
|
479
|
+
if (roundsSincePlanUpdate < 2)
|
|
480
|
+
return null;
|
|
481
|
+
const current = this.plan.find(item => item.status === 'in_progress');
|
|
482
|
+
const nextPending = this.plan.find(item => item.status === 'pending');
|
|
483
|
+
const completed = this.plan.filter(item => item.status === 'completed').length;
|
|
484
|
+
const lines = [
|
|
485
|
+
`【计划进度提醒】当前计划共 ${this.plan.length} 步,已完成 ${completed} 步,`
|
|
486
|
+
+ `已连续 ${roundsSincePlanUpdate} 轮工具调用未同步计划状态。`,
|
|
487
|
+
];
|
|
488
|
+
if (current) {
|
|
489
|
+
lines.push(`进行中的步骤是「${current.step}」:如果它已经做完,请立即调用 updatePlan 把它置为 completed`
|
|
490
|
+
+ `${nextPending ? `,并把「${nextPending.step}」置为 in_progress` : ''}。`);
|
|
491
|
+
}
|
|
492
|
+
else if (nextPending) {
|
|
493
|
+
lines.push(`当前没有进行中的步骤,请立即调用 updatePlan 把「${nextPending.step}」置为 in_progress。`);
|
|
494
|
+
}
|
|
495
|
+
lines.push('调用时必须传入完整步骤列表,且同一时刻最多一个步骤为 in_progress。');
|
|
496
|
+
return lines.join('\n');
|
|
497
|
+
}
|
|
440
498
|
// 清除未读消息数并持久化
|
|
441
499
|
clearUnreadCount() {
|
|
442
500
|
this.unreadCount = 0;
|
|
@@ -489,6 +547,12 @@ class Session {
|
|
|
489
547
|
this.stopGenerating();
|
|
490
548
|
}
|
|
491
549
|
}
|
|
550
|
+
// 上一轮计划已全部完成,新一轮需求开始前清空,避免残留在界面上
|
|
551
|
+
if (this.isPlanCompleted()) {
|
|
552
|
+
this.clearPlan();
|
|
553
|
+
}
|
|
554
|
+
// 新请求到来:放弃排队中的预压缩,避免与前台压缩重复调用摘要模型
|
|
555
|
+
this.abortPrecompression();
|
|
492
556
|
// Add user message
|
|
493
557
|
this.getAbortController();
|
|
494
558
|
this.isGenerating = true;
|
|
@@ -579,6 +643,8 @@ class Session {
|
|
|
579
643
|
: (await dataService_js_1.modelsService.list(token)).data.map(x => (0, models_js_1.toModel)(x));
|
|
580
644
|
// 工具调用轮次计数,防止无限循环
|
|
581
645
|
let toolRound = 0;
|
|
646
|
+
// 计划模式:距上次调用 updatePlan 已经过的工具轮次,用于在中间过程强制提醒模型同步进度
|
|
647
|
+
let roundsSincePlanUpdate = 0;
|
|
582
648
|
// 当前裁剪级别,遇到上下文超限时逐级加重
|
|
583
649
|
let trimLevel = ContextBuilder_js_1.TrimLevel.OldToolPayload;
|
|
584
650
|
/** 判断错误是否为上下文超限 */
|
|
@@ -901,6 +967,9 @@ class Session {
|
|
|
901
967
|
};
|
|
902
968
|
await Promise.all(llmResult.toolCalls.map(x => startToolCall(x)));
|
|
903
969
|
toolCalls.push(toolCall);
|
|
970
|
+
// 计划模式:本轮是否调用了 updatePlan
|
|
971
|
+
const calledUpdatePlan = llmResult.toolCalls.some((tc) => tc.toolName === 'updatePlan');
|
|
972
|
+
roundsSincePlanUpdate = calledUpdatePlan ? 0 : roundsSincePlanUpdate + 1;
|
|
904
973
|
// 将工具结果添加到消息历史(格式化为 tool 角色的消息)
|
|
905
974
|
for (const result of toolResults) {
|
|
906
975
|
const toolResultMessage = {
|
|
@@ -915,6 +984,17 @@ class Session {
|
|
|
915
984
|
};
|
|
916
985
|
messages.push(toolResultMessage);
|
|
917
986
|
}
|
|
987
|
+
// 计划模式:模型常常只在开头和结尾调用 updatePlan,中间过程不同步进度。
|
|
988
|
+
// 这里在已有计划且连续多轮未同步时,主动注入一条提醒,迫使模型推进计划状态。
|
|
989
|
+
const reminder = this.buildPlanReminder(roundsSincePlanUpdate);
|
|
990
|
+
if (reminder) {
|
|
991
|
+
messages.push({
|
|
992
|
+
role: 'assistant',
|
|
993
|
+
content: reminder,
|
|
994
|
+
attachments: []
|
|
995
|
+
});
|
|
996
|
+
roundsSincePlanUpdate = 0;
|
|
997
|
+
}
|
|
918
998
|
// 继续下一轮(带上工具结果继续调用模型)
|
|
919
999
|
return await processModelResponse();
|
|
920
1000
|
}
|
|
@@ -982,14 +1062,57 @@ class Session {
|
|
|
982
1062
|
if (this.currentMessageId !== null) {
|
|
983
1063
|
this.saveMessage();
|
|
984
1064
|
}
|
|
1065
|
+
// 后台预压缩:本轮已结束,提前生成摘要让下一轮直接命中,降低首字延迟。
|
|
1066
|
+
// 延迟启动是为了给用户的连续追问让路(追问会取消它)。
|
|
1067
|
+
this.schedulePrecompression();
|
|
985
1068
|
}
|
|
986
1069
|
}
|
|
987
1070
|
}
|
|
1071
|
+
/**
|
|
1072
|
+
* 延迟调度后台预压缩。
|
|
1073
|
+
*
|
|
1074
|
+
* 不 await:预压缩是纯优化,绝不能阻塞本轮请求的收尾。
|
|
1075
|
+
*/
|
|
1076
|
+
schedulePrecompression() {
|
|
1077
|
+
if (!index_js_2.appConfig.enablePrecompression)
|
|
1078
|
+
return;
|
|
1079
|
+
this.cancelPrecompressionTimer();
|
|
1080
|
+
this.precompressionTimer = setTimeout(() => {
|
|
1081
|
+
this.precompressionTimer = null;
|
|
1082
|
+
try {
|
|
1083
|
+
MemoryManager_js_1.MemoryManager.schedulePrecompression(this);
|
|
1084
|
+
}
|
|
1085
|
+
catch (error) {
|
|
1086
|
+
logger.warn('调度后台预压缩失败:', error?.message);
|
|
1087
|
+
}
|
|
1088
|
+
}, index_js_2.appConfig.precompressionDelay);
|
|
1089
|
+
// 不阻止进程退出
|
|
1090
|
+
this.precompressionTimer.unref?.();
|
|
1091
|
+
}
|
|
1092
|
+
/** 取消尚未触发的预压缩定时器 */
|
|
1093
|
+
cancelPrecompressionTimer() {
|
|
1094
|
+
if (this.precompressionTimer) {
|
|
1095
|
+
clearTimeout(this.precompressionTimer);
|
|
1096
|
+
this.precompressionTimer = null;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* 用户发起新请求或会话销毁时,放弃排队中/进行中的预压缩。
|
|
1101
|
+
*
|
|
1102
|
+
* 前台会自行压缩,且新消息会使预压缩的边界立即过期。
|
|
1103
|
+
*/
|
|
1104
|
+
abortPrecompression() {
|
|
1105
|
+
this.cancelPrecompressionTimer();
|
|
1106
|
+
MemoryManager_js_1.MemoryManager.cancelPrecompression(this.id);
|
|
1107
|
+
}
|
|
988
1108
|
/**
|
|
989
1109
|
* 停止当前正在进行的生成
|
|
990
1110
|
*/
|
|
991
1111
|
stopGenerating() {
|
|
992
1112
|
this.isGenerating = false;
|
|
1113
|
+
this.abortPrecompression();
|
|
1114
|
+
// 停止生成后清除计划,避免 Desktop 的进度圈停留在未完成状态
|
|
1115
|
+
this.clearPlan();
|
|
993
1116
|
if (this.abortController) {
|
|
994
1117
|
this.abortController.abort();
|
|
995
1118
|
this.abortController = null;
|
|
@@ -149,6 +149,8 @@ class SessionManager {
|
|
|
149
149
|
const session = this.sessions.get(sessionId);
|
|
150
150
|
if (!session)
|
|
151
151
|
return false;
|
|
152
|
+
// 先取消后台预压缩:否则任务完成后会向已删除的会话写入摘要
|
|
153
|
+
session.abortPrecompression();
|
|
152
154
|
SessionStore_js_1.sessionStore.transaction(() => {
|
|
153
155
|
SessionStore_js_1.sessionStore.deleteMessagesBySessionId(sessionId);
|
|
154
156
|
SessionStore_js_1.sessionStore.deleteSession(sessionId);
|
|
@@ -79,26 +79,26 @@ function getChatStyle(settings) {
|
|
|
79
79
|
function getIndentityInfoAsync(settings, agent) {
|
|
80
80
|
const style = getChatStyle(settings);
|
|
81
81
|
const agentName = agent?.name || settings.agentName || settings.assistantName;
|
|
82
|
-
return `【身份】
|
|
83
|
-
- 你是「${agentName}」,${settings.userName}的智能助手。${style}
|
|
82
|
+
return `【身份】
|
|
83
|
+
- 你是「${agentName}」,${settings.userName}的智能助手。${style}
|
|
84
84
|
- 自称"我",称用户为"您"或「${settings.userName}」`;
|
|
85
85
|
}
|
|
86
86
|
function getInstalledSkillsPrompt(skills) {
|
|
87
87
|
// 替换技能列表(模板中应该有占位符,如 {{skills}})
|
|
88
88
|
const skillsList = skills.map((skill, index) => `skillId: "${skill.id}". **${skill.name}** - ${skill.description || '无描述'}`).join('\n');
|
|
89
89
|
const skillSystemPrompt = `【技能系统】当你出现无法解决用户需求时,需要通过技能系统来帮助完成。 技能有两种,一种是通过skill工具可以获取到的技能,一种是通过clawhub安装的技能。`;
|
|
90
|
-
const skillToolPrompt = `【skill工具技能】
|
|
91
|
-
【已安装skill技能】
|
|
92
|
-
${skillsList}
|
|
93
|
-
【强制调用流程】
|
|
94
|
-
1.必须先调用技能管理工具获取详情
|
|
95
|
-
2.如果技能的scriptsUrl不为空,并且当前目录中的skills目录下不存在对应的技能,需要先下载,然后解压后才可以使用,下载目录统一下载到当前目录的skills目录下的该技能目录。
|
|
90
|
+
const skillToolPrompt = `【skill工具技能】
|
|
91
|
+
【已安装skill技能】
|
|
92
|
+
${skillsList}
|
|
93
|
+
【强制调用流程】
|
|
94
|
+
1.必须先调用技能管理工具获取详情
|
|
95
|
+
2.如果技能的scriptsUrl不为空,并且当前目录中的skills目录下不存在对应的技能,需要先下载,然后解压后才可以使用,下载目录统一下载到当前目录的skills目录下的该技能目录。
|
|
96
96
|
3.下载直接使用命令行工具,不要使用fetch工具。运行完脚本记得把脚本及其解压内容删除。`;
|
|
97
|
-
const clawhubSkillPrompt = `
|
|
98
|
-
【clawhub技能】
|
|
99
|
-
1.通过clawhub安装的技能,首先通过命令行工具查看clawhub命令是否安装
|
|
100
|
-
2.如果没有安装需要先安装clawhub
|
|
101
|
-
3.如果已经安装可以使用clawhub search命令搜索相关工具
|
|
97
|
+
const clawhubSkillPrompt = `
|
|
98
|
+
【clawhub技能】
|
|
99
|
+
1.通过clawhub安装的技能,首先通过命令行工具查看clawhub命令是否安装
|
|
100
|
+
2.如果没有安装需要先安装clawhub
|
|
101
|
+
3.如果已经安装可以使用clawhub search命令搜索相关工具
|
|
102
102
|
4.安装后根据技能中的SKILL.md文档描述进行使用`;
|
|
103
103
|
return `\n${skillSystemPrompt}\n${skillToolPrompt}\n${clawhubSkillPrompt}\n`;
|
|
104
104
|
}
|
|
@@ -116,46 +116,55 @@ async function getSystemPromptAsync(agent, customPrompt) {
|
|
|
116
116
|
const skillPrompts = getInstalledSkillsPrompt(skills);
|
|
117
117
|
const chineseLocalName = exports.LANGUAGES.filter(x => x.code === settings.language)[0]?.chineseName ?? '简体中文';
|
|
118
118
|
const date = new Date();
|
|
119
|
-
let prompt = `${getIndentityInfoAsync(settings, agent)}。
|
|
120
|
-
【当前运行环境】:${osInfo}。
|
|
121
|
-
【当前时间】:${date.toLocaleString(settings.language)},weekday:${date.getDay()}。
|
|
122
|
-
【语言】你的回复语言严格遵循用户设备系统语言:${chineseLocalName},无论用户使用任何语言提问,你都只使用【${chineseLocalName}】进行完整回复,不要混用其他语言。
|
|
123
|
-
【尽力原则】充分分析用户需求,尽力满足,不要偷懒,不要得过且过,要诚实,不能胡编乱造。
|
|
124
|
-
【工具调用】
|
|
125
|
-
1.逐步思考,分步骤调用工具,工具调用前尽量返回content,明确当前工具调用的目的。
|
|
126
|
-
2.工具调用的参数全部采用JSON语法,调用前先验证JSON语法正确后,再回复,切记未验证JSON语法就直接回复。
|
|
127
|
-
【命令执行】执行命令时一定要根据当前系统来执行命令
|
|
128
|
-
【文件操作】
|
|
129
|
-
1.文件编辑后一定要验证编辑是否成功,文件内容与预期是否一致。
|
|
130
|
-
2.文件工具目前未提供批量替换操作,如果需要批量修改,请自行创建脚本来完成。
|
|
131
|
-
3.大文件写入如果无法识别,请先写入主体内容,再使用edit工具进行修改来操作
|
|
132
|
-
【特殊语法】mermaid折线图需要使用xychart-beta,完整示例如下:
|
|
133
|
-
xychart-beta
|
|
134
|
-
title "2025年上半年产品销量对比"
|
|
135
|
-
x-axis ["1月", "2月", "3月", "4月", "5月", "6月"]
|
|
136
|
-
y-axis "销量 (件)" 0 --> 80
|
|
137
|
-
line "智能手机" [45, 52, 38, 65, 58, 72]
|
|
138
|
-
line "笔记本电脑" [30, 28, 35, 42, 38, 45]
|
|
139
|
-
line "平板电脑" [20, 25, 30, 28, 35, 40]
|
|
140
|
-
【解决问题思路】
|
|
141
|
-
1.首先分析用户需求,根据需求罗列出实施计划。如果对需求不够明确,需要把计划罗列完后,让用户确认后再执行。
|
|
142
|
-
2.按照计划分步执行
|
|
143
|
-
3.所有步骤完成后,验证结果,比如编写程序,则需要执行类型检查
|
|
144
|
-
4.验证完全通过,总结完成情况,告诉用户
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
119
|
+
let prompt = `${getIndentityInfoAsync(settings, agent)}。
|
|
120
|
+
【当前运行环境】:${osInfo}。
|
|
121
|
+
【当前时间】:${date.toLocaleString(settings.language)},weekday:${date.getDay()}。
|
|
122
|
+
【语言】你的回复语言严格遵循用户设备系统语言:${chineseLocalName},无论用户使用任何语言提问,你都只使用【${chineseLocalName}】进行完整回复,不要混用其他语言。
|
|
123
|
+
【尽力原则】充分分析用户需求,尽力满足,不要偷懒,不要得过且过,要诚实,不能胡编乱造。
|
|
124
|
+
【工具调用】
|
|
125
|
+
1.逐步思考,分步骤调用工具,工具调用前尽量返回content,明确当前工具调用的目的。
|
|
126
|
+
2.工具调用的参数全部采用JSON语法,调用前先验证JSON语法正确后,再回复,切记未验证JSON语法就直接回复。
|
|
127
|
+
【命令执行】执行命令时一定要根据当前系统来执行命令
|
|
128
|
+
【文件操作】
|
|
129
|
+
1.文件编辑后一定要验证编辑是否成功,文件内容与预期是否一致。
|
|
130
|
+
2.文件工具目前未提供批量替换操作,如果需要批量修改,请自行创建脚本来完成。
|
|
131
|
+
3.大文件写入如果无法识别,请先写入主体内容,再使用edit工具进行修改来操作
|
|
132
|
+
【特殊语法】mermaid折线图需要使用xychart-beta,完整示例如下:
|
|
133
|
+
xychart-beta
|
|
134
|
+
title "2025年上半年产品销量对比"
|
|
135
|
+
x-axis ["1月", "2月", "3月", "4月", "5月", "6月"]
|
|
136
|
+
y-axis "销量 (件)" 0 --> 80
|
|
137
|
+
line "智能手机" [45, 52, 38, 65, 58, 72]
|
|
138
|
+
line "笔记本电脑" [30, 28, 35, 42, 38, 45]
|
|
139
|
+
line "平板电脑" [20, 25, 30, 28, 35, 40]
|
|
140
|
+
【解决问题思路】
|
|
141
|
+
1.首先分析用户需求,根据需求罗列出实施计划。如果对需求不够明确,需要把计划罗列完后,让用户确认后再执行。
|
|
142
|
+
2.按照计划分步执行
|
|
143
|
+
3.所有步骤完成后,验证结果,比如编写程序,则需要执行类型检查
|
|
144
|
+
4.验证完全通过,总结完成情况,告诉用户
|
|
145
|
+
【计划模式】
|
|
146
|
+
1.当用户需求需要拆分成两步以上才能完成时,必须先调用 updatePlan 工具给出完整的计划步骤(每步 status 初始为 pending,第一步直接置为 in_progress)。
|
|
147
|
+
2.**每完成一个步骤就必须立刻调用一次 updatePlan**:把刚完成的步骤置为 completed,把下一个要做的步骤置为 in_progress。禁止只在开头和结尾各调用一次、中间全程不同步——这会让用户界面的进度一直停在第一项。
|
|
148
|
+
3.判断标准:只要你准备开始做「下一个步骤」的事情,就说明上一个步骤已完成,此时必须先调用 updatePlan 再继续。
|
|
149
|
+
4.每次调用 updatePlan 都要传入完整步骤列表,不要只传变化的部分;同一时刻最多一个步骤为 in_progress。
|
|
150
|
+
5.计划步骤描述要简洁(建议不超过 30 字),可执行、可验证,不要写"分析需求""总结"之类的空步骤。
|
|
151
|
+
6.如果收到【计划进度提醒】,说明你已多轮未同步进度,必须立即调用 updatePlan 修正状态。
|
|
152
|
+
7.任务结束前必须把所有步骤都置为 completed,否则用户界面的计划进度圈不会消失。
|
|
153
|
+
8.一句话就能答完的简单需求不要调用 updatePlan。
|
|
154
|
+
【代码规范】
|
|
155
|
+
## 1 面向对象代码设计
|
|
156
|
+
1)根据功能先添加接口
|
|
157
|
+
2)对应接口添加一个基类
|
|
158
|
+
3)实现类默认继承这个基类
|
|
159
|
+
## 2 添加新功能
|
|
160
|
+
1)服务端根据DDD架构,进行分层设计:控制器、服务、领域服务
|
|
161
|
+
2)客户端将一个页面分为3部分,页面组件、页面样式、页面执行的脚本,页面执行的脚本中,数据服务与业务逻辑分开处理,数据服务又分为本地数据和远程数据
|
|
162
|
+
## 3 修改已经存在的代码文件
|
|
163
|
+
1)修改Bug,找到Bug后精确替换,不要调整架构
|
|
164
|
+
2)修改业务功能,删除旧的业务功能,重新添加新的,不要在旧的上面做修改,直接删除旧功能,然后添加新功能
|
|
165
|
+
【代码提交】务必类型检查通过
|
|
166
|
+
【任务完成过程中】不能独白
|
|
167
|
+
【会话标题】任务开始前,先修改会话标题,标题必须简洁明了,能概括任务内容
|
|
159
168
|
${skillPrompts}`;
|
|
160
169
|
// 如果有自定义提示词,追加
|
|
161
170
|
if (customPrompt) {
|
|
@@ -37,10 +37,11 @@ const edit_js_1 = require("./edit.js");
|
|
|
37
37
|
const webFetch_js_1 = require("./webFetch.js");
|
|
38
38
|
const sessionsSpawn_js_1 = require("./sessionsSpawn.js");
|
|
39
39
|
const setSessionTitle_js_1 = require("./setSessionTitle.js");
|
|
40
|
+
const plan_js_1 = require("./plan.js");
|
|
40
41
|
exports.tools = [
|
|
41
42
|
search_js_1.searchTool, calculator_js_1.calculatorTool, screenshot_js_1.screenshotTool, keyboard_js_1.keyboardTool, mouse_js_1.mouseTool,
|
|
42
43
|
skill_js_1.skillTool, exec_js_1.execTool, task_js_1.taskTool, model_js_1.modelTool, fetch_js_1.fetchTool,
|
|
43
|
-
edit_js_1.editTool, webFetch_js_1.webFetchTool, file_js_1.fileTool, sessionsSpawn_js_1.sessionsSpawnTool, setSessionTitle_js_1.setSessionTitleTool
|
|
44
|
+
edit_js_1.editTool, webFetch_js_1.webFetchTool, file_js_1.fileTool, sessionsSpawn_js_1.sessionsSpawnTool, setSessionTitle_js_1.setSessionTitleTool, plan_js_1.updatePlanTool
|
|
44
45
|
];
|
|
45
46
|
function getToolByName(name) {
|
|
46
47
|
return exports.tools.find(tool => tool.name === name);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.updatePlanTool = void 0;
|
|
4
|
+
const SessionManager_js_1 = require("../session/SessionManager.js");
|
|
5
|
+
const WebSocketService_js_1 = require("../WebSocketService.js");
|
|
6
|
+
const shared_1 = require("@myassis/shared");
|
|
7
|
+
const logger = (0, shared_1.getLogger)('PlanTool');
|
|
8
|
+
const VALID_STATUS = ['pending', 'in_progress', 'completed'];
|
|
9
|
+
/**
|
|
10
|
+
* 规范化计划步骤,过滤非法数据
|
|
11
|
+
*/
|
|
12
|
+
function normalizeSteps(steps) {
|
|
13
|
+
if (!Array.isArray(steps) || steps.length === 0)
|
|
14
|
+
return null;
|
|
15
|
+
const normalized = [];
|
|
16
|
+
for (const item of steps) {
|
|
17
|
+
if (!item)
|
|
18
|
+
continue;
|
|
19
|
+
const step = typeof item === 'string' ? item : item.step;
|
|
20
|
+
if (typeof step !== 'string' || step.trim().length === 0)
|
|
21
|
+
continue;
|
|
22
|
+
const status = VALID_STATUS.includes(item?.status) ? item.status : 'pending';
|
|
23
|
+
normalized.push({ step: step.trim(), status });
|
|
24
|
+
}
|
|
25
|
+
return normalized.length > 0 ? normalized : null;
|
|
26
|
+
}
|
|
27
|
+
exports.updatePlanTool = {
|
|
28
|
+
name: 'updatePlan',
|
|
29
|
+
description: `维护当前需求的多步执行计划(计划模式)。
|
|
30
|
+
当用户需求需要拆分成两步以上才能完成时,必须先调用本工具给出完整的计划步骤。
|
|
31
|
+
调用后 Desktop 会话底部会显示计划进度圈,鼠标悬浮可查看每个步骤的状态。
|
|
32
|
+
规则:
|
|
33
|
+
1. 每完成一个步骤都必须立刻再调用一次本工具:把已完成步骤置为 completed,把下一个步骤置为 in_progress。
|
|
34
|
+
禁止只在任务开头和结尾各调用一次,中间不同步会导致用户看到的进度一直停在第一项。
|
|
35
|
+
2. 每次调用都要传入完整的步骤列表(含已完成步骤),不要只传增量。
|
|
36
|
+
3. 同一时刻最多只有一个步骤处于 in_progress。
|
|
37
|
+
4. 全部步骤都变为 completed 后,进度圈才会消失,所以任务结束时务必把最后一个步骤标记为 completed。`,
|
|
38
|
+
parameters: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
steps: {
|
|
42
|
+
type: 'array',
|
|
43
|
+
description: '完整的计划步骤列表,按执行顺序排列',
|
|
44
|
+
items: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
description: '单个计划步骤',
|
|
47
|
+
properties: {
|
|
48
|
+
step: { type: 'string', description: '步骤描述,简洁明确,建议不超过 30 字' },
|
|
49
|
+
status: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: '步骤状态:pending 待完成、in_progress 进行中、completed 已完成',
|
|
52
|
+
enum: ['pending', 'in_progress', 'completed'],
|
|
53
|
+
default: 'pending',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
required: ['step', 'status'],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
explanation: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description: '可选,本次计划变更的简要说明',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
required: ['steps'],
|
|
65
|
+
},
|
|
66
|
+
handler: async (args, sessionId, _messageId, userId) => {
|
|
67
|
+
try {
|
|
68
|
+
const steps = normalizeSteps(args?.steps);
|
|
69
|
+
if (!steps) {
|
|
70
|
+
return { success: false, errorMessage: '计划步骤不能为空,steps 需要是 [{ step, status }] 数组' };
|
|
71
|
+
}
|
|
72
|
+
if (steps.filter(s => s.status === 'in_progress').length > 1) {
|
|
73
|
+
return { success: false, errorMessage: '同一时刻最多只能有一个步骤处于 in_progress' };
|
|
74
|
+
}
|
|
75
|
+
if (!sessionId) {
|
|
76
|
+
return { success: false, errorMessage: '无当前会话,无法更新计划' };
|
|
77
|
+
}
|
|
78
|
+
if (!userId) {
|
|
79
|
+
return { success: false, errorMessage: '未登录' };
|
|
80
|
+
}
|
|
81
|
+
const session = (0, SessionManager_js_1.getSessionManager)(String(userId)).getSession(sessionId);
|
|
82
|
+
if (!session) {
|
|
83
|
+
return { success: false, errorMessage: '会话不存在' };
|
|
84
|
+
}
|
|
85
|
+
session.updatePlan(steps);
|
|
86
|
+
// 推送计划进度到 Desktop,驱动底部进度圈刷新
|
|
87
|
+
WebSocketService_js_1.webSocketService.sendToUser(String(userId), {
|
|
88
|
+
type: 'plan_updated',
|
|
89
|
+
payload: {
|
|
90
|
+
sessionId,
|
|
91
|
+
agentId: session.agentId,
|
|
92
|
+
steps: session.plan,
|
|
93
|
+
updatedAt: session.planUpdatedAt,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const completed = steps.filter(s => s.status === 'completed').length;
|
|
97
|
+
return {
|
|
98
|
+
success: true,
|
|
99
|
+
output: JSON.stringify({
|
|
100
|
+
total: steps.length,
|
|
101
|
+
completed,
|
|
102
|
+
steps,
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
logger.error('更新计划失败:', error);
|
|
108
|
+
return { success: false, errorMessage: `更新计划失败: ${error?.message}` };
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|