@nickyzj2023/ai 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ai
2
2
 
3
- 男生自用MyPi Coding Agent,0外部依赖
3
+ 男生自用MyPi Coding Agent
4
4
 
5
5
  ## 安装
6
6
 
package/dist/cli.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { i as defineModel, n as get_weather_default, o as runAgent, r as get_time_default } from "./src-BJFCp7ur.mjs";
1
+ import { a as get_time_default, c as runAgent, i as get_weather_default, o as defineModel, r as loadMCPTools } from "./src-DEDOOez9.mjs";
2
2
  import { extractErrorMessage } from "@nickyzj2023/utils";
3
3
  import readline from "node:readline";
4
4
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -162,16 +162,23 @@ var TUI = class {
162
162
  this.preparePrint("tool_result");
163
163
  process.stdout.write(this.colorize(`[工具结果:${name}] ${result}`, "90"));
164
164
  }
165
+ formatter = new Intl.NumberFormat("en-US", {
166
+ notation: "compact",
167
+ maximumFractionDigits: 1
168
+ });
169
+ format(number) {
170
+ return this.formatter.format(number);
171
+ }
165
172
  /** 打印轮次结束原因、token消耗 */
166
173
  printFinish(finishReason, usage) {
167
174
  this.preparePrint("done");
168
- process.stdout.write(this.colorize(`[本轮结束:${finishReason}] ${usage ? `输入${usage.prompt_tokens}tok,输出${usage.completion_tokens}tok,总共${usage.total_tokens}tok` : ""}${finishReason === "stop" ? "\n\n" : "\n"}`, "90"));
175
+ process.stdout.write(this.colorize(`[本轮结束:${finishReason}] ${usage ? `输入${this.format(usage.prompt_tokens)}token,输出${this.format(usage.completion_tokens)}token,总共${this.format(usage.total_tokens)}token` : ""}${finishReason === "stop" ? "\n\n" : "\n"}`, "90"));
169
176
  }
170
177
  };
171
178
  //#endregion
172
179
  //#region src/cli.ts
173
180
  /** 启动交互对话:配置来自全局配置文件,环境变量可临时覆盖 */
174
- const startChat = () => {
181
+ const startChat = async () => {
175
182
  const config = loadConfig();
176
183
  if (!config) {
177
184
  console.error("请先运行 `ai setup` 配置一个模型");
@@ -179,7 +186,12 @@ const startChat = () => {
179
186
  }
180
187
  const model = defineModel(config);
181
188
  const messages = [];
182
- const tools = [get_weather_default, get_time_default];
189
+ const mcpTools = await loadMCPTools(config.mcpServers);
190
+ const tools = [
191
+ get_weather_default,
192
+ get_time_default,
193
+ ...mcpTools
194
+ ];
183
195
  const tui = new TUI(async (input) => {
184
196
  messages.push({
185
197
  role: "user",
package/dist/index.d.mts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client";
1
2
  //#region src/types.d.ts
2
3
  type Model = {
3
4
  baseUrl: string;
@@ -148,126 +149,156 @@ declare const _default: ToolDefinition;
148
149
  //#region src/tools/get-weather.d.ts
149
150
  declare const _default$1: ToolDefinition;
150
151
  //#endregion
152
+ //#region src/tools/mcp.d.ts
153
+ type McpServer = {
154
+ type: "streamable_http" | "sse";
155
+ url: string;
156
+ headers?: Record<string, any>;
157
+ ignoredToolNames?: string[];
158
+ };
159
+ declare class MCPRouter {
160
+ private entries;
161
+ /** 注册一个新的MCP客户端 */
162
+ addClient(name: string, url: string, options?: Omit<McpServer, "type" | "url">): Promise<Client<{
163
+ method: string;
164
+ params?: {
165
+ [x: string]: unknown;
166
+ _meta?: {
167
+ [x: string]: unknown;
168
+ progressToken?: string | number | undefined;
169
+ "io.modelcontextprotocol/related-task"?: {
170
+ taskId: string;
171
+ } | undefined;
172
+ } | undefined;
173
+ } | undefined;
174
+ }, {
175
+ method: string;
176
+ params?: {
177
+ [x: string]: unknown;
178
+ _meta?: {
179
+ [x: string]: unknown;
180
+ progressToken?: string | number | undefined;
181
+ "io.modelcontextprotocol/related-task"?: {
182
+ taskId: string;
183
+ } | undefined;
184
+ } | undefined;
185
+ } | undefined;
186
+ }, {
187
+ [x: string]: unknown;
188
+ _meta?: {
189
+ [x: string]: unknown;
190
+ progressToken?: string | number | undefined;
191
+ "io.modelcontextprotocol/related-task"?: {
192
+ taskId: string;
193
+ } | undefined;
194
+ } | undefined;
195
+ }> | undefined>;
196
+ /** 返回OpenAI API兼容的tools数组 */
197
+ getTools(): Promise<ToolDefinition[]>;
198
+ }
199
+ /**
200
+ * 把传入的MCPServer列表转换成OpenAI API兼容的tools数组
201
+ */
202
+ declare const loadMCPTools: (mcpServers: Record<string, McpServer>) => Promise<ToolDefinition[]>;
203
+ //#endregion
151
204
  //#region src/utils/compact/types.d.ts
152
205
  declare namespace Compact {
153
- type ReplacerOfToolResultContent = (content: Message["content"], options?: Record<string, any>) => Promise<Message["content"]> | Message["content"];
154
- type ReplacerOfMediaContent = (content: Message["content"], options?: Record<string, any>) => Promise<Message["content"]> | Message["content"];
206
+ type Options = {
207
+ /** 提供token消耗情况时,能更准确地判断上下文是否达到阈值 */
208
+ usage?: Usage;
209
+ /**
210
+ * 各种压缩方式统一保留的最近消息条数
211
+ * @default 10
212
+ * @remarks 压缩工具调用结果、压缩媒体消息、总结消息、硬删除兜底都会保留最近keepCount条消息不处理
213
+ */
214
+ keepCount?: number;
215
+ /**
216
+ * 压缩工具/媒体消息时做个标记,防止下次重复压缩
217
+ * @default "[已压缩]"
218
+ */
219
+ compactedMessageMark?: string;
220
+ /**
221
+ * 上下文>总上下文*ratio时压缩工具调用结果
222
+ * @default 0.6
223
+ */
224
+ ratioToCompactToolResults?: number;
225
+ /**
226
+ * 如何压缩工具调用结果
227
+ * @default 让其他模型返回简化后的工具结果
228
+ */
229
+ replacerOfToolResultContent?: Compact.ReplacerOfToolResultContent;
230
+ /**
231
+ * 上下文>总上下文*ratio时压缩图片/音频/视频消息
232
+ * @default 0.7
233
+ */
234
+ ratioToCompactMedia?: number;
235
+ /**
236
+ * 如何压缩媒体消息
237
+ * @default 让其他模型用自然语言简短描述一遍
238
+ */
239
+ replacerOfMediaContent?: Compact.ReplacerOfMediaContent;
240
+ /**
241
+ * 上下文>总上下文*ratio时总结消息
242
+ * @default 0.8
243
+ * @remarks 如果总结成功,会把keepCount以前的消息压成一条用户消息
244
+ */
245
+ ratioToSummarize?: number;
246
+ /**
247
+ * 总结消息时的配置项
248
+ * @default { systemPrompt: "你现在的任务是总结历史消息" }
249
+ */
250
+ summarizeOptions?: Partial<Compact.SummarizeOptions>;
251
+ };
252
+ type ReplacerOfToolResultContent = (content: Message["content"], options?: Record<string, any>) => Promise<string> | string;
253
+ type ReplacerOfMediaContent = (content: Message["content"], options?: Record<string, any>) => Promise<string> | string;
155
254
  type SummarizeOptions = {
156
- /** 用什么模型总结 */
157
- model: Model;
158
- /** 用于指导大模型如何总结消息的提示词 */
255
+ /** 指导大模型如何总结消息 */
159
256
  systemPrompt: string;
257
+ model: Model;
160
258
  };
161
- /**
162
- * compactMessages 的返回值,告知调用方各压缩动作是否执行
163
- * @remarks
164
- * 这些字段是"操作级"标志:为 true 只代表对应操作已执行,不代表一定产生了效果。
165
- * 例如 hasSummarized 为 true 只代表进入了总结流程,是否真的总结成功,
166
- * 应由调用方检查消息数组里是否出现含 `<summary>` 标签的消息来判断。
167
- */
168
- type CompactResult = {
169
- /** 是否执行了压缩工具调用结果 */
170
- hasCompactedToolResult: boolean;
171
- /** 是否执行了压缩图片/音频/视频消息 */
259
+ type Result = {
260
+ /** 是否压缩了工具调用结果 */
261
+ hasCompactedToolResults: boolean;
262
+ /** 是否压缩了图片/音频/视频消息 */
172
263
  hasCompactedMedia: boolean;
173
- /** 是否执行了清理软删除残留的占位消息 */
174
- hasClearedSoftDeletedMessages: boolean;
175
- /** 是否执行了总结消息操作(是否真的总结,请检查消息中是否出现`<summary>`标签) */
264
+ /** 是否总结了消息 */
176
265
  hasSummarized: boolean;
177
- /** 是否执行了兜底硬删除较早消息 */
178
- hasDeletedOldMessages: boolean;
266
+ /** 是否丢弃了一些旧消息(最终兜底策略) */
267
+ hasDiscardMessages: boolean;
179
268
  };
180
269
  }
181
270
  //#endregion
182
271
  //#region src/utils/compact/strategy.d.ts
183
- declare const softDeleteToolResults: (messages: Message[], options: {
272
+ /**
273
+ * @returns 实际处理了几条消息
274
+ */
275
+ declare const compactToolResults: (compressible: Message[], options: {
184
276
  replacer?: Compact.ReplacerOfToolResultContent;
185
277
  mark: string;
186
278
  model?: Model;
187
- }) => Promise<Message[]>;
188
- declare const softDeleteOldMediaMessages: (messages: Message[], options: {
279
+ }) => Promise<number>;
280
+ /**
281
+ * @returns 实际处理了几条消息
282
+ */
283
+ declare const compactMediaMessages: (compressible: Message[], options: {
189
284
  replacer?: Compact.ReplacerOfMediaContent;
190
- mark: string;
191
285
  model?: Model;
192
- }) => Promise<Message[]>;
286
+ }) => Promise<number>;
287
+ declare const summarizeMessages: (compressible: Message[], options: Compact.SummarizeOptions) => Promise<number>;
193
288
  /**
194
- * 清理软删除后残留的占位消息(信息在软删除时已丢失,此时删除不损失任何额外信息)
195
- * @param messages 可压缩区消息数组,直接原地删除
196
- * @param softDeleted 本轮被软删过的消息引用集合
197
- * @remarks
198
- * tool消息不能单独删除:OpenAI API要求assistant(tool_calls)与tool消息按tool_call_id配对,
199
- * 单独删掉tool会让assistant(tool_calls)变成孤立消息,后续请求返回400。
200
- * 因此遇到被软删的tool消息时,必须把整个assistant(tool_calls) + tool配对组一起删除。
201
- * 组内多条tool被软删时,集合去重后只会删除一次。
289
+ * 最终的兜底压缩策略,从头删除旧消息,直到第二个回调函数返回true(达成目标)
202
290
  */
203
- declare const hardDeleteSoftMessages: (messages: Message[], softDeleted: ReadonlySet<Message>) => void;
204
- declare const summarizeMessages: (messages: Message[], options: Compact.SummarizeOptions) => Promise<void>;
205
- declare const hardDeleteOldMessages: (messages: Message[]) => void;
291
+ declare const discardMessagesUntil: (compressible: Message[], until: (compressible: Message[]) => boolean) => number;
206
292
  //#endregion
207
293
  //#region src/utils/compact/index.d.ts
208
294
  /**
209
295
  * 自动优化上下文,类似AI Coding Agent的/compact命令
210
296
  */
211
- declare const compact: ((messages: Message[], model: Model, options?: {
212
- /** 提供token消耗情况时,能更准确地判断上下文是否达到阈值 */
213
- usage?: Usage;
214
- /**
215
- * 各种压缩方式统一保留的最近消息条数
216
- * @default 10
217
- * @remarks 压缩工具调用结果、压缩媒体消息、总结消息、硬删除兜底都会保留最近keepCount条消息不处理
218
- */
219
- keepCount?: number;
220
- /**
221
- * 上下文>总上下文*ratio时压缩工具调用结果
222
- * @default 0.5
223
- */
224
- ratioToCompactToolResult?: number;
225
- /**
226
- * 如何压缩工具调用结果,例如让其他模型返回精简后的工具结果
227
- * @default (content) => "(已被消费)"
228
- */
229
- replacerOfToolResultContent?: Compact.ReplacerOfToolResultContent;
230
- /**
231
- * 上下文>总上下文*ratio时压缩图片/音频/视频消息
232
- * @default 0.6
233
- */
234
- ratioToCompactMedia?: number;
235
- /**
236
- * 如何压缩媒体消息,例如让其他模型用自然语言简短描述一遍
237
- * @default (content) => "(已被丢弃)"
238
- */
239
- replacerOfMediaContent?: Compact.ReplacerOfMediaContent;
240
- /**
241
- * 用于标记哪些消息被软删除了
242
- */
243
- softDeletedMessageMark?: string;
244
- /**
245
- * 上下文>总上下文*ratio时清理软删除残留的占位消息
246
- * @default 0.7
247
- * @remarks
248
- * 软删除(ratioToCompactToolResult/ratioToCompactMedia)只替换content不删消息,
249
- * 该选项负责把残留的占位消息真正移除。信息在软删除时已丢失,清理不损失任何额外信息。
250
- * 阈值应介于ratioToCompactMedia与ratioToSummarize之间:
251
- * 太早则媒体软删还没执行、无残留可清;太晚则总结/硬删除兜底已处理整个压缩区,清理失去意义
252
- */
253
- ratioToClearSoftDeletedMessages?: number;
254
- /**
255
- * 上下文>总上下文*ratio时总结消息
256
- * @default 0.8
257
- * @remarks 如果总结成功,会把keepCount(默认10,见顶层选项)以前的消息压成一条消息;如果总结失败,会采取兜底压缩方法:硬删除keepCount以前的消息
258
- */
259
- ratioToSummarize?: number;
260
- /**
261
- * 总结消息时的配置项
262
- * @default { model: undefined, systemPrompt: "总结历史消息" }
263
- */
264
- summarizeOptions?: Partial<Compact.SummarizeOptions>;
265
- }) => Promise<Compact.CompactResult>) & {
297
+ declare const compact: ((messages: Message[], model: Model, options?: Compact.Options) => Promise<Compact.Result>) & {
298
+ compactToolResults: typeof compactToolResults;
299
+ compactMediaMessages: typeof compactMediaMessages;
266
300
  summarizeMessages: typeof summarizeMessages;
267
- softDeleteToolResults: typeof softDeleteToolResults;
268
- softDeleteOldMediaMessages: typeof softDeleteOldMediaMessages;
269
- hardDeleteSoftMessages: typeof hardDeleteSoftMessages;
270
- hardDeleteOldMessages: typeof hardDeleteOldMessages;
301
+ discardMessagesUntil: typeof discardMessagesUntil;
271
302
  };
272
303
  //#endregion
273
304
  //#region src/utils/helper.d.ts
@@ -282,4 +313,4 @@ declare const defineModel: (config: Model) => Model;
282
313
  */
283
314
  declare const defineTool: (name: ToolDefinition["function"]["name"], description: ToolDefinition["function"]["description"], properties: ToolDefinition["function"]["parameters"]["properties"], execute: ToolDefinition["execute"]) => ToolDefinition;
284
315
  //#endregion
285
- export { type AgentEvent, type AudioContent, type ChatCompletionsChunk, type ContentPart, type FinishReason, type ImageContent, type LLMEvent, type Message, type Modality, type Model, type TextContent, type ToolCall, type ToolDefinition, type Usage, type VideoContent, compact, defineModel, defineTool, _default as getTime, _default$1 as getWeather, runAgent };
316
+ export { type AgentEvent, type AudioContent, type ChatCompletionsChunk, type ContentPart, type FinishReason, type ImageContent, type LLMEvent, MCPRouter, McpServer, type Message, type Modality, type Model, type TextContent, type ToolCall, type ToolDefinition, type Usage, type VideoContent, compact, defineModel, defineTool, _default as getTime, _default$1 as getWeather, loadMCPTools, runAgent };
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as defineTool, i as defineModel, n as get_weather_default, o as runAgent, r as get_time_default, t as compact } from "./src-BJFCp7ur.mjs";
2
- export { compact, defineModel, defineTool, get_time_default as getTime, get_weather_default as getWeather, runAgent };
1
+ import { a as get_time_default, c as runAgent, i as get_weather_default, n as MCPRouter, o as defineModel, r as loadMCPTools, s as defineTool, t as compact } from "./src-DEDOOez9.mjs";
2
+ export { MCPRouter, compact, defineModel, defineTool, get_time_default as getTime, get_weather_default as getWeather, loadMCPTools, runAgent };
@@ -1,4 +1,6 @@
1
- import { createXMLText, fetcher, logger, parseSSE, pick, to } from "@nickyzj2023/utils";
1
+ import { createXMLText, extractErrorMessage, fetcher, isObject, logger, omit, parseSSE, pick, to } from "@nickyzj2023/utils";
2
+ import { Client } from "@modelcontextprotocol/sdk/client";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
2
4
  //#region src/llm.ts
3
5
  /**
4
6
  * 分离ToolDefinition中的valid/invalid字段,前者可以传给模型,后者用于本地运算
@@ -229,248 +231,258 @@ var get_weather_default = defineTool("get_weather", "查询指定城市的天气
229
231
  return fetcher("https://wttr.in", { params: { format: "j1" } }).get(`/${city}`);
230
232
  });
231
233
  //#endregion
234
+ //#region src/tools/mcp.ts
235
+ /** 全局单例MCP加载器 */
236
+ let router = null;
237
+ var MCPRouter = class {
238
+ entries = /* @__PURE__ */ new Map();
239
+ /** 注册一个新的MCP客户端 */
240
+ async addClient(name, url, options) {
241
+ if (this.entries.has(name)) return;
242
+ const transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: options?.headers } });
243
+ const client = new Client({
244
+ name,
245
+ version: "1.0.0"
246
+ });
247
+ await client.connect(transport);
248
+ const { tools } = await client.listTools();
249
+ const normalizedTools = tools.filter((tool) => !options?.ignoredToolNames?.includes(tool.name)).map((tool) => {
250
+ const _properties = { ...tool.inputSchema.properties ?? {} };
251
+ tool.inputSchema.required?.forEach((key) => {
252
+ if (isObject(_properties[key])) _properties[key] = {
253
+ ..._properties[key],
254
+ required: true
255
+ };
256
+ });
257
+ return defineTool(tool.name, tool.description ?? "", _properties, (args) => client.callTool({
258
+ name: tool.name,
259
+ arguments: args
260
+ }));
261
+ });
262
+ this.entries.set(name, {
263
+ client,
264
+ tools: normalizedTools
265
+ });
266
+ return client;
267
+ }
268
+ /** 返回OpenAI API兼容的tools数组 */
269
+ async getTools() {
270
+ return [...this.entries.values()].flatMap((e) => e.tools);
271
+ }
272
+ };
273
+ /**
274
+ * 把传入的MCPServer列表转换成OpenAI API兼容的tools数组
275
+ */
276
+ const loadMCPTools = async (mcpServers) => {
277
+ router ||= new MCPRouter();
278
+ await Promise.allSettled(Object.entries(mcpServers).map(async ([name, server]) => {
279
+ try {
280
+ await router?.addClient(name, server.url, omit(server, ["type", "url"]));
281
+ logger(`已激活MCP客户端:${name}`);
282
+ } catch (e) {
283
+ logger(`MCP服务器${name}连接失败,跳过:${extractErrorMessage(e)}`);
284
+ }
285
+ }));
286
+ return router.getTools();
287
+ };
288
+ //#endregion
232
289
  //#region src/utils/compact/helper.ts
233
290
  /**
234
- * 判断消息是否为带有工具调用的 assistant 消息
291
+ * 校验assistant(tool_calls)消息
292
+ */
293
+ const isToolCalls = (message) => {
294
+ return message?.role === "assistant" && Array.isArray(message.tool_calls);
295
+ };
296
+ /**
297
+ * 校验多模态消息
235
298
  */
236
- const hasToolCalls = (message) => message?.role === "assistant" && Array.isArray(message.tool_calls);
299
+ const isMediaMessage = (message) => {
300
+ const MEDIA_TYPES = [
301
+ "image_url",
302
+ "input_audio",
303
+ "video_url"
304
+ ];
305
+ return message && Array.isArray(message.content) && message.content.some((part) => MEDIA_TYPES.includes(part.type));
306
+ };
237
307
  /**
238
- * 查找tool消息所属的 assistant(tool_calls) + tool 配对组范围
308
+ * 查找assistant(tool_calls) + tool配对组范围
239
309
  * @param messages 消息数组
240
- * @param toolIndex tool消息的索引
241
- * @returns 配对组范围 [startIndex, endIndex)(含组头assistant与其后连续的全部tool消息);
242
- * 找不到配对组头时返回 null
243
- * @remarks 配对组约定:组头是assistant(tool_calls),组内是紧随其后的连续tool消息。
244
- * 删除或切分tool消息时都应整组处理,否则会留下孤立消息导致OpenAI API返回400。
245
- * 组内任意一条tool消息都能定位到整组,供上层按整组范围批量操作
310
+ * @param toolIndex 组内任意tool消息下标
311
+ * @returns 下标数组[组头assistant(tool_calls), 组内最后一个tool),找不到组头时返回null
246
312
  */
247
313
  const findToolGroupRange = (messages, toolIndex) => {
248
314
  let groupStart = toolIndex;
249
315
  while (groupStart > 0 && messages[groupStart - 1]?.role === "tool") groupStart--;
250
- if (!hasToolCalls(messages[groupStart - 1])) return null;
316
+ if (!isToolCalls(messages[groupStart - 1])) return null;
251
317
  let groupEnd = groupStart;
252
318
  while (groupEnd < messages.length && messages[groupEnd]?.role === "tool") groupEnd++;
253
319
  return [groupStart - 1, groupEnd];
254
320
  };
255
321
  //#endregion
256
322
  //#region src/utils/compact/strategy.ts
257
- /** 默认的软删除多模态消息策略:让大模型精简消息内容 */
323
+ /** 默认的压缩工具结果策略:让大模型精简消息内容 */
258
324
  const defaultReplacerOfToolResultContent = async (content, options) => {
259
- const { mark, model } = options ?? {};
325
+ const { model } = options ?? {};
260
326
  const messages = [{
261
327
  role: "user",
262
328
  content
263
329
  }, {
264
330
  role: "user",
265
- content: "请用一两句话对上条消息做个“省流”"
331
+ content: "请用一两句话简述上条消息"
266
332
  }];
267
- let simplifiedContent = mark;
333
+ let simplifiedContent = "";
268
334
  for await (const e of runAgent(model, messages, [])) if (e.type === "content_delta") simplifiedContent += e.delta;
335
+ else if (e.type === "error") throw new Error(e.message);
269
336
  return simplifiedContent;
270
337
  };
271
- const softDeleteToolResults = async (messages, options) => {
272
- const { replacer = defaultReplacerOfToolResultContent, mark, model } = options ?? {};
273
- if (!replacer && !model) return [];
274
- const softDeleted = [];
275
- for (const message of messages) if (message?.role === "tool" && typeof message.content === "string") {
338
+ /**
339
+ * @returns 实际处理了几条消息
340
+ */
341
+ const compactToolResults = async (compressible, options) => {
342
+ const { replacer, mark, model } = options ?? {};
343
+ if (!replacer && !model) return 0;
344
+ const _replacer = replacer || defaultReplacerOfToolResultContent;
345
+ let count = 0;
346
+ for (const message of compressible) if (message?.role === "tool" && typeof message.content === "string") {
276
347
  if (message.content.startsWith(mark)) continue;
277
- message.content = await replacer(message.content, {
278
- mark,
279
- model
280
- });
281
- softDeleted.push(message);
348
+ message.content = mark + await _replacer(message.content, { model });
349
+ count++;
282
350
  }
283
- if (softDeleted.length > 0) logger(`软删除了${softDeleted.length}条工具调用结果消息`);
284
- return softDeleted;
351
+ if (count > 0) logger(`压缩了${count}条工具调用结果`);
352
+ return count;
285
353
  };
286
- /** 默认的软删除多模态消息策略:让大模型精简消息内容 */
354
+ /** 默认的压缩多模态消息策略:让大模型精简消息内容 */
287
355
  const defaultReplacerOfMediaContent = async (content, options) => {
288
- const { mark, model } = options ?? {};
356
+ const { model } = options ?? {};
289
357
  const messages = [{
290
358
  role: "user",
291
359
  content
292
360
  }, {
293
361
  role: "user",
294
- content: "请用一两句话描述上方的多模态消息"
362
+ content: "请用一两句话简述上方的多模态消息"
295
363
  }];
296
- let simplifiedContent = mark;
364
+ let simplifiedContent = "";
297
365
  for await (const e of runAgent(model, messages, [])) if (e.type === "content_delta") simplifiedContent += e.delta;
366
+ else if (e.type === "error") throw new Error(e.message);
298
367
  return simplifiedContent;
299
368
  };
300
- const softDeleteOldMediaMessages = async (messages, options) => {
301
- const { replacer = defaultReplacerOfMediaContent, mark, model } = options ?? {};
302
- if (!replacer && !model) return [];
303
- const mediaTypes = [
304
- "image_url",
305
- "input_audio",
306
- "video_url"
307
- ];
308
- const softDeleted = [];
309
- for (const message of messages) if (message && Array.isArray(message.content) && message.content.some((part) => mediaTypes.includes(part.type))) {
310
- message.content = await replacer(message.content, {
311
- mark,
312
- model
313
- });
314
- softDeleted.push(message);
315
- }
316
- if (softDeleted.length > 0) logger(`软删除了${softDeleted.length}条旧图片/音频/视频消息`);
317
- return softDeleted;
318
- };
319
369
  /**
320
- * 清理软删除后残留的占位消息(信息在软删除时已丢失,此时删除不损失任何额外信息)
321
- * @param messages 可压缩区消息数组,直接原地删除
322
- * @param softDeleted 本轮被软删过的消息引用集合
323
- * @remarks
324
- * tool消息不能单独删除:OpenAI API要求assistant(tool_calls)与tool消息按tool_call_id配对,
325
- * 单独删掉tool会让assistant(tool_calls)变成孤立消息,后续请求返回400。
326
- * 因此遇到被软删的tool消息时,必须把整个assistant(tool_calls) + tool配对组一起删除。
327
- * 组内多条tool被软删时,集合去重后只会删除一次。
370
+ * @returns 实际处理了几条消息
328
371
  */
329
- const hardDeleteSoftMessages = (messages, softDeleted) => {
330
- const deleteIndices = /* @__PURE__ */ new Set();
331
- for (let i = 0; i < messages.length; i++) {
332
- const message = messages[i];
333
- if (!message || !softDeleted.has(message)) continue;
334
- if (message.role === "tool") {
335
- const range = findToolGroupRange(messages, i);
336
- if (range) {
337
- const [start, end] = range;
338
- for (let k = start; k < end; k++) deleteIndices.add(k);
339
- } else deleteIndices.add(i);
340
- } else deleteIndices.add(i);
372
+ const compactMediaMessages = async (compressible, options) => {
373
+ const { replacer, model } = options ?? {};
374
+ if (!replacer && !model) return 0;
375
+ const _replacer = replacer || defaultReplacerOfMediaContent;
376
+ let count = 0;
377
+ for (const message of compressible) if (isMediaMessage(message)) {
378
+ const compacted = await _replacer(message.content, { model });
379
+ message.content = createXMLText("media", compacted);
380
+ count++;
341
381
  }
342
- const sortedIndices = [...deleteIndices].sort((a, b) => b - a);
343
- for (const index of sortedIndices) if (index !== void 0) messages.splice(index, 1);
344
- if (deleteIndices.size > 0) logger(`清理了${deleteIndices.size}条软删除残留消息`);
382
+ if (count > 0) logger(`压缩了${count}条多模态消息`);
383
+ return count;
345
384
  };
346
- const summarizeMessages = async (messages, options) => {
385
+ const summarizeMessages = async (compressible, options) => {
347
386
  const { model, systemPrompt } = options ?? {};
348
- if (messages.length === 0) {
387
+ const summarizable = compressible.slice(1);
388
+ if (summarizable.length === 0) {
349
389
  logger("消息太少,无需总结");
350
- return;
351
- }
352
- const summarizableIndices = [];
353
- const summarizingMessages = [];
354
- for (let i = 0; i < messages.length; i++) {
355
- const message = messages[i];
356
- if (!message) continue;
357
- if (i === 0 && message.role === "system") continue;
358
- if (hasToolCalls(message)) {
359
- let j = i + 1;
360
- while (j < messages.length && messages[j]?.role === "tool") j++;
361
- const group = messages.slice(i, j);
362
- for (let k = i; k < j; k++) summarizableIndices.push(k);
363
- summarizingMessages.push(...group);
364
- i = j - 1;
365
- continue;
366
- }
367
- if (message.role === "tool") continue;
368
- summarizableIndices.push(i);
369
- summarizingMessages.push(message);
370
- }
371
- if (summarizableIndices.length === 0) {
372
- logger("没有可总结的消息");
373
- return;
390
+ return 0;
374
391
  }
375
- summarizingMessages.push({
392
+ const count = summarizable.length;
393
+ summarizable.push({
376
394
  role: "system",
377
395
  content: systemPrompt
378
396
  }, {
379
397
  role: "user",
380
- content: "开始总结"
398
+ content: "开始总结上下文"
381
399
  });
382
400
  let summarized = "";
383
401
  let usage;
384
- for await (const e of runAgent(model, summarizingMessages, [])) if (e.type === "content_delta") summarized += e.delta;
402
+ for await (const e of runAgent(model, summarizable, [])) if (e.type === "content_delta") summarized += e.delta;
385
403
  else if (e.type === "done") usage = e.usage;
386
- logger(`总结了${summarizingMessages.length}条消息,消耗:`, usage);
387
- const firstIndex = summarizableIndices[0] ?? 0;
388
- for (let i = summarizableIndices.length - 1; i >= 0; i--) {
389
- const index = summarizableIndices[i];
390
- if (index !== void 0) messages.splice(index, 1);
391
- }
392
- messages.splice(firstIndex, 0, {
404
+ else if (e.type === "error") throw new Error(e.message);
405
+ compressible.splice(1, Infinity, {
393
406
  role: "user",
394
407
  content: createXMLText("summary", summarized)
395
408
  });
409
+ logger(`总结了${count}条消息,消耗:`, usage);
410
+ return count;
396
411
  };
397
- const hardDeleteOldMessages = (messages) => {
398
- const startIndex = messages.findIndex((message) => message.role === "user");
399
- if (startIndex < 0) {
400
- logger("消息太少,无需硬删除");
401
- return;
412
+ /**
413
+ * 最终的兜底压缩策略,从头删除旧消息,直到第二个回调函数返回true(达成目标)
414
+ */
415
+ const discardMessagesUntil = (compressible, until) => {
416
+ let count = 0;
417
+ while (compressible.length > 0 && !until(compressible)) {
418
+ let endIndex = Math.max(1, Math.floor(compressible.length / 10));
419
+ if (compressible[endIndex]?.role === "tool") {
420
+ const range = findToolGroupRange(compressible, endIndex);
421
+ if (range) endIndex = range[1];
422
+ else while (compressible[endIndex]?.role === "tool") endIndex++;
423
+ }
424
+ count += compressible.splice(0, endIndex).length;
402
425
  }
403
- const deletedCount = messages.length - startIndex;
404
- messages.splice(startIndex, deletedCount);
405
- logger(`硬删除了${deletedCount}条较早的消息`);
426
+ logger(`丢弃了${count}条旧消息`);
427
+ return count;
406
428
  };
407
429
  /**
408
430
  * 自动优化上下文,类似AI Coding Agent的/compact命令
409
431
  */
410
432
  const compact = Object.assign(async (messages, model, options) => {
411
- const { usage, keepCount = 10, ratioToCompactToolResult = .5, replacerOfToolResultContent, ratioToCompactMedia = .6, replacerOfMediaContent, ratioToClearSoftDeletedMessages = .7, softDeletedMessageMark = "[已简化]", ratioToSummarize = .8, summarizeOptions } = options ?? {};
433
+ const { usage, keepCount = 10, compactedMessageMark = "[已简化]", ratioToCompactToolResults = .6, replacerOfToolResultContent, ratioToCompactMedia = .7, replacerOfMediaContent, ratioToSummarize = .8, summarizeOptions } = options ?? {};
412
434
  const context = model?.context ?? 131072;
413
435
  const tokens = usage?.total_tokens ?? estimateTokens(messages);
414
436
  const result = {
415
- hasCompactedToolResult: false,
437
+ hasCompactedToolResults: false,
416
438
  hasCompactedMedia: false,
417
- hasClearedSoftDeletedMessages: false,
418
439
  hasSummarized: false,
419
- hasDeletedOldMessages: false
440
+ hasDiscardMessages: false
420
441
  };
421
- let endIndex = Math.max(0, messages.length - keepCount);
442
+ const startIndex = 1;
443
+ let endIndex = Math.max(startIndex, messages.length - keepCount);
422
444
  if (messages[endIndex]?.role === "tool") {
423
445
  const range = findToolGroupRange(messages, endIndex);
424
446
  if (range) endIndex = range[1];
425
447
  else while (messages[endIndex]?.role === "tool") endIndex++;
426
448
  }
427
- const compressible = messages.slice(0, endIndex);
428
- const reserved = messages.slice(endIndex);
429
- const softDeleted = /* @__PURE__ */ new Set();
430
- if (tokens > context * ratioToCompactToolResult) {
431
- const softDeletedMessages = await softDeleteToolResults(compressible, {
432
- replacer: replacerOfToolResultContent,
433
- mark: softDeletedMessageMark,
434
- model
435
- });
436
- for (const message of softDeletedMessages) softDeleted.add(message);
437
- result.hasCompactedToolResult = true;
438
- }
439
- if (tokens > context * ratioToCompactMedia) {
440
- const softDeletedMessages = await softDeleteOldMediaMessages(compressible, {
441
- replacer: replacerOfMediaContent,
442
- mark: softDeletedMessageMark,
443
- model
444
- });
445
- for (const message of softDeletedMessages) softDeleted.add(message);
446
- result.hasCompactedMedia = true;
447
- }
448
- if (tokens > context * ratioToClearSoftDeletedMessages) {
449
- hardDeleteSoftMessages(compressible, softDeleted);
450
- result.hasClearedSoftDeletedMessages = true;
451
- messages.splice(0, messages.length, ...compressible, ...reserved);
452
- }
449
+ endIndex = Math.max(startIndex, endIndex);
450
+ const reservedStart = messages.slice(0, startIndex);
451
+ const compressible = messages.slice(startIndex, endIndex);
452
+ const reservedEnd = messages.slice(endIndex);
453
+ if (tokens > context * ratioToCompactToolResults) result.hasCompactedToolResults = await compactToolResults(compressible, {
454
+ replacer: replacerOfToolResultContent,
455
+ mark: compactedMessageMark,
456
+ model
457
+ }) > 0;
458
+ if (tokens > context * ratioToCompactMedia) result.hasCompactedMedia = await compactMediaMessages(compressible, {
459
+ replacer: replacerOfMediaContent,
460
+ model
461
+ }) > 0;
453
462
  if (tokens > context * ratioToSummarize) {
454
463
  const { systemPrompt = "你现在的任务是总结历史消息" } = summarizeOptions ?? {};
455
- const [error] = await to(summarizeMessages(compressible, {
464
+ const [error, count] = await to(summarizeMessages(compressible, {
456
465
  model,
457
466
  systemPrompt
458
467
  }));
459
- result.hasSummarized = true;
460
- if (error) {
461
- logger(`总结失败(${error.message}),改用硬删除兜底`);
462
- hardDeleteOldMessages(compressible);
463
- result.hasDeletedOldMessages = true;
464
- }
465
- messages.splice(0, messages.length, ...compressible, ...reserved);
468
+ if (!error) result.hasSummarized = count > 0;
469
+ else result.hasDiscardMessages = discardMessagesUntil(compressible, (compressible) => {
470
+ if (compressible.length === 0) return true;
471
+ const tempMessages = [
472
+ ...reservedStart,
473
+ ...compressible,
474
+ ...reservedEnd
475
+ ];
476
+ return estimateTokens(tempMessages) < context * ratioToSummarize;
477
+ }) > 0;
478
+ messages.splice(0, Infinity, ...reservedStart, ...compressible, ...reservedEnd);
466
479
  }
467
480
  return result;
468
481
  }, {
482
+ compactToolResults,
483
+ compactMediaMessages,
469
484
  summarizeMessages,
470
- softDeleteToolResults,
471
- softDeleteOldMediaMessages,
472
- hardDeleteSoftMessages,
473
- hardDeleteOldMessages
485
+ discardMessagesUntil
474
486
  });
475
487
  //#endregion
476
- export { defineTool as a, defineModel as i, get_weather_default as n, runAgent as o, get_time_default as r, compact as t };
488
+ export { get_time_default as a, runAgent as c, get_weather_default as i, MCPRouter as n, defineModel as o, loadMCPTools as r, defineTool as s, compact as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nickyzj2023/ai",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "我的“pi”,参考了pi-from-scratch",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -32,6 +32,7 @@
32
32
  "typescript": "^7.0.2"
33
33
  },
34
34
  "dependencies": {
35
+ "@modelcontextprotocol/sdk": "^1.30.0",
35
36
  "@nickyzj2023/utils": "link:../utils"
36
37
  },
37
38
  "scripts": {