@nickyzj2023/ai 1.2.0 → 1.3.0
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 +74 -5
- package/dist/index.d.mts +74 -96
- package/dist/index.mjs +1 -1
- package/dist/{src-BJFCp7ur.mjs → src-Dm0rwYe2.mjs} +114 -159
- package/package.json +2 -1
package/README.md
CHANGED
package/dist/cli.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { i as defineModel, n as get_weather_default, o as runAgent, r as get_time_default } from "./src-
|
|
2
|
-
import { extractErrorMessage } from "@nickyzj2023/utils";
|
|
1
|
+
import { a as defineTool, i as defineModel, n as get_weather_default, o as runAgent, r as get_time_default } from "./src-Dm0rwYe2.mjs";
|
|
2
|
+
import { extractErrorMessage, isObject, omit } from "@nickyzj2023/utils";
|
|
3
3
|
import readline from "node:readline";
|
|
4
4
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
|
+
import { Client } from "@modelcontextprotocol/sdk/client";
|
|
8
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
7
9
|
//#region src/utils/config.ts
|
|
8
10
|
/**
|
|
9
11
|
* 获取全局配置文件路径(Windows/macOS/Linux通用)
|
|
@@ -162,16 +164,78 @@ var TUI = class {
|
|
|
162
164
|
this.preparePrint("tool_result");
|
|
163
165
|
process.stdout.write(this.colorize(`[工具结果:${name}] ${result}`, "90"));
|
|
164
166
|
}
|
|
167
|
+
formatter = new Intl.NumberFormat("en-US", {
|
|
168
|
+
notation: "compact",
|
|
169
|
+
maximumFractionDigits: 1
|
|
170
|
+
});
|
|
171
|
+
format(number) {
|
|
172
|
+
return this.formatter.format(number);
|
|
173
|
+
}
|
|
165
174
|
/** 打印轮次结束原因、token消耗 */
|
|
166
175
|
printFinish(finishReason, usage) {
|
|
167
176
|
this.preparePrint("done");
|
|
168
|
-
process.stdout.write(this.colorize(`[本轮结束:${finishReason}] ${usage ? `输入${usage.prompt_tokens}
|
|
177
|
+
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
178
|
}
|
|
170
179
|
};
|
|
171
180
|
//#endregion
|
|
181
|
+
//#region src/tools/mcp.ts
|
|
182
|
+
/** 全局单例MCP加载器 */
|
|
183
|
+
let router = null;
|
|
184
|
+
var MCPRouter = class {
|
|
185
|
+
entries = /* @__PURE__ */ new Map();
|
|
186
|
+
/** 注册一个新的MCP客户端 */
|
|
187
|
+
async addClient(name, url, options) {
|
|
188
|
+
if (this.entries.has(name)) return;
|
|
189
|
+
const transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: options?.headers } });
|
|
190
|
+
const client = new Client({
|
|
191
|
+
name,
|
|
192
|
+
version: "1.0.0"
|
|
193
|
+
});
|
|
194
|
+
await client.connect(transport);
|
|
195
|
+
const { tools } = await client.listTools();
|
|
196
|
+
const normalizedTools = tools.filter((tool) => !options?.ignoredToolNames?.includes(tool.name)).map((tool) => {
|
|
197
|
+
const _properties = { ...tool.inputSchema.properties ?? {} };
|
|
198
|
+
tool.inputSchema.required?.forEach((key) => {
|
|
199
|
+
if (isObject(_properties[key])) _properties[key] = {
|
|
200
|
+
..._properties[key],
|
|
201
|
+
required: true
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
return defineTool(tool.name, tool.description ?? "", _properties, (args) => client.callTool({
|
|
205
|
+
name: tool.name,
|
|
206
|
+
arguments: args
|
|
207
|
+
}));
|
|
208
|
+
});
|
|
209
|
+
this.entries.set(name, {
|
|
210
|
+
client,
|
|
211
|
+
tools: normalizedTools
|
|
212
|
+
});
|
|
213
|
+
return client;
|
|
214
|
+
}
|
|
215
|
+
/** 返回OpenAI API兼容的tools数组 */
|
|
216
|
+
async getTools() {
|
|
217
|
+
return [...this.entries.values()].flatMap((e) => e.tools);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* 把传入的MCPServer列表转换成OpenAI API兼容的tools数组
|
|
222
|
+
*/
|
|
223
|
+
const loadMCPTools = async (mcpServers) => {
|
|
224
|
+
router ||= new MCPRouter();
|
|
225
|
+
await Promise.allSettled(Object.entries(mcpServers).map(async ([name, server]) => {
|
|
226
|
+
try {
|
|
227
|
+
await router?.addClient(name, server.url, omit(server, ["type", "url"]));
|
|
228
|
+
console.log(`已加载MCP工具:${name}`);
|
|
229
|
+
} catch (e) {
|
|
230
|
+
console.error(`MCP服务器${name}连接失败,跳过:${extractErrorMessage(e)}`);
|
|
231
|
+
}
|
|
232
|
+
}));
|
|
233
|
+
return router.getTools();
|
|
234
|
+
};
|
|
235
|
+
//#endregion
|
|
172
236
|
//#region src/cli.ts
|
|
173
237
|
/** 启动交互对话:配置来自全局配置文件,环境变量可临时覆盖 */
|
|
174
|
-
const startChat = () => {
|
|
238
|
+
const startChat = async () => {
|
|
175
239
|
const config = loadConfig();
|
|
176
240
|
if (!config) {
|
|
177
241
|
console.error("请先运行 `ai setup` 配置一个模型");
|
|
@@ -179,7 +243,12 @@ const startChat = () => {
|
|
|
179
243
|
}
|
|
180
244
|
const model = defineModel(config);
|
|
181
245
|
const messages = [];
|
|
182
|
-
const
|
|
246
|
+
const mcpTools = await loadMCPTools(config.mcpServers);
|
|
247
|
+
const tools = [
|
|
248
|
+
get_weather_default,
|
|
249
|
+
get_time_default,
|
|
250
|
+
...mcpTools
|
|
251
|
+
];
|
|
183
252
|
const tui = new TUI(async (input) => {
|
|
184
253
|
messages.push({
|
|
185
254
|
role: "user",
|
package/dist/index.d.mts
CHANGED
|
@@ -150,124 +150,102 @@ declare const _default$1: ToolDefinition;
|
|
|
150
150
|
//#endregion
|
|
151
151
|
//#region src/utils/compact/types.d.ts
|
|
152
152
|
declare namespace Compact {
|
|
153
|
-
type
|
|
154
|
-
|
|
153
|
+
type Options = {
|
|
154
|
+
/** 提供token消耗情况时,能更准确地判断上下文是否达到阈值 */
|
|
155
|
+
usage?: Usage;
|
|
156
|
+
/**
|
|
157
|
+
* 各种压缩方式统一保留的最近消息条数
|
|
158
|
+
* @default 10
|
|
159
|
+
* @remarks 压缩工具调用结果、压缩媒体消息、总结消息、硬删除兜底都会保留最近keepCount条消息不处理
|
|
160
|
+
*/
|
|
161
|
+
keepCount?: number;
|
|
162
|
+
/**
|
|
163
|
+
* 压缩工具/媒体消息时做个标记,防止下次重复压缩
|
|
164
|
+
* @default "[已压缩]"
|
|
165
|
+
*/
|
|
166
|
+
compactedMessageMark?: string;
|
|
167
|
+
/**
|
|
168
|
+
* 上下文>总上下文*ratio时压缩工具调用结果
|
|
169
|
+
* @default 0.6
|
|
170
|
+
*/
|
|
171
|
+
ratioToCompactToolResults?: number;
|
|
172
|
+
/**
|
|
173
|
+
* 如何压缩工具调用结果
|
|
174
|
+
* @default 让其他模型返回简化后的工具结果
|
|
175
|
+
*/
|
|
176
|
+
replacerOfToolResultContent?: Compact.ReplacerOfToolResultContent;
|
|
177
|
+
/**
|
|
178
|
+
* 上下文>总上下文*ratio时压缩图片/音频/视频消息
|
|
179
|
+
* @default 0.7
|
|
180
|
+
*/
|
|
181
|
+
ratioToCompactMedia?: number;
|
|
182
|
+
/**
|
|
183
|
+
* 如何压缩媒体消息
|
|
184
|
+
* @default 让其他模型用自然语言简短描述一遍
|
|
185
|
+
*/
|
|
186
|
+
replacerOfMediaContent?: Compact.ReplacerOfMediaContent;
|
|
187
|
+
/**
|
|
188
|
+
* 上下文>总上下文*ratio时总结消息
|
|
189
|
+
* @default 0.8
|
|
190
|
+
* @remarks 如果总结成功,会把keepCount以前的消息压成一条用户消息
|
|
191
|
+
*/
|
|
192
|
+
ratioToSummarize?: number;
|
|
193
|
+
/**
|
|
194
|
+
* 总结消息时的配置项
|
|
195
|
+
* @default { systemPrompt: "你现在的任务是总结历史消息" }
|
|
196
|
+
*/
|
|
197
|
+
summarizeOptions?: Partial<Compact.SummarizeOptions>;
|
|
198
|
+
};
|
|
199
|
+
type ReplacerOfToolResultContent = (content: Message["content"], options?: Record<string, any>) => Promise<string> | string;
|
|
200
|
+
type ReplacerOfMediaContent = (content: Message["content"], options?: Record<string, any>) => Promise<string> | string;
|
|
155
201
|
type SummarizeOptions = {
|
|
156
|
-
/**
|
|
157
|
-
model: Model;
|
|
158
|
-
/** 用于指导大模型如何总结消息的提示词 */
|
|
202
|
+
/** 指导大模型如何总结消息 */
|
|
159
203
|
systemPrompt: string;
|
|
204
|
+
model: Model;
|
|
160
205
|
};
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
* 例如 hasSummarized 为 true 只代表进入了总结流程,是否真的总结成功,
|
|
166
|
-
* 应由调用方检查消息数组里是否出现含 `<summary>` 标签的消息来判断。
|
|
167
|
-
*/
|
|
168
|
-
type CompactResult = {
|
|
169
|
-
/** 是否执行了压缩工具调用结果 */
|
|
170
|
-
hasCompactedToolResult: boolean;
|
|
171
|
-
/** 是否执行了压缩图片/音频/视频消息 */
|
|
206
|
+
type Result = {
|
|
207
|
+
/** 是否压缩了工具调用结果 */
|
|
208
|
+
hasCompactedToolResults: boolean;
|
|
209
|
+
/** 是否压缩了图片/音频/视频消息 */
|
|
172
210
|
hasCompactedMedia: boolean;
|
|
173
|
-
/**
|
|
174
|
-
hasClearedSoftDeletedMessages: boolean;
|
|
175
|
-
/** 是否执行了总结消息操作(是否真的总结,请检查消息中是否出现`<summary>`标签) */
|
|
211
|
+
/** 是否总结了消息 */
|
|
176
212
|
hasSummarized: boolean;
|
|
177
|
-
/**
|
|
178
|
-
|
|
213
|
+
/** 是否丢弃了一些旧消息(最终兜底策略) */
|
|
214
|
+
hasDiscardMessages: boolean;
|
|
179
215
|
};
|
|
180
216
|
}
|
|
181
217
|
//#endregion
|
|
182
218
|
//#region src/utils/compact/strategy.d.ts
|
|
183
|
-
|
|
219
|
+
/**
|
|
220
|
+
* @returns 实际处理了几条消息
|
|
221
|
+
*/
|
|
222
|
+
declare const compactToolResults: (compressible: Message[], options: {
|
|
184
223
|
replacer?: Compact.ReplacerOfToolResultContent;
|
|
185
224
|
mark: string;
|
|
186
225
|
model?: Model;
|
|
187
|
-
}) => Promise<
|
|
188
|
-
|
|
226
|
+
}) => Promise<number>;
|
|
227
|
+
/**
|
|
228
|
+
* @returns 实际处理了几条消息
|
|
229
|
+
*/
|
|
230
|
+
declare const compactMediaMessages: (compressible: Message[], options: {
|
|
189
231
|
replacer?: Compact.ReplacerOfMediaContent;
|
|
190
|
-
mark: string;
|
|
191
232
|
model?: Model;
|
|
192
|
-
}) => Promise<
|
|
233
|
+
}) => Promise<number>;
|
|
234
|
+
declare const summarizeMessages: (compressible: Message[], options: Compact.SummarizeOptions) => Promise<number>;
|
|
193
235
|
/**
|
|
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被软删时,集合去重后只会删除一次。
|
|
236
|
+
* 最终的兜底压缩策略,从头删除旧消息,直到第二个回调函数返回true(达成目标)
|
|
202
237
|
*/
|
|
203
|
-
declare const
|
|
204
|
-
declare const summarizeMessages: (messages: Message[], options: Compact.SummarizeOptions) => Promise<void>;
|
|
205
|
-
declare const hardDeleteOldMessages: (messages: Message[]) => void;
|
|
238
|
+
declare const discardMessagesUntil: (compressible: Message[], until: (compressible: Message[]) => boolean) => number;
|
|
206
239
|
//#endregion
|
|
207
240
|
//#region src/utils/compact/index.d.ts
|
|
208
241
|
/**
|
|
209
242
|
* 自动优化上下文,类似AI Coding Agent的/compact命令
|
|
210
243
|
*/
|
|
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>) & {
|
|
244
|
+
declare const compact: ((messages: Message[], model: Model, options?: Compact.Options) => Promise<Compact.Result>) & {
|
|
245
|
+
compactToolResults: typeof compactToolResults;
|
|
246
|
+
compactMediaMessages: typeof compactMediaMessages;
|
|
266
247
|
summarizeMessages: typeof summarizeMessages;
|
|
267
|
-
|
|
268
|
-
softDeleteOldMediaMessages: typeof softDeleteOldMediaMessages;
|
|
269
|
-
hardDeleteSoftMessages: typeof hardDeleteSoftMessages;
|
|
270
|
-
hardDeleteOldMessages: typeof hardDeleteOldMessages;
|
|
248
|
+
discardMessagesUntil: typeof discardMessagesUntil;
|
|
271
249
|
};
|
|
272
250
|
//#endregion
|
|
273
251
|
//#region src/utils/helper.d.ts
|
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-
|
|
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-Dm0rwYe2.mjs";
|
|
2
2
|
export { compact, defineModel, defineTool, get_time_default as getTime, get_weather_default as getWeather, runAgent };
|
|
@@ -231,246 +231,201 @@ var get_weather_default = defineTool("get_weather", "查询指定城市的天气
|
|
|
231
231
|
//#endregion
|
|
232
232
|
//#region src/utils/compact/helper.ts
|
|
233
233
|
/**
|
|
234
|
-
*
|
|
234
|
+
* 校验assistant(tool_calls)消息
|
|
235
235
|
*/
|
|
236
|
-
const
|
|
236
|
+
const isToolCalls = (message) => {
|
|
237
|
+
return message?.role === "assistant" && Array.isArray(message.tool_calls);
|
|
238
|
+
};
|
|
239
|
+
/**
|
|
240
|
+
* 校验多模态消息
|
|
241
|
+
*/
|
|
242
|
+
const isMediaMessage = (message) => {
|
|
243
|
+
const MEDIA_TYPES = [
|
|
244
|
+
"image_url",
|
|
245
|
+
"input_audio",
|
|
246
|
+
"video_url"
|
|
247
|
+
];
|
|
248
|
+
return message && Array.isArray(message.content) && message.content.some((part) => MEDIA_TYPES.includes(part.type));
|
|
249
|
+
};
|
|
237
250
|
/**
|
|
238
|
-
* 查找
|
|
251
|
+
* 查找assistant(tool_calls) + tool配对组范围
|
|
239
252
|
* @param messages 消息数组
|
|
240
|
-
* @param toolIndex tool
|
|
241
|
-
* @returns
|
|
242
|
-
* 找不到配对组头时返回 null
|
|
243
|
-
* @remarks 配对组约定:组头是assistant(tool_calls),组内是紧随其后的连续tool消息。
|
|
244
|
-
* 删除或切分tool消息时都应整组处理,否则会留下孤立消息导致OpenAI API返回400。
|
|
245
|
-
* 组内任意一条tool消息都能定位到整组,供上层按整组范围批量操作
|
|
253
|
+
* @param toolIndex 组内任意tool消息下标
|
|
254
|
+
* @returns 下标数组[组头assistant(tool_calls), 组内最后一个tool),找不到组头时返回null
|
|
246
255
|
*/
|
|
247
256
|
const findToolGroupRange = (messages, toolIndex) => {
|
|
248
257
|
let groupStart = toolIndex;
|
|
249
258
|
while (groupStart > 0 && messages[groupStart - 1]?.role === "tool") groupStart--;
|
|
250
|
-
if (!
|
|
259
|
+
if (!isToolCalls(messages[groupStart - 1])) return null;
|
|
251
260
|
let groupEnd = groupStart;
|
|
252
261
|
while (groupEnd < messages.length && messages[groupEnd]?.role === "tool") groupEnd++;
|
|
253
262
|
return [groupStart - 1, groupEnd];
|
|
254
263
|
};
|
|
255
264
|
//#endregion
|
|
256
265
|
//#region src/utils/compact/strategy.ts
|
|
257
|
-
/**
|
|
266
|
+
/** 默认的压缩工具结果策略:让大模型精简消息内容 */
|
|
258
267
|
const defaultReplacerOfToolResultContent = async (content, options) => {
|
|
259
|
-
const {
|
|
268
|
+
const { model } = options ?? {};
|
|
260
269
|
const messages = [{
|
|
261
270
|
role: "user",
|
|
262
271
|
content
|
|
263
272
|
}, {
|
|
264
273
|
role: "user",
|
|
265
|
-
content: "
|
|
274
|
+
content: "请用一两句话简述上条消息"
|
|
266
275
|
}];
|
|
267
|
-
let simplifiedContent =
|
|
276
|
+
let simplifiedContent = "";
|
|
268
277
|
for await (const e of runAgent(model, messages, [])) if (e.type === "content_delta") simplifiedContent += e.delta;
|
|
278
|
+
else if (e.type === "error") throw new Error(e.message);
|
|
269
279
|
return simplifiedContent;
|
|
270
280
|
};
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
281
|
+
/**
|
|
282
|
+
* @returns 实际处理了几条消息
|
|
283
|
+
*/
|
|
284
|
+
const compactToolResults = async (compressible, options) => {
|
|
285
|
+
const { replacer, mark, model } = options ?? {};
|
|
286
|
+
if (!replacer && !model) return 0;
|
|
287
|
+
const _replacer = replacer || defaultReplacerOfToolResultContent;
|
|
288
|
+
let count = 0;
|
|
289
|
+
for (const message of compressible) if (message?.role === "tool" && typeof message.content === "string") {
|
|
276
290
|
if (message.content.startsWith(mark)) continue;
|
|
277
|
-
message.content = await
|
|
278
|
-
|
|
279
|
-
model
|
|
280
|
-
});
|
|
281
|
-
softDeleted.push(message);
|
|
291
|
+
message.content = mark + await _replacer(message.content, { model });
|
|
292
|
+
count++;
|
|
282
293
|
}
|
|
283
|
-
if (
|
|
284
|
-
return
|
|
294
|
+
if (count > 0) logger(`压缩了${count}条工具调用结果`);
|
|
295
|
+
return count;
|
|
285
296
|
};
|
|
286
|
-
/**
|
|
297
|
+
/** 默认的压缩多模态消息策略:让大模型精简消息内容 */
|
|
287
298
|
const defaultReplacerOfMediaContent = async (content, options) => {
|
|
288
|
-
const {
|
|
299
|
+
const { model } = options ?? {};
|
|
289
300
|
const messages = [{
|
|
290
301
|
role: "user",
|
|
291
302
|
content
|
|
292
303
|
}, {
|
|
293
304
|
role: "user",
|
|
294
|
-
content: "
|
|
305
|
+
content: "请用一两句话简述上方的多模态消息"
|
|
295
306
|
}];
|
|
296
|
-
let simplifiedContent =
|
|
307
|
+
let simplifiedContent = "";
|
|
297
308
|
for await (const e of runAgent(model, messages, [])) if (e.type === "content_delta") simplifiedContent += e.delta;
|
|
309
|
+
else if (e.type === "error") throw new Error(e.message);
|
|
298
310
|
return simplifiedContent;
|
|
299
311
|
};
|
|
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
312
|
/**
|
|
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被软删时,集合去重后只会删除一次。
|
|
313
|
+
* @returns 实际处理了几条消息
|
|
328
314
|
*/
|
|
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);
|
|
315
|
+
const compactMediaMessages = async (compressible, options) => {
|
|
316
|
+
const { replacer, model } = options ?? {};
|
|
317
|
+
if (!replacer && !model) return 0;
|
|
318
|
+
const _replacer = replacer || defaultReplacerOfMediaContent;
|
|
319
|
+
let count = 0;
|
|
320
|
+
for (const message of compressible) if (isMediaMessage(message)) {
|
|
321
|
+
const compacted = await _replacer(message.content, { model });
|
|
322
|
+
message.content = createXMLText("media", compacted);
|
|
323
|
+
count++;
|
|
341
324
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (deleteIndices.size > 0) logger(`清理了${deleteIndices.size}条软删除残留消息`);
|
|
325
|
+
if (count > 0) logger(`压缩了${count}条多模态消息`);
|
|
326
|
+
return count;
|
|
345
327
|
};
|
|
346
|
-
const summarizeMessages = async (
|
|
328
|
+
const summarizeMessages = async (compressible, options) => {
|
|
347
329
|
const { model, systemPrompt } = options ?? {};
|
|
348
|
-
|
|
330
|
+
const summarizable = compressible.slice(1);
|
|
331
|
+
if (summarizable.length === 0) {
|
|
349
332
|
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);
|
|
333
|
+
return 0;
|
|
370
334
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
summarizingMessages.push({
|
|
335
|
+
const count = summarizable.length;
|
|
336
|
+
summarizable.push({
|
|
376
337
|
role: "system",
|
|
377
338
|
content: systemPrompt
|
|
378
339
|
}, {
|
|
379
340
|
role: "user",
|
|
380
|
-
content: "
|
|
341
|
+
content: "开始总结上下文"
|
|
381
342
|
});
|
|
382
343
|
let summarized = "";
|
|
383
344
|
let usage;
|
|
384
|
-
for await (const e of runAgent(model,
|
|
345
|
+
for await (const e of runAgent(model, summarizable, [])) if (e.type === "content_delta") summarized += e.delta;
|
|
385
346
|
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, {
|
|
347
|
+
else if (e.type === "error") throw new Error(e.message);
|
|
348
|
+
compressible.splice(1, Infinity, {
|
|
393
349
|
role: "user",
|
|
394
350
|
content: createXMLText("summary", summarized)
|
|
395
351
|
});
|
|
352
|
+
logger(`总结了${count}条消息,消耗:`, usage);
|
|
353
|
+
return count;
|
|
396
354
|
};
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
355
|
+
/**
|
|
356
|
+
* 最终的兜底压缩策略,从头删除旧消息,直到第二个回调函数返回true(达成目标)
|
|
357
|
+
*/
|
|
358
|
+
const discardMessagesUntil = (compressible, until) => {
|
|
359
|
+
let count = 0;
|
|
360
|
+
while (compressible.length > 0 && !until(compressible)) {
|
|
361
|
+
let endIndex = Math.max(1, Math.floor(compressible.length / 10));
|
|
362
|
+
if (compressible[endIndex]?.role === "tool") {
|
|
363
|
+
const range = findToolGroupRange(compressible, endIndex);
|
|
364
|
+
if (range) endIndex = range[1];
|
|
365
|
+
else while (compressible[endIndex]?.role === "tool") endIndex++;
|
|
366
|
+
}
|
|
367
|
+
count += compressible.splice(0, endIndex).length;
|
|
402
368
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
logger(`硬删除了${deletedCount}条较早的消息`);
|
|
369
|
+
logger(`丢弃了${count}条旧消息`);
|
|
370
|
+
return count;
|
|
406
371
|
};
|
|
407
372
|
/**
|
|
408
373
|
* 自动优化上下文,类似AI Coding Agent的/compact命令
|
|
409
374
|
*/
|
|
410
375
|
const compact = Object.assign(async (messages, model, options) => {
|
|
411
|
-
const { usage, keepCount = 10,
|
|
376
|
+
const { usage, keepCount = 10, compactedMessageMark = "[已简化]", ratioToCompactToolResults = .6, replacerOfToolResultContent, ratioToCompactMedia = .7, replacerOfMediaContent, ratioToSummarize = .8, summarizeOptions } = options ?? {};
|
|
412
377
|
const context = model?.context ?? 131072;
|
|
413
378
|
const tokens = usage?.total_tokens ?? estimateTokens(messages);
|
|
414
379
|
const result = {
|
|
415
|
-
|
|
380
|
+
hasCompactedToolResults: false,
|
|
416
381
|
hasCompactedMedia: false,
|
|
417
|
-
hasClearedSoftDeletedMessages: false,
|
|
418
382
|
hasSummarized: false,
|
|
419
|
-
|
|
383
|
+
hasDiscardMessages: false
|
|
420
384
|
};
|
|
421
|
-
|
|
385
|
+
const startIndex = 1;
|
|
386
|
+
let endIndex = Math.max(startIndex, messages.length - keepCount);
|
|
422
387
|
if (messages[endIndex]?.role === "tool") {
|
|
423
388
|
const range = findToolGroupRange(messages, endIndex);
|
|
424
389
|
if (range) endIndex = range[1];
|
|
425
390
|
else while (messages[endIndex]?.role === "tool") endIndex++;
|
|
426
391
|
}
|
|
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
|
-
}
|
|
392
|
+
endIndex = Math.max(startIndex, endIndex);
|
|
393
|
+
const reservedStart = messages.slice(0, startIndex);
|
|
394
|
+
const compressible = messages.slice(startIndex, endIndex);
|
|
395
|
+
const reservedEnd = messages.slice(endIndex);
|
|
396
|
+
if (tokens > context * ratioToCompactToolResults) result.hasCompactedToolResults = await compactToolResults(compressible, {
|
|
397
|
+
replacer: replacerOfToolResultContent,
|
|
398
|
+
mark: compactedMessageMark,
|
|
399
|
+
model
|
|
400
|
+
}) > 0;
|
|
401
|
+
if (tokens > context * ratioToCompactMedia) result.hasCompactedMedia = await compactMediaMessages(compressible, {
|
|
402
|
+
replacer: replacerOfMediaContent,
|
|
403
|
+
model
|
|
404
|
+
}) > 0;
|
|
453
405
|
if (tokens > context * ratioToSummarize) {
|
|
454
406
|
const { systemPrompt = "你现在的任务是总结历史消息" } = summarizeOptions ?? {};
|
|
455
|
-
const [error] = await to(summarizeMessages(compressible, {
|
|
407
|
+
const [error, count] = await to(summarizeMessages(compressible, {
|
|
456
408
|
model,
|
|
457
409
|
systemPrompt
|
|
458
410
|
}));
|
|
459
|
-
result.hasSummarized =
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
411
|
+
if (!error) result.hasSummarized = count > 0;
|
|
412
|
+
else result.hasDiscardMessages = discardMessagesUntil(compressible, (compressible) => {
|
|
413
|
+
if (compressible.length === 0) return true;
|
|
414
|
+
const tempMessages = [
|
|
415
|
+
...reservedStart,
|
|
416
|
+
...compressible,
|
|
417
|
+
...reservedEnd
|
|
418
|
+
];
|
|
419
|
+
return estimateTokens(tempMessages) < context * ratioToSummarize;
|
|
420
|
+
}) > 0;
|
|
421
|
+
messages.splice(0, Infinity, ...reservedStart, ...compressible, ...reservedEnd);
|
|
466
422
|
}
|
|
467
423
|
return result;
|
|
468
424
|
}, {
|
|
425
|
+
compactToolResults,
|
|
426
|
+
compactMediaMessages,
|
|
469
427
|
summarizeMessages,
|
|
470
|
-
|
|
471
|
-
softDeleteOldMediaMessages,
|
|
472
|
-
hardDeleteSoftMessages,
|
|
473
|
-
hardDeleteOldMessages
|
|
428
|
+
discardMessagesUntil
|
|
474
429
|
});
|
|
475
430
|
//#endregion
|
|
476
431
|
export { defineTool as a, defineModel as i, get_weather_default as n, runAgent as o, get_time_default as r, 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.0",
|
|
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": {
|