@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 +1 -1
- package/dist/cli.mjs +16 -4
- package/dist/index.d.mts +128 -97
- package/dist/index.mjs +2 -2
- package/dist/{src-BJFCp7ur.mjs → src-DEDOOez9.mjs} +173 -161
- package/package.json +2 -1
package/README.md
CHANGED
package/dist/cli.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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}
|
|
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
|
|
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
|
|
154
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
266
|
+
/** 是否丢弃了一些旧消息(最终兜底策略) */
|
|
267
|
+
hasDiscardMessages: boolean;
|
|
179
268
|
};
|
|
180
269
|
}
|
|
181
270
|
//#endregion
|
|
182
271
|
//#region src/utils/compact/strategy.d.ts
|
|
183
|
-
|
|
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<
|
|
188
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
* 查找
|
|
308
|
+
* 查找assistant(tool_calls) + tool配对组范围
|
|
239
309
|
* @param messages 消息数组
|
|
240
|
-
* @param toolIndex tool
|
|
241
|
-
* @returns
|
|
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 (!
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
278
|
-
|
|
279
|
-
model
|
|
280
|
-
});
|
|
281
|
-
softDeleted.push(message);
|
|
348
|
+
message.content = mark + await _replacer(message.content, { model });
|
|
349
|
+
count++;
|
|
282
350
|
}
|
|
283
|
-
if (
|
|
284
|
-
return
|
|
351
|
+
if (count > 0) logger(`压缩了${count}条工具调用结果`);
|
|
352
|
+
return count;
|
|
285
353
|
};
|
|
286
|
-
/**
|
|
354
|
+
/** 默认的压缩多模态消息策略:让大模型精简消息内容 */
|
|
287
355
|
const defaultReplacerOfMediaContent = async (content, options) => {
|
|
288
|
-
const {
|
|
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 =
|
|
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
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
if (deleteIndices.size > 0) logger(`清理了${deleteIndices.size}条软删除残留消息`);
|
|
382
|
+
if (count > 0) logger(`压缩了${count}条多模态消息`);
|
|
383
|
+
return count;
|
|
345
384
|
};
|
|
346
|
-
const summarizeMessages = async (
|
|
385
|
+
const summarizeMessages = async (compressible, options) => {
|
|
347
386
|
const { model, systemPrompt } = options ?? {};
|
|
348
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
387
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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,
|
|
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
|
-
|
|
437
|
+
hasCompactedToolResults: false,
|
|
416
438
|
hasCompactedMedia: false,
|
|
417
|
-
hasClearedSoftDeletedMessages: false,
|
|
418
439
|
hasSummarized: false,
|
|
419
|
-
|
|
440
|
+
hasDiscardMessages: false
|
|
420
441
|
};
|
|
421
|
-
|
|
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
|
-
|
|
428
|
-
const
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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 =
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
471
|
-
softDeleteOldMediaMessages,
|
|
472
|
-
hardDeleteSoftMessages,
|
|
473
|
-
hardDeleteOldMessages
|
|
485
|
+
discardMessagesUntil
|
|
474
486
|
});
|
|
475
487
|
//#endregion
|
|
476
|
-
export {
|
|
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.
|
|
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": {
|