@nickyzj2023/ai 1.1.3 → 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 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
 
@@ -46,10 +46,15 @@ for await (const e of runAgent(model, messages, tools)) {
46
46
  ### 在终端里使用
47
47
 
48
48
  ```bash
49
- # 配置deepseek环境变量、启动对话
50
- SET APIKEY=sk-xxx && ai
49
+ # 首次使用:交互式配置BASE_URL / APIKEY / MODEL / ...
50
+ ai setup
51
+
52
+ # 启动对话
53
+ ai
51
54
  ```
52
55
 
56
+ 配置保存在 `~/.@nickyzj2023/ai/config.json`,任意目录下执行`ai`都能读取到
57
+
53
58
  ## License
54
59
 
55
60
  ISC
package/dist/cli.mjs CHANGED
@@ -1,7 +1,85 @@
1
- import { i as defineModel, n as get_weather_default, o as runAgent, r as get_time_default } from "./src-B0tl6AT5.mjs";
2
- import { existsSync } from "node:fs";
3
- import { loadEnvFile } from "node:process";
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";
4
3
  import readline from "node:readline";
4
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { Client } from "@modelcontextprotocol/sdk/client";
8
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
9
+ //#region src/utils/config.ts
10
+ /**
11
+ * 获取全局配置文件路径(Windows/macOS/Linux通用)
12
+ * @returns (Windows下)C:/Users/Administrator/.@nickyzj2023/ai/config.json
13
+ */
14
+ const getConfigPath = () => {
15
+ return join(homedir(), ".@nickyzj2023", "ai", "config.json");
16
+ };
17
+ /**
18
+ * 读取配置文件
19
+ * @returns 正常返回Config,配置不存在或损坏时返回null
20
+ */
21
+ const loadConfig = () => {
22
+ try {
23
+ const config = JSON.parse(readFileSync(getConfigPath(), "utf8"));
24
+ if (typeof config.baseUrl !== "string" || typeof config.apiKey !== "string" || typeof config.model !== "string") return null;
25
+ return config;
26
+ } catch {
27
+ return null;
28
+ }
29
+ };
30
+ /**
31
+ * 将配置写回全局文件(自动创建目录)
32
+ * @remarks 写入失败会抛异常
33
+ */
34
+ const saveConfig = (config) => {
35
+ const configPath = getConfigPath();
36
+ mkdirSync(dirname(configPath), { recursive: true });
37
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
38
+ };
39
+ //#endregion
40
+ //#region src/interfaces/setup.ts
41
+ /**
42
+ * 临时创建readline接口,问答结束就关闭
43
+ * @param question 提问
44
+ * @returns 用户输入的回答
45
+ */
46
+ const ask = (question) => {
47
+ return new Promise((resolve) => {
48
+ const rl = readline.createInterface({
49
+ input: process.stdin,
50
+ output: process.stdout
51
+ });
52
+ rl.question(question, (answer) => {
53
+ rl.close();
54
+ resolve(answer);
55
+ });
56
+ });
57
+ };
58
+ /**
59
+ * setup入口:依次询问BASE_URL / MODEL / APIKEY,确认后写入全局配置
60
+ */
61
+ async function runSetup() {
62
+ const config = loadConfig();
63
+ if (config) {
64
+ console.log(`当前配置:BASE_URL ${config.baseUrl},APIKEY ${config.apiKey},MODEL ${config.model}`);
65
+ console.log("直接回车可沿用当前值。\n");
66
+ }
67
+ const baseUrl = await ask(`BASE_URL [当前为${config?.baseUrl}]: `) || config?.baseUrl;
68
+ const apiKey = await ask(`APIKEY [当前为${config?.apiKey}]: `) || config?.apiKey;
69
+ const model = await ask(`MODEL [当前为${config?.model}]: `) || config?.model;
70
+ try {
71
+ saveConfig({
72
+ baseUrl,
73
+ apiKey,
74
+ model
75
+ });
76
+ console.log(`配置已保存到${getConfigPath()},直接运行ai即可开始对话`);
77
+ } catch (e) {
78
+ console.error(`保存配置失败:${extractErrorMessage(e)}`);
79
+ process.exitCode = 1;
80
+ }
81
+ }
82
+ //#endregion
5
83
  //#region src/interfaces/tui.ts
6
84
  var TUI = class {
7
85
  rl = null;
@@ -86,47 +164,142 @@ var TUI = class {
86
164
  this.preparePrint("tool_result");
87
165
  process.stdout.write(this.colorize(`[工具结果:${name}] ${result}`, "90"));
88
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
+ }
89
174
  /** 打印轮次结束原因、token消耗 */
90
175
  printFinish(finishReason, usage) {
91
176
  this.preparePrint("done");
92
- 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"));
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"));
178
+ }
179
+ };
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;
93
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();
94
234
  };
95
235
  //#endregion
96
236
  //#region src/cli.ts
97
- if (existsSync(".env")) loadEnvFile(".env");
98
- const model = defineModel({
99
- baseUrl: process.env.BASE_URL ?? "https://api.deepseek.com/v1",
100
- apiKey: process.env.APIKEY,
101
- model: process.env.MODEL ?? "deepseek-v4-flash"
102
- });
103
- if (!model.apiKey) {
104
- console.error("请先在环境变量或.env文件中填入APIKEY(目前仅支持DeepSeek)");
105
- process.exit(1);
106
- }
107
- const messages = [];
108
- const tools = [get_weather_default, get_time_default];
109
- const tui = new TUI(async (input) => {
110
- messages.push({
111
- role: "user",
112
- content: input
237
+ /** 启动交互对话:配置来自全局配置文件,环境变量可临时覆盖 */
238
+ const startChat = async () => {
239
+ const config = loadConfig();
240
+ if (!config) {
241
+ console.error("请先运行 `ai setup` 配置一个模型");
242
+ process.exit(1);
243
+ }
244
+ const model = defineModel(config);
245
+ const messages = [];
246
+ const mcpTools = await loadMCPTools(config.mcpServers);
247
+ const tools = [
248
+ get_weather_default,
249
+ get_time_default,
250
+ ...mcpTools
251
+ ];
252
+ const tui = new TUI(async (input) => {
253
+ messages.push({
254
+ role: "user",
255
+ content: input
256
+ });
257
+ for await (const e of runAgent(model, messages, tools)) switch (e.type) {
258
+ case "reasoning_delta":
259
+ tui.printReasoning(e.delta);
260
+ break;
261
+ case "content_delta":
262
+ tui.printContent(e.delta);
263
+ break;
264
+ case "tool_call":
265
+ tui.printToolCall(e.name, e.args);
266
+ break;
267
+ case "tool_result":
268
+ tui.printToolResult(e.name, e.result);
269
+ break;
270
+ case "error":
271
+ tui.printContent(e.message);
272
+ break;
273
+ case "done": tui.printFinish(e.finishReason, e.usage);
274
+ }
113
275
  });
114
- for await (const e of runAgent(model, messages, tools)) switch (e.type) {
115
- case "reasoning_delta":
116
- tui.printReasoning(e.delta);
117
- break;
118
- case "content_delta":
119
- tui.printContent(e.delta);
120
- break;
121
- case "tool_call":
122
- tui.printToolCall(e.name, e.args);
123
- break;
124
- case "tool_result":
125
- tui.printToolResult(e.name, e.result);
126
- break;
127
- case "done": tui.printFinish(e.finishReason, e.usage);
128
- }
129
- });
130
- tui.start();
276
+ tui.start();
277
+ };
278
+ /** 打印命令用法 */
279
+ const printHelp = () => {
280
+ console.log(`用法: ai [命令]
281
+
282
+ 命令:
283
+ --help 显示帮助
284
+ setup 交互式配置模型APIKEY / BASE_URL / MODEL(保存到 ~/.@nickyzj2023/ai/config.json)
285
+
286
+ 不带命令则启动对话`);
287
+ };
288
+ const command = process.argv[2];
289
+ switch (command) {
290
+ case void 0:
291
+ startChat();
292
+ break;
293
+ case "setup":
294
+ await runSetup();
295
+ break;
296
+ case "--help":
297
+ case "-h":
298
+ printHelp();
299
+ break;
300
+ default:
301
+ console.error(`未知命令:${command}(可以运行ai --help查看用法)`);
302
+ process.exit(1);
303
+ }
131
304
  //#endregion
132
305
  export {};
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 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"];
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
- * compactMessages 的返回值,告知调用方各压缩动作是否执行
163
- * @remarks
164
- * 这些字段是"操作级"标志:为 true 只代表对应操作已执行,不代表一定产生了效果。
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
- hasDeletedOldMessages: boolean;
213
+ /** 是否丢弃了一些旧消息(最终兜底策略) */
214
+ hasDiscardMessages: boolean;
179
215
  };
180
216
  }
181
217
  //#endregion
182
218
  //#region src/utils/compact/strategy.d.ts
183
- declare const softDeleteToolResults: (messages: Message[], options: {
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<Message[]>;
188
- declare const softDeleteOldMediaMessages: (messages: Message[], options: {
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<Message[]>;
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 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;
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
- /** 提供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>) & {
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
- softDeleteToolResults: typeof softDeleteToolResults;
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-B0tl6AT5.mjs";
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 };
@@ -130,7 +130,7 @@ async function* runAgent(model, messages, tools) {
130
130
  if (!tool) result = `不存在工具“${name}”`;
131
131
  else {
132
132
  const [error, response] = await to(tool.execute(JSON.parse(args)));
133
- result = error ? `工具“${name}”执行出错:${error.message}` : String(response);
133
+ result = error ? `工具“${name}”执行出错:${error.message}` : JSON.stringify(response);
134
134
  }
135
135
  messages.push({
136
136
  role: "tool",
@@ -212,27 +212,12 @@ const estimateTokens = (messages) => {
212
212
  };
213
213
  //#endregion
214
214
  //#region src/tools/get-time.ts
215
- const weekdayNames = {
216
- Sunday: "星期日",
217
- Monday: "星期一",
218
- Tuesday: "星期二",
219
- Wednesday: "星期三",
220
- Thursday: "星期四",
221
- Friday: "星期五",
222
- Saturday: "星期六"
223
- };
224
215
  var get_time_default = defineTool("get_time", "查询指定时区的当前时间", { timezone: {
225
216
  type: "string",
226
217
  description: "完整的IANA时区名称,如Europe/Amsterdam。用户未明确提及时,传入对方语言对应的时区,例如对方使用中文时可传入Asia/Shanghai。",
227
218
  required: true
228
219
  } }, async ({ timezone }) => {
229
- const data = await fetcher("https://timeapi.io/api", { params: { timezone } }).get("/time/current/zone");
230
- const pad = (n) => String(n).padStart(2, "0");
231
- return [
232
- `时区:${data.timeZone}${data.dstActive ? "(夏令时已生效)" : ""}`,
233
- `日期:${data.year}-${pad(data.month)}-${pad(data.day)}(${weekdayNames[data.dayOfWeek] ?? data.dayOfWeek})`,
234
- `时间:${pad(data.hour)}:${pad(data.minute)}:${pad(data.seconds)}`
235
- ].join("\n");
220
+ return fetcher("https://timeapi.io/api", { params: { timezone } }).get("/time/current/zone");
236
221
  });
237
222
  //#endregion
238
223
  //#region src/tools/get-weather.ts
@@ -241,260 +226,206 @@ var get_weather_default = defineTool("get_weather", "查询指定城市的天气
241
226
  description: "城市名,如shanghai、tokyo",
242
227
  required: true
243
228
  } }, async ({ city }) => {
244
- const data = await fetcher("https://wttr.in", { params: { format: "j1" } }).get(`/${city}`);
245
- const nearestArea = data.nearest_area[0];
246
- const currentCondition = data.current_condition[0];
247
- return [
248
- `位置:${nearestArea.country[0].value}-${nearestArea.region[0].value}-${nearestArea.areaName[0].value}`,
249
- `当前温度: ${currentCondition.temp_C}°C`,
250
- `体感温度: ${currentCondition.FeelsLikeC}°C`,
251
- `天气状况: ${currentCondition.weatherDesc[0].value}`,
252
- `湿度: ${currentCondition.humidity}%`
253
- ].join("\n");
229
+ return fetcher("https://wttr.in", { params: { format: "j1" } }).get(`/${city}`);
254
230
  });
255
231
  //#endregion
256
232
  //#region src/utils/compact/helper.ts
257
233
  /**
258
- * 判断消息是否为带有工具调用的 assistant 消息
234
+ * 校验assistant(tool_calls)消息
235
+ */
236
+ const isToolCalls = (message) => {
237
+ return message?.role === "assistant" && Array.isArray(message.tool_calls);
238
+ };
239
+ /**
240
+ * 校验多模态消息
259
241
  */
260
- const hasToolCalls = (message) => message?.role === "assistant" && Array.isArray(message.tool_calls);
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
+ };
261
250
  /**
262
- * 查找tool消息所属的 assistant(tool_calls) + tool 配对组范围
251
+ * 查找assistant(tool_calls) + tool配对组范围
263
252
  * @param messages 消息数组
264
- * @param toolIndex tool消息的索引
265
- * @returns 配对组范围 [startIndex, endIndex)(含组头assistant与其后连续的全部tool消息);
266
- * 找不到配对组头时返回 null
267
- * @remarks 配对组约定:组头是assistant(tool_calls),组内是紧随其后的连续tool消息。
268
- * 删除或切分tool消息时都应整组处理,否则会留下孤立消息导致OpenAI API返回400。
269
- * 组内任意一条tool消息都能定位到整组,供上层按整组范围批量操作
253
+ * @param toolIndex 组内任意tool消息下标
254
+ * @returns 下标数组[组头assistant(tool_calls), 组内最后一个tool),找不到组头时返回null
270
255
  */
271
256
  const findToolGroupRange = (messages, toolIndex) => {
272
257
  let groupStart = toolIndex;
273
258
  while (groupStart > 0 && messages[groupStart - 1]?.role === "tool") groupStart--;
274
- if (!hasToolCalls(messages[groupStart - 1])) return null;
259
+ if (!isToolCalls(messages[groupStart - 1])) return null;
275
260
  let groupEnd = groupStart;
276
261
  while (groupEnd < messages.length && messages[groupEnd]?.role === "tool") groupEnd++;
277
262
  return [groupStart - 1, groupEnd];
278
263
  };
279
264
  //#endregion
280
265
  //#region src/utils/compact/strategy.ts
281
- /** 默认的软删除多模态消息策略:让大模型精简消息内容 */
266
+ /** 默认的压缩工具结果策略:让大模型精简消息内容 */
282
267
  const defaultReplacerOfToolResultContent = async (content, options) => {
283
- const { mark, model } = options ?? {};
268
+ const { model } = options ?? {};
284
269
  const messages = [{
285
270
  role: "user",
286
271
  content
287
272
  }, {
288
273
  role: "user",
289
- content: "请用一两句话对上条消息做个“省流”"
274
+ content: "请用一两句话简述上条消息"
290
275
  }];
291
- let simplifiedContent = mark;
276
+ let simplifiedContent = "";
292
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);
293
279
  return simplifiedContent;
294
280
  };
295
- const softDeleteToolResults = async (messages, options) => {
296
- const { replacer = defaultReplacerOfToolResultContent, mark, model } = options ?? {};
297
- if (!replacer && !model) return [];
298
- const softDeleted = [];
299
- for (const message of messages) if (message?.role === "tool" && typeof message.content === "string") {
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") {
300
290
  if (message.content.startsWith(mark)) continue;
301
- message.content = await replacer(message.content, {
302
- mark,
303
- model
304
- });
305
- softDeleted.push(message);
291
+ message.content = mark + await _replacer(message.content, { model });
292
+ count++;
306
293
  }
307
- if (softDeleted.length > 0) logger(`软删除了${softDeleted.length}条工具调用结果消息`);
308
- return softDeleted;
294
+ if (count > 0) logger(`压缩了${count}条工具调用结果`);
295
+ return count;
309
296
  };
310
- /** 默认的软删除多模态消息策略:让大模型精简消息内容 */
297
+ /** 默认的压缩多模态消息策略:让大模型精简消息内容 */
311
298
  const defaultReplacerOfMediaContent = async (content, options) => {
312
- const { mark, model } = options ?? {};
299
+ const { model } = options ?? {};
313
300
  const messages = [{
314
301
  role: "user",
315
302
  content
316
303
  }, {
317
304
  role: "user",
318
- content: "请用一两句话描述上方的多模态消息"
305
+ content: "请用一两句话简述上方的多模态消息"
319
306
  }];
320
- let simplifiedContent = mark;
307
+ let simplifiedContent = "";
321
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);
322
310
  return simplifiedContent;
323
311
  };
324
- const softDeleteOldMediaMessages = async (messages, options) => {
325
- const { replacer = defaultReplacerOfMediaContent, mark, model } = options ?? {};
326
- if (!replacer && !model) return [];
327
- const mediaTypes = [
328
- "image_url",
329
- "input_audio",
330
- "video_url"
331
- ];
332
- const softDeleted = [];
333
- for (const message of messages) if (message && Array.isArray(message.content) && message.content.some((part) => mediaTypes.includes(part.type))) {
334
- message.content = await replacer(message.content, {
335
- mark,
336
- model
337
- });
338
- softDeleted.push(message);
339
- }
340
- if (softDeleted.length > 0) logger(`软删除了${softDeleted.length}条旧图片/音频/视频消息`);
341
- return softDeleted;
342
- };
343
312
  /**
344
- * 清理软删除后残留的占位消息(信息在软删除时已丢失,此时删除不损失任何额外信息)
345
- * @param messages 可压缩区消息数组,直接原地删除
346
- * @param softDeleted 本轮被软删过的消息引用集合
347
- * @remarks
348
- * tool消息不能单独删除:OpenAI API要求assistant(tool_calls)与tool消息按tool_call_id配对,
349
- * 单独删掉tool会让assistant(tool_calls)变成孤立消息,后续请求返回400。
350
- * 因此遇到被软删的tool消息时,必须把整个assistant(tool_calls) + tool配对组一起删除。
351
- * 组内多条tool被软删时,集合去重后只会删除一次。
313
+ * @returns 实际处理了几条消息
352
314
  */
353
- const hardDeleteSoftMessages = (messages, softDeleted) => {
354
- const deleteIndices = /* @__PURE__ */ new Set();
355
- for (let i = 0; i < messages.length; i++) {
356
- const message = messages[i];
357
- if (!message || !softDeleted.has(message)) continue;
358
- if (message.role === "tool") {
359
- const range = findToolGroupRange(messages, i);
360
- if (range) {
361
- const [start, end] = range;
362
- for (let k = start; k < end; k++) deleteIndices.add(k);
363
- } else deleteIndices.add(i);
364
- } 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++;
365
324
  }
366
- const sortedIndices = [...deleteIndices].sort((a, b) => b - a);
367
- for (const index of sortedIndices) if (index !== void 0) messages.splice(index, 1);
368
- if (deleteIndices.size > 0) logger(`清理了${deleteIndices.size}条软删除残留消息`);
325
+ if (count > 0) logger(`压缩了${count}条多模态消息`);
326
+ return count;
369
327
  };
370
- const summarizeMessages = async (messages, options) => {
328
+ const summarizeMessages = async (compressible, options) => {
371
329
  const { model, systemPrompt } = options ?? {};
372
- if (messages.length === 0) {
330
+ const summarizable = compressible.slice(1);
331
+ if (summarizable.length === 0) {
373
332
  logger("消息太少,无需总结");
374
- return;
375
- }
376
- const summarizableIndices = [];
377
- const summarizingMessages = [];
378
- for (let i = 0; i < messages.length; i++) {
379
- const message = messages[i];
380
- if (!message) continue;
381
- if (i === 0 && message.role === "system") continue;
382
- if (hasToolCalls(message)) {
383
- let j = i + 1;
384
- while (j < messages.length && messages[j]?.role === "tool") j++;
385
- const group = messages.slice(i, j);
386
- for (let k = i; k < j; k++) summarizableIndices.push(k);
387
- summarizingMessages.push(...group);
388
- i = j - 1;
389
- continue;
390
- }
391
- if (message.role === "tool") continue;
392
- summarizableIndices.push(i);
393
- summarizingMessages.push(message);
333
+ return 0;
394
334
  }
395
- if (summarizableIndices.length === 0) {
396
- logger("没有可总结的消息");
397
- return;
398
- }
399
- summarizingMessages.push({
335
+ const count = summarizable.length;
336
+ summarizable.push({
400
337
  role: "system",
401
338
  content: systemPrompt
402
339
  }, {
403
340
  role: "user",
404
- content: "开始总结"
341
+ content: "开始总结上下文"
405
342
  });
406
343
  let summarized = "";
407
344
  let usage;
408
- for await (const e of runAgent(model, summarizingMessages, [])) if (e.type === "content_delta") summarized += e.delta;
345
+ for await (const e of runAgent(model, summarizable, [])) if (e.type === "content_delta") summarized += e.delta;
409
346
  else if (e.type === "done") usage = e.usage;
410
- logger(`总结了${summarizingMessages.length}条消息,消耗:`, usage);
411
- const firstIndex = summarizableIndices[0] ?? 0;
412
- for (let i = summarizableIndices.length - 1; i >= 0; i--) {
413
- const index = summarizableIndices[i];
414
- if (index !== void 0) messages.splice(index, 1);
415
- }
416
- messages.splice(firstIndex, 0, {
347
+ else if (e.type === "error") throw new Error(e.message);
348
+ compressible.splice(1, Infinity, {
417
349
  role: "user",
418
350
  content: createXMLText("summary", summarized)
419
351
  });
352
+ logger(`总结了${count}条消息,消耗:`, usage);
353
+ return count;
420
354
  };
421
- const hardDeleteOldMessages = (messages) => {
422
- const startIndex = messages.findIndex((message) => message.role === "user");
423
- if (startIndex < 0) {
424
- logger("消息太少,无需硬删除");
425
- return;
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;
426
368
  }
427
- const deletedCount = messages.length - startIndex;
428
- messages.splice(startIndex, deletedCount);
429
- logger(`硬删除了${deletedCount}条较早的消息`);
369
+ logger(`丢弃了${count}条旧消息`);
370
+ return count;
430
371
  };
431
372
  /**
432
373
  * 自动优化上下文,类似AI Coding Agent的/compact命令
433
374
  */
434
375
  const compact = Object.assign(async (messages, model, options) => {
435
- const { usage, keepCount = 10, ratioToCompactToolResult = .5, replacerOfToolResultContent, ratioToCompactMedia = .6, replacerOfMediaContent, ratioToClearSoftDeletedMessages = .7, softDeletedMessageMark = "[已简化]", ratioToSummarize = .8, summarizeOptions } = options ?? {};
376
+ const { usage, keepCount = 10, compactedMessageMark = "[已简化]", ratioToCompactToolResults = .6, replacerOfToolResultContent, ratioToCompactMedia = .7, replacerOfMediaContent, ratioToSummarize = .8, summarizeOptions } = options ?? {};
436
377
  const context = model?.context ?? 131072;
437
378
  const tokens = usage?.total_tokens ?? estimateTokens(messages);
438
379
  const result = {
439
- hasCompactedToolResult: false,
380
+ hasCompactedToolResults: false,
440
381
  hasCompactedMedia: false,
441
- hasClearedSoftDeletedMessages: false,
442
382
  hasSummarized: false,
443
- hasDeletedOldMessages: false
383
+ hasDiscardMessages: false
444
384
  };
445
- let endIndex = Math.max(0, messages.length - keepCount);
385
+ const startIndex = 1;
386
+ let endIndex = Math.max(startIndex, messages.length - keepCount);
446
387
  if (messages[endIndex]?.role === "tool") {
447
388
  const range = findToolGroupRange(messages, endIndex);
448
389
  if (range) endIndex = range[1];
449
390
  else while (messages[endIndex]?.role === "tool") endIndex++;
450
391
  }
451
- const compressible = messages.slice(0, endIndex);
452
- const reserved = messages.slice(endIndex);
453
- const softDeleted = /* @__PURE__ */ new Set();
454
- if (tokens > context * ratioToCompactToolResult) {
455
- const softDeletedMessages = await softDeleteToolResults(compressible, {
456
- replacer: replacerOfToolResultContent,
457
- mark: softDeletedMessageMark,
458
- model
459
- });
460
- for (const message of softDeletedMessages) softDeleted.add(message);
461
- result.hasCompactedToolResult = true;
462
- }
463
- if (tokens > context * ratioToCompactMedia) {
464
- const softDeletedMessages = await softDeleteOldMediaMessages(compressible, {
465
- replacer: replacerOfMediaContent,
466
- mark: softDeletedMessageMark,
467
- model
468
- });
469
- for (const message of softDeletedMessages) softDeleted.add(message);
470
- result.hasCompactedMedia = true;
471
- }
472
- if (tokens > context * ratioToClearSoftDeletedMessages) {
473
- hardDeleteSoftMessages(compressible, softDeleted);
474
- result.hasClearedSoftDeletedMessages = true;
475
- messages.splice(0, messages.length, ...compressible, ...reserved);
476
- }
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;
477
405
  if (tokens > context * ratioToSummarize) {
478
406
  const { systemPrompt = "你现在的任务是总结历史消息" } = summarizeOptions ?? {};
479
- const [error] = await to(summarizeMessages(compressible, {
407
+ const [error, count] = await to(summarizeMessages(compressible, {
480
408
  model,
481
409
  systemPrompt
482
410
  }));
483
- result.hasSummarized = true;
484
- if (error) {
485
- logger(`总结失败(${error.message}),改用硬删除兜底`);
486
- hardDeleteOldMessages(compressible);
487
- result.hasDeletedOldMessages = true;
488
- }
489
- messages.splice(0, messages.length, ...compressible, ...reserved);
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);
490
422
  }
491
423
  return result;
492
424
  }, {
425
+ compactToolResults,
426
+ compactMediaMessages,
493
427
  summarizeMessages,
494
- softDeleteToolResults,
495
- softDeleteOldMediaMessages,
496
- hardDeleteSoftMessages,
497
- hardDeleteOldMessages
428
+ discardMessagesUntil
498
429
  });
499
430
  //#endregion
500
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.1.3",
3
+ "version": "1.3.0",
4
4
  "description": "我的“pi”,参考了pi-from-scratch",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -26,12 +26,13 @@
26
26
  "access": "public"
27
27
  },
28
28
  "devDependencies": {
29
- "@biomejs/biome": "^2.5.11",
30
- "@types/node": "^26.4.0",
29
+ "@biomejs/biome": "^2.5.12",
30
+ "@types/node": "^26.4.1",
31
31
  "tsdown": "^0.22.14",
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": {