@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/src/events.ts CHANGED
@@ -62,46 +62,13 @@ export type ErrorCategory =
62
62
  | 'parse' // 解析错误(JSON、响应格式)
63
63
  | 'unknown'; // 未知错误
64
64
 
65
- /**
66
- * 工具执行错误结构化(可抛出的 Error 携带)
67
- * 用于工具 throw 后由 bridge 透传,前端展示 suggestion、重试按钮等
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
- /** 判断错误是否携带 toolError 结构 */
77
- export function isThrowableToolError(e: unknown): e is Error & { toolError: ToolErrorShape } {
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?: string;
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?: string,
571
- sideEffects?: SideEffect[],
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
- sideEffects,
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
- ToolResult,
142
- // 副作用类型
143
- SideEffect,
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 { createThrowableToolError, isThrowableToolError } from './events';
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 工具通过 resultType:'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 JSON.stringify({ query: '', results: [], error: '缺少 query' });
32
+ return { query: '', results: [], error: '缺少 query' };
34
33
  }
35
34
  if (!tavilyApiKey) {
36
- return JSON.stringify({ query, results: [], error: '缺少 Tavily API Key' });
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 JSON.stringify({ query, results: [], error: `Tavily /search 错误: ${resp.status} ${t}`.trim() });
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 JSON.stringify({ query, results });
73
+ return { query, results };
75
74
  },
76
75
  };
77
76
  }
@@ -181,7 +181,8 @@ export class McpClientManager {
181
181
  }
182
182
  }
183
183
 
184
- return texts.join('\n') || JSON.stringify(result.content);
184
+ const text = texts.join('\n') || JSON.stringify(result.content);
185
+ return { text };
185
186
  },
186
187
  };
187
188
  }
@@ -1,16 +1,11 @@
1
1
  /**
2
2
  * Context 压缩模块
3
- *
4
- * 当消息历史过长时自动压缩,避免超出模型 context window
5
- *
6
- * 策略参考 Claude Code(92% context window 触发摘要)和
7
- * OpenAI Codex(auto_compact_limit 触发压缩)。
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
- * 使用字符数而非 token 数(粗略估计:1 token ≈ 3-4 中文字符 / 4 英文字符)。
29
- * 80K 字符 ≈ 20K-27K tokens,约为最小 context window (128K tokens) 的 15-20%。
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 DEFAULT_CHAR_THRESHOLD = 80_000;
46
- const DEFAULT_KEEP_RECENT = 10;
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 compactMessages(messages: StandardMessage[], config?: CompactConfig): boolean {
60
- const charThreshold = config?.charThreshold ?? DEFAULT_CHAR_THRESHOLD;
61
- const keepRecent = config?.keepRecentMessages ?? DEFAULT_KEEP_RECENT;
62
-
63
- // 计算总字符数
64
- const totalChars = messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0);
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
- systemEnd = 1;
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
- if (recentStart - firstUserEnd < 4) {
96
- return false;
97
- }
98
-
99
- // ---- 生成摘要 ----
106
+ const keepMessages = messages.slice(recentStart);
107
+
108
+ // 要被总结的中间历史
100
109
  const middleMessages = messages.slice(firstUserEnd, recentStart);
101
- const summary = buildSummary(middleMessages);
102
-
103
- // ---- 组装压缩后的消息 ----
104
- const compressed: StandardMessage[] = [
105
- ...messages.slice(0, firstUserEnd), // system + first user
106
- { role: 'system', content: summary }, // 压缩摘要
107
- ...messages.slice(recentStart), // 最近 N
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 buildSummary(middleMessages: StandardMessage[]): string {
126
- const toolCallNames: string[] = [];
127
- let textPreview = '';
128
-
129
- for (const msg of middleMessages) {
130
- if (msg.role === 'assistant' && msg.toolCalls) {
131
- for (const tc of msg.toolCalls) {
132
- toolCallNames.push(tc.name);
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
- if (msg.role === 'assistant' && msg.content) {
136
- textPreview += msg.content.slice(0, 200) + '\n';
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
  }