@huyooo/ai-chat-core 0.2.40 → 0.2.42
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/events-7V2drqe4.d.ts +1117 -0
- package/dist/events.d.ts +1 -477
- package/dist/events.js +1 -1
- package/dist/index.d.ts +18 -644
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/agent.ts +15 -11
- package/src/events.ts +98 -66
- package/src/index.ts +14 -5
- package/src/internal/web-search.ts +4 -5
- package/src/mcp/client-manager.ts +2 -1
- package/src/providers/context-compressor.ts +178 -110
- package/src/providers/model-registry.ts +158 -14
- package/src/providers/orchestrator.ts +166 -31
- package/src/providers/protocols/ark.ts +1 -1
- package/src/providers/types.ts +3 -13
- package/src/types.ts +101 -69
package/src/events.ts
CHANGED
|
@@ -62,46 +62,13 @@ export type ErrorCategory =
|
|
|
62
62
|
| 'parse' // 解析错误(JSON、响应格式)
|
|
63
63
|
| 'unknown'; // 未知错误
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
export interface ToolErrorShape {
|
|
70
|
-
message: string;
|
|
71
|
-
code?: string;
|
|
72
|
-
retryable?: boolean;
|
|
73
|
-
suggestion?: string;
|
|
74
|
-
}
|
|
65
|
+
// 工具错误类型从 types 统一定义,此处重导出
|
|
66
|
+
import type { ToolError } from './types';
|
|
67
|
+
export type { ToolError as ToolErrorShape };
|
|
68
|
+
export { isToolError as isThrowableToolError } from './types';
|
|
75
69
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
return (
|
|
79
|
-
e instanceof Error &&
|
|
80
|
-
e !== null &&
|
|
81
|
-
typeof (e as Error & { toolError?: unknown }).toolError === 'object' &&
|
|
82
|
-
(e as Error & { toolError: Record<string, unknown> }).toolError !== null &&
|
|
83
|
-
typeof ((e as Error & { toolError: Record<string, unknown> }).toolError as Record<string, unknown>).message === 'string'
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* 创建可抛出的结构化工具错误
|
|
89
|
-
* 工具 throw 后 bridge 检测 toolError 并透传给前端
|
|
90
|
-
*/
|
|
91
|
-
export function createThrowableToolError(
|
|
92
|
-
message: string,
|
|
93
|
-
opts?: { code?: string; retryable?: boolean; suggestion?: string }
|
|
94
|
-
): Error & { toolError: ToolErrorShape } {
|
|
95
|
-
const toolError: ToolErrorShape = {
|
|
96
|
-
message,
|
|
97
|
-
code: opts?.code,
|
|
98
|
-
retryable: opts?.retryable,
|
|
99
|
-
suggestion: opts?.suggestion,
|
|
100
|
-
};
|
|
101
|
-
const err = new Error(message) as Error & { toolError: ToolErrorShape };
|
|
102
|
-
Object.defineProperty(err, 'toolError', { value: toolError, enumerable: true });
|
|
103
|
-
return err;
|
|
104
|
-
}
|
|
70
|
+
/** 内部使用的 ToolErrorShape 别名 */
|
|
71
|
+
type ToolErrorShape = ToolError;
|
|
105
72
|
|
|
106
73
|
/** 错误详情 - 结构化错误信息 */
|
|
107
74
|
export interface ErrorDetails {
|
|
@@ -220,14 +187,6 @@ export interface ToolCallStartEvent {
|
|
|
220
187
|
};
|
|
221
188
|
}
|
|
222
189
|
|
|
223
|
-
/** 副作用定义(从 types.ts 复制,避免循环依赖) */
|
|
224
|
-
export interface SideEffect {
|
|
225
|
-
type: string;
|
|
226
|
-
success: boolean;
|
|
227
|
-
data?: unknown;
|
|
228
|
-
message?: string;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
190
|
/** 工具调用结果事件 */
|
|
232
191
|
export interface ToolCallResultEvent {
|
|
233
192
|
type: 'tool_call_result';
|
|
@@ -236,31 +195,28 @@ export interface ToolCallResultEvent {
|
|
|
236
195
|
id: string;
|
|
237
196
|
/** 工具名称 */
|
|
238
197
|
name: string;
|
|
239
|
-
/**
|
|
198
|
+
/** 执行结果(JSON 字符串) */
|
|
240
199
|
result: string;
|
|
241
200
|
/** 是否成功 */
|
|
242
201
|
success: boolean;
|
|
243
202
|
/** 错误信息(失败时) */
|
|
244
|
-
error?:
|
|
203
|
+
error?: ToolErrorShape;
|
|
245
204
|
/** 结束时间戳(毫秒) */
|
|
246
205
|
endedAt: number;
|
|
247
206
|
/** 执行耗时(毫秒) */
|
|
248
207
|
duration: number;
|
|
249
|
-
/**
|
|
250
|
-
|
|
251
|
-
* 前端可根据此字段处理通知、刷新文件列表等
|
|
252
|
-
*/
|
|
253
|
-
sideEffects?: SideEffect[];
|
|
254
|
-
/**
|
|
255
|
-
* 结果类型(用于前端渲染)
|
|
256
|
-
*
|
|
257
|
-
* 当工具定义了 resultType 且执行成功时,前端会根据此类型生成对应的 ContentPart
|
|
258
|
-
* 例如:resultType: 'weather' 会生成 { type: 'weather', ...result }
|
|
259
|
-
*/
|
|
260
|
-
resultType?: string;
|
|
208
|
+
/** 工具 UI 声明(成功时) */
|
|
209
|
+
ui?: ToolUIShape;
|
|
261
210
|
};
|
|
262
211
|
}
|
|
263
212
|
|
|
213
|
+
/** ToolUI 形状(对应 types.ts 中的 ToolUI,避免循环依赖) */
|
|
214
|
+
export interface ToolUIShape {
|
|
215
|
+
type: 'render' | 'action';
|
|
216
|
+
name: string;
|
|
217
|
+
props?: Record<string, unknown>;
|
|
218
|
+
}
|
|
219
|
+
|
|
264
220
|
/** 工具输出增量事件(用于 stdout/stderr 流式展示) */
|
|
265
221
|
export interface ToolCallOutputEvent {
|
|
266
222
|
type: 'tool_call_output';
|
|
@@ -413,6 +369,43 @@ export interface StepEndEvent {
|
|
|
413
369
|
/** 步骤相关事件联合类型 */
|
|
414
370
|
export type StepEvent = StepStartEvent | StepEndEvent;
|
|
415
371
|
|
|
372
|
+
// ==================== 上下文压缩事件 ====================
|
|
373
|
+
|
|
374
|
+
/** 上下文压缩开始事件 */
|
|
375
|
+
export interface CompactStartEvent {
|
|
376
|
+
type: 'compact_start';
|
|
377
|
+
data: {
|
|
378
|
+
/** 压缩前估算 token 数 */
|
|
379
|
+
estimatedTokens: number;
|
|
380
|
+
/** 可用 prompt token 预算 */
|
|
381
|
+
budget: number;
|
|
382
|
+
/** 开始时间戳 */
|
|
383
|
+
startedAt: number;
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 上下文压缩完成事件 */
|
|
388
|
+
export interface CompactEndEvent {
|
|
389
|
+
type: 'compact_end';
|
|
390
|
+
data: {
|
|
391
|
+
/** 是否成功 */
|
|
392
|
+
success: boolean;
|
|
393
|
+
/** 压缩后估算 token 数 */
|
|
394
|
+
compressedTokens: number;
|
|
395
|
+
/** 压缩前消息数 */
|
|
396
|
+
originalMessageCount: number;
|
|
397
|
+
/** 压缩后消息数 */
|
|
398
|
+
compressedMessageCount: number;
|
|
399
|
+
/** 结束时间戳 */
|
|
400
|
+
endedAt: number;
|
|
401
|
+
/** 耗时(毫秒) */
|
|
402
|
+
duration: number;
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** 上下文压缩相关事件联合类型 */
|
|
407
|
+
export type CompactEvent = CompactStartEvent | CompactEndEvent;
|
|
408
|
+
|
|
416
409
|
// ==================== Agent 状态事件 ====================
|
|
417
410
|
|
|
418
411
|
/**
|
|
@@ -447,6 +440,7 @@ export type ChatEvent =
|
|
|
447
440
|
| TextEvent
|
|
448
441
|
| StatusEvent
|
|
449
442
|
| StepEvent
|
|
443
|
+
| CompactEvent
|
|
450
444
|
| AgentStatusEvent;
|
|
451
445
|
|
|
452
446
|
/** 事件类型字符串 */
|
|
@@ -460,6 +454,7 @@ export const CHAT_EVENT_TYPES: readonly ChatEventType[] = [
|
|
|
460
454
|
'text_delta',
|
|
461
455
|
'done', 'error', 'abort',
|
|
462
456
|
'step_start', 'step_end',
|
|
457
|
+
'compact_start', 'compact_end',
|
|
463
458
|
'agent_status',
|
|
464
459
|
] as const;
|
|
465
460
|
|
|
@@ -567,9 +562,8 @@ export function createToolCallResult(
|
|
|
567
562
|
result: string,
|
|
568
563
|
success: boolean,
|
|
569
564
|
startedAt: number,
|
|
570
|
-
error?:
|
|
571
|
-
|
|
572
|
-
resultType?: string
|
|
565
|
+
error?: ToolErrorShape,
|
|
566
|
+
ui?: ToolUIShape
|
|
573
567
|
): ToolCallResultEvent {
|
|
574
568
|
const endedAt = Date.now();
|
|
575
569
|
return {
|
|
@@ -582,8 +576,7 @@ export function createToolCallResult(
|
|
|
582
576
|
error,
|
|
583
577
|
endedAt,
|
|
584
578
|
duration: endedAt - startedAt,
|
|
585
|
-
|
|
586
|
-
resultType
|
|
579
|
+
ui
|
|
587
580
|
}
|
|
588
581
|
};
|
|
589
582
|
}
|
|
@@ -789,6 +782,40 @@ export function createStepEnd(stepNumber: number, startedAt: number): StepEndEve
|
|
|
789
782
|
};
|
|
790
783
|
}
|
|
791
784
|
|
|
785
|
+
/**
|
|
786
|
+
* 创建上下文压缩开始事件
|
|
787
|
+
*/
|
|
788
|
+
export function createCompactStart(estimatedTokens: number, budget: number): CompactStartEvent {
|
|
789
|
+
return {
|
|
790
|
+
type: 'compact_start',
|
|
791
|
+
data: { estimatedTokens, budget, startedAt: Date.now() },
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* 创建上下文压缩完成事件
|
|
797
|
+
*/
|
|
798
|
+
export function createCompactEnd(
|
|
799
|
+
success: boolean,
|
|
800
|
+
compressedTokens: number,
|
|
801
|
+
originalMessageCount: number,
|
|
802
|
+
compressedMessageCount: number,
|
|
803
|
+
startedAt: number,
|
|
804
|
+
): CompactEndEvent {
|
|
805
|
+
const endedAt = Date.now();
|
|
806
|
+
return {
|
|
807
|
+
type: 'compact_end',
|
|
808
|
+
data: {
|
|
809
|
+
success,
|
|
810
|
+
compressedTokens,
|
|
811
|
+
originalMessageCount,
|
|
812
|
+
compressedMessageCount,
|
|
813
|
+
endedAt,
|
|
814
|
+
duration: endedAt - startedAt,
|
|
815
|
+
},
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
792
819
|
// ==================== 类型守卫 ====================
|
|
793
820
|
|
|
794
821
|
/** 检查是否为思考事件 */
|
|
@@ -831,6 +858,11 @@ export function isStepEvent(event: ChatEvent): event is StepEvent {
|
|
|
831
858
|
return event.type.startsWith('step_');
|
|
832
859
|
}
|
|
833
860
|
|
|
861
|
+
/** 检查是否为上下文压缩事件 */
|
|
862
|
+
export function isCompactEvent(event: ChatEvent): event is CompactEvent {
|
|
863
|
+
return event.type.startsWith('compact_');
|
|
864
|
+
}
|
|
865
|
+
|
|
834
866
|
/** 检查错误是否可重试 */
|
|
835
867
|
export function isRetryableError(event: ChatEvent): boolean {
|
|
836
868
|
if (event.type !== 'error') return false;
|
package/src/index.ts
CHANGED
|
@@ -138,9 +138,12 @@ export type {
|
|
|
138
138
|
// 工具接口
|
|
139
139
|
Tool,
|
|
140
140
|
ToolContext,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
ToolUI,
|
|
142
|
+
RenderType,
|
|
143
|
+
ActionType,
|
|
144
|
+
ExecResult,
|
|
145
|
+
ToolError,
|
|
146
|
+
ToolErrorCode,
|
|
144
147
|
// 工具插件(Vite 风格)
|
|
145
148
|
ToolPlugin,
|
|
146
149
|
ToolConfigItem,
|
|
@@ -156,6 +159,11 @@ export {
|
|
|
156
159
|
resolveTools,
|
|
157
160
|
tool,
|
|
158
161
|
tools,
|
|
162
|
+
// 工具错误
|
|
163
|
+
throwToolError,
|
|
164
|
+
rethrowToolError,
|
|
165
|
+
isToolError,
|
|
166
|
+
getArg,
|
|
159
167
|
} from './types';
|
|
160
168
|
|
|
161
169
|
// 常量
|
|
@@ -172,9 +180,10 @@ export type {
|
|
|
172
180
|
ErrorCategory,
|
|
173
181
|
ErrorDetails,
|
|
174
182
|
ToolErrorShape,
|
|
183
|
+
ToolUIShape,
|
|
175
184
|
} from './events';
|
|
176
185
|
|
|
177
|
-
export {
|
|
186
|
+
export { isThrowableToolError } from './events';
|
|
178
187
|
|
|
179
188
|
// 思考事件
|
|
180
189
|
export type {
|
|
@@ -208,7 +217,7 @@ export type {
|
|
|
208
217
|
TextEvent
|
|
209
218
|
} from './events';
|
|
210
219
|
|
|
211
|
-
// 计划类型(update_plan 工具通过
|
|
220
|
+
// 计划类型(update_plan 工具通过 ui: { type: 'render', name: 'plan' } 渲染,无专用事件)
|
|
212
221
|
export type {
|
|
213
222
|
PlanStep,
|
|
214
223
|
PlanStepStatus,
|
|
@@ -22,7 +22,6 @@ export function createWebSearchTool(tavilyApiKey: string): Tool {
|
|
|
22
22
|
},
|
|
23
23
|
required: ['query'],
|
|
24
24
|
},
|
|
25
|
-
resultType: 'search_results',
|
|
26
25
|
execute: async (args, ctx) => {
|
|
27
26
|
const query = typeof args.query === 'string' ? args.query : '';
|
|
28
27
|
const maxResults = typeof args.max_results === 'number' && Number.isFinite(args.max_results)
|
|
@@ -30,10 +29,10 @@ export function createWebSearchTool(tavilyApiKey: string): Tool {
|
|
|
30
29
|
: 5;
|
|
31
30
|
|
|
32
31
|
if (!query.trim()) {
|
|
33
|
-
return
|
|
32
|
+
return { query: '', results: [], error: '缺少 query' };
|
|
34
33
|
}
|
|
35
34
|
if (!tavilyApiKey) {
|
|
36
|
-
return
|
|
35
|
+
return { query, results: [], error: '缺少 Tavily API Key' };
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
const resp = await fetch('https://api.tavily.com/search', {
|
|
@@ -54,7 +53,7 @@ export function createWebSearchTool(tavilyApiKey: string): Tool {
|
|
|
54
53
|
|
|
55
54
|
if (!resp.ok) {
|
|
56
55
|
const t = await resp.text().catch(() => '');
|
|
57
|
-
return
|
|
56
|
+
return { query, results: [], error: `Tavily /search 错误: ${resp.status} ${t}`.trim() };
|
|
58
57
|
}
|
|
59
58
|
|
|
60
59
|
const data: unknown = await resp.json().catch(() => null);
|
|
@@ -71,7 +70,7 @@ export function createWebSearchTool(tavilyApiKey: string): Tool {
|
|
|
71
70
|
results.push({ title, url, snippet });
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
return
|
|
73
|
+
return { query, results };
|
|
75
74
|
},
|
|
76
75
|
};
|
|
77
76
|
}
|
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Context 压缩模块
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 压缩算法:
|
|
10
|
-
* 1. 保留 system prompt
|
|
11
|
-
* 2. 保留第一条 user 消息(任务描述)
|
|
12
|
-
* 3. 将中间的 assistant/tool 交互压缩为一条摘要
|
|
13
|
-
* 4. 保留最近 N 条消息(工作上下文)
|
|
3
|
+
*
|
|
4
|
+
* 当 prompt token 估算接近模型 context window 时,让当前模型自己总结对话历史,
|
|
5
|
+
* 然后用 summary + 最近几条消息继续对话。
|
|
6
|
+
*
|
|
7
|
+
* 参考 Claude Code / Cursor 的做法:
|
|
8
|
+
* 不机械截断,而是让 AI 生成高质量摘要,保留关键决策和上下文。
|
|
14
9
|
*/
|
|
15
10
|
|
|
16
11
|
import type { StandardMessage } from './types';
|
|
@@ -18,69 +13,86 @@ import { DebugLogger } from '../utils';
|
|
|
18
13
|
|
|
19
14
|
const logger = DebugLogger.module('ContextCompressor');
|
|
20
15
|
|
|
16
|
+
// ==================== Token 估算 ====================
|
|
17
|
+
|
|
18
|
+
const CHARS_PER_TOKEN = 3.2;
|
|
19
|
+
const MESSAGE_OVERHEAD_TOKENS = 4;
|
|
20
|
+
|
|
21
|
+
function estimateStringTokens(s: string): number {
|
|
22
|
+
if (!s) return 0;
|
|
23
|
+
return Math.ceil(s.length / CHARS_PER_TOKEN);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function estimateMessageTokens(msg: StandardMessage): number {
|
|
27
|
+
let tokens = MESSAGE_OVERHEAD_TOKENS;
|
|
28
|
+
tokens += estimateStringTokens(msg.content);
|
|
29
|
+
|
|
30
|
+
if (msg.toolCalls) {
|
|
31
|
+
for (const tc of msg.toolCalls) {
|
|
32
|
+
tokens += estimateStringTokens(tc.name);
|
|
33
|
+
tokens += estimateStringTokens(tc.arguments);
|
|
34
|
+
tokens += 10;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (msg.images) {
|
|
39
|
+
tokens += msg.images.length * 85;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return tokens;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function estimateTotalTokens(messages: StandardMessage[]): number {
|
|
46
|
+
let total = 3;
|
|
47
|
+
for (const msg of messages) {
|
|
48
|
+
total += estimateMessageTokens(msg);
|
|
49
|
+
}
|
|
50
|
+
return total;
|
|
51
|
+
}
|
|
52
|
+
|
|
21
53
|
// ==================== 配置 ====================
|
|
22
54
|
|
|
23
|
-
/** 压缩配置 */
|
|
24
55
|
export interface CompactConfig {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
*
|
|
31
|
-
* @default 80_000
|
|
32
|
-
*/
|
|
33
|
-
charThreshold?: number;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* 压缩后保留的最近消息数
|
|
37
|
-
*
|
|
38
|
-
* 保留最近的消息对(assistant + tool),确保模型有足够上下文继续工作。
|
|
39
|
-
*
|
|
40
|
-
* @default 10
|
|
41
|
-
*/
|
|
56
|
+
contextWindowTokens: number;
|
|
57
|
+
maxOutputTokens: number;
|
|
58
|
+
/** 触发压缩的使用率,默认 0.80 */
|
|
59
|
+
compactThresholdRatio?: number;
|
|
60
|
+
/** 压缩后保留的最近消息数,默认 6 */
|
|
42
61
|
keepRecentMessages?: number;
|
|
43
62
|
}
|
|
44
63
|
|
|
45
|
-
const
|
|
46
|
-
const DEFAULT_KEEP_RECENT =
|
|
64
|
+
const DEFAULT_THRESHOLD_RATIO = 0.80;
|
|
65
|
+
const DEFAULT_KEEP_RECENT = 6;
|
|
47
66
|
|
|
48
67
|
// ==================== 核心函数 ====================
|
|
49
68
|
|
|
69
|
+
/** 计算可用 prompt token 预算 */
|
|
70
|
+
export function getPromptBudget(config: CompactConfig): number {
|
|
71
|
+
const ratio = config.compactThresholdRatio ?? DEFAULT_THRESHOLD_RATIO;
|
|
72
|
+
return Math.floor(config.contextWindowTokens * ratio) - config.maxOutputTokens;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 检测是否需要压缩 */
|
|
76
|
+
export function needsCompaction(messages: StandardMessage[], config: CompactConfig): boolean {
|
|
77
|
+
return estimateTotalTokens(messages) > getPromptBudget(config);
|
|
78
|
+
}
|
|
79
|
+
|
|
50
80
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* @param messages - 消息数组(会被原地修改)
|
|
56
|
-
* @param config - 压缩配置(可选)
|
|
57
|
-
* @returns 是否执行了压缩
|
|
81
|
+
* 构建发给 AI 的总结请求
|
|
82
|
+
*
|
|
83
|
+
* 返回一组消息,发给当前模型让它总结对话历史。
|
|
84
|
+
* 总结完成后调用 applySummary 组装新的消息列表。
|
|
58
85
|
*/
|
|
59
|
-
export function
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (totalChars < charThreshold) {
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
logger.info(`Context 压缩触发: ${totalChars} 字符, ${messages.length} 条消息`);
|
|
71
|
-
|
|
72
|
-
// ---- 找到各段边界 ----
|
|
73
|
-
// [0..systemEnd): system prompt(可能没有)
|
|
74
|
-
// [systemEnd..firstUserEnd): 第一条 user 消息
|
|
75
|
-
// [firstUserEnd..recentStart): 中间的 assistant/tool 交互(压缩目标)
|
|
76
|
-
// [recentStart..end): 最近 N 条消息(保留)
|
|
77
|
-
|
|
86
|
+
export function buildSummarizeRequest(
|
|
87
|
+
messages: StandardMessage[],
|
|
88
|
+
config: CompactConfig,
|
|
89
|
+
): { summarizeMessages: StandardMessage[]; keepMessages: StandardMessage[] } {
|
|
90
|
+
const keepRecent = config.keepRecentMessages ?? DEFAULT_KEEP_RECENT;
|
|
91
|
+
|
|
92
|
+
// 找边界
|
|
78
93
|
let systemEnd = 0;
|
|
79
|
-
if (messages[0]?.role === 'system')
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// 找到第一条 user 消息
|
|
94
|
+
if (messages[0]?.role === 'system') systemEnd = 1;
|
|
95
|
+
|
|
84
96
|
let firstUserEnd = systemEnd;
|
|
85
97
|
for (let i = systemEnd; i < messages.length; i++) {
|
|
86
98
|
if (messages[i].role === 'user') {
|
|
@@ -88,62 +100,118 @@ export function compactMessages(messages: StandardMessage[], config?: CompactCon
|
|
|
88
100
|
break;
|
|
89
101
|
}
|
|
90
102
|
}
|
|
91
|
-
|
|
103
|
+
|
|
104
|
+
// 要保留的最近消息
|
|
92
105
|
const recentStart = Math.max(firstUserEnd, messages.length - keepRecent);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return false;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// ---- 生成摘要 ----
|
|
106
|
+
const keepMessages = messages.slice(recentStart);
|
|
107
|
+
|
|
108
|
+
// 要被总结的中间历史
|
|
100
109
|
const middleMessages = messages.slice(firstUserEnd, recentStart);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
110
|
+
|
|
111
|
+
if (middleMessages.length < 2) {
|
|
112
|
+
// 中间太短,没什么可总结的
|
|
113
|
+
return { summarizeMessages: [], keepMessages: messages.slice(systemEnd) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const estimatedTokens = estimateTotalTokens(messages);
|
|
117
|
+
const budget = getPromptBudget(config);
|
|
118
|
+
logger.info(`准备 AI 总结: ~${estimatedTokens} tokens > budget ${budget}, 总结 ${middleMessages.length} 条中间消息, 保留最近 ${keepMessages.length} 条`);
|
|
119
|
+
|
|
120
|
+
// 构建总结请求:把中间历史交给模型
|
|
121
|
+
const summarizeMessages: StandardMessage[] = [
|
|
122
|
+
{
|
|
123
|
+
role: 'system',
|
|
124
|
+
content: SUMMARIZE_SYSTEM_PROMPT,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
role: 'user',
|
|
128
|
+
content: formatMessagesForSummary(middleMessages),
|
|
129
|
+
},
|
|
108
130
|
];
|
|
109
|
-
|
|
110
|
-
const compressedChars = compressed.reduce((s, m) => s + (m.content?.length ?? 0), 0);
|
|
111
|
-
logger.info(`Context 压缩完成: ${messages.length} → ${compressed.length} 条消息, ${totalChars} → ${compressedChars} 字符`);
|
|
112
|
-
|
|
113
|
-
// 原地替换
|
|
114
|
-
messages.length = 0;
|
|
115
|
-
messages.push(...compressed);
|
|
116
|
-
|
|
117
|
-
return true;
|
|
118
|
-
}
|
|
119
131
|
|
|
120
|
-
|
|
132
|
+
return { summarizeMessages, keepMessages };
|
|
133
|
+
}
|
|
121
134
|
|
|
122
135
|
/**
|
|
123
|
-
*
|
|
136
|
+
* 用 AI 返回的摘要组装新的消息列表
|
|
124
137
|
*/
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
138
|
+
export function applySummary(
|
|
139
|
+
originalMessages: StandardMessage[],
|
|
140
|
+
summary: string,
|
|
141
|
+
keepMessages: StandardMessage[],
|
|
142
|
+
): StandardMessage[] {
|
|
143
|
+
// 取原始的 system prompt
|
|
144
|
+
const systemPrompt = originalMessages[0]?.role === 'system' ? originalMessages[0] : null;
|
|
145
|
+
|
|
146
|
+
// 取第一条 user 消息
|
|
147
|
+
const startIdx = systemPrompt ? 1 : 0;
|
|
148
|
+
let firstUser: StandardMessage | null = null;
|
|
149
|
+
for (let i = startIdx; i < originalMessages.length; i++) {
|
|
150
|
+
if (originalMessages[i].role === 'user') {
|
|
151
|
+
firstUser = originalMessages[i];
|
|
152
|
+
break;
|
|
134
153
|
}
|
|
135
|
-
|
|
136
|
-
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const result: StandardMessage[] = [];
|
|
157
|
+
if (systemPrompt) result.push(systemPrompt);
|
|
158
|
+
if (firstUser) result.push(firstUser);
|
|
159
|
+
|
|
160
|
+
// 插入 AI 生成的摘要
|
|
161
|
+
result.push({
|
|
162
|
+
role: 'system',
|
|
163
|
+
content: `[对话历史摘要]\n${summary}`,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// 拼上最近保留的消息
|
|
167
|
+
result.push(...keepMessages);
|
|
168
|
+
|
|
169
|
+
const tokens = estimateTotalTokens(result);
|
|
170
|
+
logger.info(`AI 总结应用完成: ${originalMessages.length} → ${result.length} 条消息, ~${tokens} tokens`);
|
|
171
|
+
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ==================== 内部 ====================
|
|
176
|
+
|
|
177
|
+
const SUMMARIZE_SYSTEM_PROMPT = `你是一个对话历史压缩助手。请总结以下对话历史,保留所有关键信息:
|
|
178
|
+
|
|
179
|
+
要求:
|
|
180
|
+
1. 保留所有文件修改记录(哪些文件被创建/修改/删除了,具体改了什么)
|
|
181
|
+
2. 保留所有关键决策和结论
|
|
182
|
+
3. 保留错误信息和解决方案
|
|
183
|
+
4. 保留用户的明确要求和偏好
|
|
184
|
+
5. 用简洁的条目列表格式输出
|
|
185
|
+
6. 不要遗漏任何可能影响后续工作的信息
|
|
186
|
+
|
|
187
|
+
直接输出摘要,不要开头说"以下是摘要"之类的话。`;
|
|
188
|
+
|
|
189
|
+
/** 把消息列表格式化为可读文本,供总结用 */
|
|
190
|
+
function formatMessagesForSummary(messages: StandardMessage[]): string {
|
|
191
|
+
const parts: string[] = [];
|
|
192
|
+
|
|
193
|
+
for (const msg of messages) {
|
|
194
|
+
const role = msg.role === 'assistant' ? 'AI' : msg.role === 'user' ? '用户' : '工具';
|
|
195
|
+
|
|
196
|
+
if (msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0) {
|
|
197
|
+
const calls = msg.toolCalls.map(tc => {
|
|
198
|
+
const argsPreview = tc.arguments.length > 200
|
|
199
|
+
? tc.arguments.slice(0, 200) + '...'
|
|
200
|
+
: tc.arguments;
|
|
201
|
+
return ` 调用 ${tc.name}(${argsPreview})`;
|
|
202
|
+
}).join('\n');
|
|
203
|
+
const text = msg.content ? `${msg.content}\n${calls}` : calls;
|
|
204
|
+
parts.push(`[${role}]\n${text}`);
|
|
205
|
+
} else if (msg.role === 'tool') {
|
|
206
|
+
// tool result 保留前 1000 字符(总结模型能看到足够信息)
|
|
207
|
+
const content = msg.content.length > 1000
|
|
208
|
+
? msg.content.slice(0, 1000) + `... (共 ${msg.content.length} 字符)`
|
|
209
|
+
: msg.content;
|
|
210
|
+
parts.push(`[${role}: ${msg.toolName ?? 'unknown'}]\n${content}`);
|
|
211
|
+
} else if (msg.content) {
|
|
212
|
+
parts.push(`[${role}]\n${msg.content}`);
|
|
137
213
|
}
|
|
138
214
|
}
|
|
139
|
-
|
|
140
|
-
return
|
|
141
|
-
`[上下文压缩] 以下是之前 ${middleMessages.length} 条消息的摘要:`,
|
|
142
|
-
toolCallNames.length > 0
|
|
143
|
-
? `- 执行了 ${toolCallNames.length} 次工具调用: ${[...new Set(toolCallNames)].join(', ')}`
|
|
144
|
-
: '',
|
|
145
|
-
textPreview
|
|
146
|
-
? `- AI 回复摘要: ${textPreview.slice(0, 500)}`
|
|
147
|
-
: '',
|
|
148
|
-
].filter(Boolean).join('\n');
|
|
215
|
+
|
|
216
|
+
return parts.join('\n\n---\n\n');
|
|
149
217
|
}
|