@nickyzj2023/ai 1.0.0 → 1.1.2
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/dist/cli.mjs +91 -26
- package/dist/index.d.mts +116 -53
- package/dist/index.mjs +2 -2
- package/dist/src-DzLCCWBV.mjs +451 -0
- package/package.json +4 -14
- package/dist/cli.mjs.map +0 -1
- package/dist/tui-Dz0id9sE.mjs +0 -300
- package/dist/tui-Dz0id9sE.mjs.map +0 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,30 +1,97 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { fetcher } from "@nickyzj2023/utils";
|
|
1
|
+
import { i as defineModel, n as get_weather_default, o as runAgent, r as get_time_default } from "./src-DzLCCWBV.mjs";
|
|
3
2
|
import { existsSync } from "node:fs";
|
|
4
3
|
import { loadEnvFile } from "node:process";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
import readline from "node:readline";
|
|
5
|
+
//#region src/interfaces/tui.ts
|
|
6
|
+
var TUI = class {
|
|
7
|
+
rl = null;
|
|
8
|
+
onPrompt = null;
|
|
9
|
+
/** 是否允许输入 */
|
|
10
|
+
isBusy = false;
|
|
11
|
+
/** 上次打印内容所属的状态(reasoning/content/tool)
|
|
12
|
+
* 用于在新的状态开始时改变样式、打印前缀
|
|
13
|
+
*/
|
|
14
|
+
prevPrintType = void 0;
|
|
15
|
+
/**
|
|
16
|
+
* 实例化TUI时,接收一个“发出用户提示词”的回调函数
|
|
17
|
+
* UI只做UI的事,提示词发给谁让调用方决定
|
|
18
|
+
*/
|
|
19
|
+
constructor(onPrompt) {
|
|
20
|
+
this.onPrompt = onPrompt;
|
|
21
|
+
}
|
|
22
|
+
/** 启动TUI */
|
|
23
|
+
start() {
|
|
24
|
+
this.rl = readline.createInterface({
|
|
25
|
+
input: process.stdin,
|
|
26
|
+
output: process.stdout
|
|
27
|
+
});
|
|
28
|
+
this.prompting();
|
|
29
|
+
this.rl.on("close", () => {
|
|
30
|
+
this.rl?.close();
|
|
31
|
+
this.rl = null;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/** 监听用户输入 */
|
|
35
|
+
prompting() {
|
|
36
|
+
if (this.isBusy) return;
|
|
37
|
+
this.rl?.question("> ", async (answer) => {
|
|
38
|
+
const input = answer.trim();
|
|
39
|
+
if (!input) {
|
|
40
|
+
this.prompting();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this.isBusy = true;
|
|
44
|
+
await this.onPrompt?.(input);
|
|
45
|
+
this.isBusy = false;
|
|
46
|
+
this.prompting();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 所有print方法调用前的统一入口(相当于“父类逻辑”):
|
|
51
|
+
* 状态切换时打印换行,并记录当前状态。
|
|
52
|
+
* @returns 是否发生了状态切换
|
|
53
|
+
*/
|
|
54
|
+
preparePrint(type) {
|
|
55
|
+
if (this.prevPrintType === type) return false;
|
|
56
|
+
process.stdout.write("\n");
|
|
57
|
+
this.prevPrintType = type;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 为文本添加ANSI颜色;非TTY输出(重定向/管道)时返回原文本,避免日志出现转义码。
|
|
62
|
+
* @param text 原始文本
|
|
63
|
+
* @param ansiCode ANSI颜色代码,如 "90"(亮黑,大多数终端显示为灰色)
|
|
64
|
+
*/
|
|
65
|
+
colorize(text, ansiCode) {
|
|
66
|
+
if (!process.stdout.isTTY) return text;
|
|
67
|
+
return `\x1b[${ansiCode}m${text}\x1b[0m`;
|
|
68
|
+
}
|
|
69
|
+
/** 流式打印AI思考内容(灰色) */
|
|
70
|
+
printReasoning(delta) {
|
|
71
|
+
if (this.preparePrint("reasoning_delta")) delta = `[思考内容] ${delta.replaceAll("\n", "")}`;
|
|
72
|
+
process.stdout.write(this.colorize(delta, "90"));
|
|
73
|
+
}
|
|
74
|
+
/** 流式打印AI回复内容 */
|
|
75
|
+
printContent(delta) {
|
|
76
|
+
this.preparePrint("content_delta");
|
|
77
|
+
process.stdout.write(delta);
|
|
78
|
+
}
|
|
79
|
+
/** 打印工具调用 */
|
|
80
|
+
printToolCall(name, args) {
|
|
81
|
+
this.preparePrint("tool_call");
|
|
82
|
+
process.stdout.write(`[工具调用:${name}] ${args}`);
|
|
83
|
+
}
|
|
84
|
+
/** 打印工具结果 */
|
|
85
|
+
printToolResult(name, result) {
|
|
86
|
+
this.preparePrint("tool_result");
|
|
87
|
+
process.stdout.write(this.colorize(`[工具结果:${name}] ${result}`, "90"));
|
|
88
|
+
}
|
|
89
|
+
/** 打印轮次结束原因、token消耗 */
|
|
90
|
+
printFinish(finishReason, usage) {
|
|
91
|
+
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"));
|
|
93
|
+
}
|
|
14
94
|
};
|
|
15
|
-
var get_time_default = defineTool("get_time", "查询指定时区的当前时间", { timezone: {
|
|
16
|
-
type: "string",
|
|
17
|
-
description: "完整的IANA时区名称,如Europe/Amsterdam。用户未明确提及时,传入对方语言对应的时区,例如对方使用中文时可传入Asia/Shanghai。",
|
|
18
|
-
required: true
|
|
19
|
-
} }, async ({ timezone }) => {
|
|
20
|
-
const data = await fetcher("https://timeapi.io/api", { params: { timezone } }).get("/time/current/zone");
|
|
21
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
22
|
-
return [
|
|
23
|
-
`时区:${data.timeZone}${data.dstActive ? "(夏令时已生效)" : ""}`,
|
|
24
|
-
`日期:${data.year}-${pad(data.month)}-${pad(data.day)}(${weekdayNames[data.dayOfWeek] ?? data.dayOfWeek})`,
|
|
25
|
-
`时间:${pad(data.hour)}:${pad(data.minute)}:${pad(data.seconds)}`
|
|
26
|
-
].join("\n");
|
|
27
|
-
});
|
|
28
95
|
//#endregion
|
|
29
96
|
//#region src/cli.ts
|
|
30
97
|
if (existsSync(".env")) loadEnvFile(".env");
|
|
@@ -63,5 +130,3 @@ const tui = new TUI(async (input) => {
|
|
|
63
130
|
tui.start();
|
|
64
131
|
//#endregion
|
|
65
132
|
export {};
|
|
66
|
-
|
|
67
|
-
//# sourceMappingURL=cli.mjs.map
|
package/dist/index.d.mts
CHANGED
|
@@ -142,69 +142,132 @@ type AgentEvent = LLMEvent | {
|
|
|
142
142
|
//#region src/agent.d.ts
|
|
143
143
|
declare function runAgent(model: Model, messages: Message[], tools: ToolDefinition[]): AsyncGenerator<AgentEvent>;
|
|
144
144
|
//#endregion
|
|
145
|
-
//#region src/
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
145
|
+
//#region src/tools/get-time.d.ts
|
|
146
|
+
declare const _default: ToolDefinition;
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/tools/get-weather.d.ts
|
|
149
|
+
declare const _default$1: ToolDefinition;
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/utils/compact/types.d.ts
|
|
152
|
+
declare namespace Compact {
|
|
153
|
+
type ReplacerOfToolResultContent = (content: Message["content"]) => Message["content"];
|
|
154
|
+
type ReplacerOfMediaContent = (content: Message["content"]) => Message["content"];
|
|
155
|
+
type SummarizeOptions = {
|
|
156
|
+
/** 用什么模型总结 */
|
|
157
|
+
model: Model;
|
|
158
|
+
/** 用于指导大模型如何总结消息的提示词 */
|
|
159
|
+
systemPrompt: string;
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* compactMessages 的返回值,告知调用方各压缩动作是否执行
|
|
163
|
+
* @remarks
|
|
164
|
+
* 这些字段是"操作级"标志:为 true 只代表对应操作已执行,不代表一定产生了效果。
|
|
165
|
+
* 例如 hasSummarized 为 true 只代表进入了总结流程,是否真的总结成功,
|
|
166
|
+
* 应由调用方检查消息数组里是否出现含 `<summary>` 标签的消息来判断。
|
|
167
|
+
*/
|
|
168
|
+
type CompactResult = {
|
|
169
|
+
/** 是否执行了压缩工具调用结果 */
|
|
170
|
+
hasCompactedToolResult: boolean;
|
|
171
|
+
/** 是否执行了压缩图片/音频/视频消息 */
|
|
172
|
+
hasCompactedMedia: boolean;
|
|
173
|
+
/** 是否执行了清理软删除残留的占位消息 */
|
|
174
|
+
hasClearedSoftDeletedMessages: boolean;
|
|
175
|
+
/** 是否执行了总结消息操作(是否真的总结,请检查消息中是否出现`<summary>`标签) */
|
|
176
|
+
hasSummarized: boolean;
|
|
177
|
+
/** 是否执行了兜底硬删除较早消息 */
|
|
178
|
+
hasDeletedOldMessages: boolean;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/utils/compact/strategy.d.ts
|
|
183
|
+
declare const softDeleteToolResults: (messages: Message[], replacer: Compact.ReplacerOfToolResultContent) => Message[];
|
|
184
|
+
declare const softDeleteOldMediaMessages: (messages: Message[], replacer: Compact.ReplacerOfMediaContent) => Message[];
|
|
151
185
|
/**
|
|
152
|
-
*
|
|
153
|
-
* @param
|
|
186
|
+
* 清理软删除后残留的占位消息(信息在软删除时已丢失,此时删除不损失任何额外信息)
|
|
187
|
+
* @param messages 可压缩区消息数组,直接原地删除
|
|
188
|
+
* @param softDeleted 本轮被软删过的消息引用集合
|
|
189
|
+
* @remarks
|
|
190
|
+
* tool消息不能单独删除:OpenAI API要求assistant(tool_calls)与tool消息按tool_call_id配对,
|
|
191
|
+
* 单独删掉tool会让assistant(tool_calls)变成孤立消息,后续请求返回400。
|
|
192
|
+
* 因此遇到被软删的tool消息时,必须把整个assistant(tool_calls) + tool配对组一起删除。
|
|
193
|
+
* 组内多条tool被软删时,集合去重后只会删除一次。
|
|
154
194
|
*/
|
|
155
|
-
declare const
|
|
195
|
+
declare const hardDeleteSoftMessages: (messages: Message[], softDeleted: ReadonlySet<Message>) => void;
|
|
196
|
+
declare const summarizeMessages: (messages: Message[], options: Compact.SummarizeOptions) => Promise<void>;
|
|
197
|
+
declare const hardDeleteOldMessages: (messages: Message[]) => void;
|
|
156
198
|
//#endregion
|
|
157
|
-
//#region src/
|
|
199
|
+
//#region src/utils/compact/index.d.ts
|
|
158
200
|
/**
|
|
159
|
-
*
|
|
201
|
+
* 自动优化上下文,类似AI Coding Agent的/compact命令
|
|
160
202
|
*/
|
|
161
|
-
declare
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
private rl;
|
|
169
|
-
private onPrompt;
|
|
170
|
-
/** 是否允许输入 */
|
|
171
|
-
private isBusy;
|
|
172
|
-
/** 上次打印内容所属的状态(reasoning/content/tool)
|
|
173
|
-
* 用于在新的状态开始时改变样式、打印前缀
|
|
203
|
+
declare const compact: ((messages: Message[], model: Model, options?: {
|
|
204
|
+
/** 提供token消耗情况时,能更准确地判断上下文是否达到阈值 */
|
|
205
|
+
usage?: Usage;
|
|
206
|
+
/**
|
|
207
|
+
* 各种压缩方式统一保留的最近消息条数
|
|
208
|
+
* @default 10
|
|
209
|
+
* @remarks 压缩工具调用结果、压缩媒体消息、总结消息、硬删除兜底都会保留最近keepCount条消息不处理
|
|
174
210
|
*/
|
|
175
|
-
|
|
211
|
+
keepCount?: number;
|
|
176
212
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
213
|
+
* 上下文>总上下文*ratio时压缩工具调用结果
|
|
214
|
+
* @default 0.5
|
|
179
215
|
*/
|
|
180
|
-
|
|
181
|
-
/** 启动TUI */
|
|
182
|
-
start(): void;
|
|
183
|
-
/** 监听用户输入 */
|
|
184
|
-
private prompting;
|
|
216
|
+
ratioToCompactToolResult?: number;
|
|
185
217
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* @returns 是否发生了状态切换
|
|
218
|
+
* 如何压缩工具调用结果,例如让其他模型返回精简后的工具结果
|
|
219
|
+
* @default (content) => "(已被消费)"
|
|
189
220
|
*/
|
|
190
|
-
|
|
221
|
+
replacerOfToolResultContent?: Compact.ReplacerOfToolResultContent;
|
|
191
222
|
/**
|
|
192
|
-
*
|
|
193
|
-
* @
|
|
194
|
-
* @param ansiCode ANSI颜色代码,如 "90"(亮黑,大多数终端显示为灰色)
|
|
223
|
+
* 上下文>总上下文*ratio时压缩图片/音频/视频消息
|
|
224
|
+
* @default 0.6
|
|
195
225
|
*/
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
226
|
+
ratioToCompactMedia?: number;
|
|
227
|
+
/**
|
|
228
|
+
* 如何压缩媒体消息,例如让其他模型用自然语言简短描述一遍
|
|
229
|
+
* @default (content) => "(已被丢弃)"
|
|
230
|
+
*/
|
|
231
|
+
replacerOfMediaContent?: Compact.ReplacerOfMediaContent;
|
|
232
|
+
/**
|
|
233
|
+
* 上下文>总上下文*ratio时清理软删除残留的占位消息
|
|
234
|
+
* @default 0.7
|
|
235
|
+
* @remarks
|
|
236
|
+
* 软删除(ratioToCompactToolResult/ratioToCompactMedia)只替换content不删消息,
|
|
237
|
+
* 该选项负责把残留的占位消息真正移除。信息在软删除时已丢失,清理不损失任何额外信息。
|
|
238
|
+
* 阈值应介于ratioToCompactMedia与ratioToSummarize之间:
|
|
239
|
+
* 太早则媒体软删还没执行、无残留可清;太晚则总结/硬删除兜底已处理整个压缩区,清理失去意义
|
|
240
|
+
*/
|
|
241
|
+
ratioToClearSoftDeletedMessages?: number;
|
|
242
|
+
/**
|
|
243
|
+
* 上下文>总上下文*ratio时总结消息
|
|
244
|
+
* @default 0.8
|
|
245
|
+
* @remarks 如果总结成功,会把keepCount(默认10,见顶层选项)以前的消息压成一条消息;如果总结失败,会采取兜底压缩方法:硬删除keepCount以前的消息
|
|
246
|
+
*/
|
|
247
|
+
ratioToSummarize?: number;
|
|
248
|
+
/**
|
|
249
|
+
* 总结消息时的配置项
|
|
250
|
+
* @default { model: undefined, systemPrompt: "总结历史消息" }
|
|
251
|
+
*/
|
|
252
|
+
summarizeOptions?: Partial<Compact.SummarizeOptions>;
|
|
253
|
+
}) => Promise<Compact.CompactResult>) & {
|
|
254
|
+
summarizeMessages: typeof summarizeMessages;
|
|
255
|
+
softDeleteToolResults: typeof softDeleteToolResults;
|
|
256
|
+
softDeleteOldMediaMessages: typeof softDeleteOldMediaMessages;
|
|
257
|
+
hardDeleteSoftMessages: typeof hardDeleteSoftMessages;
|
|
258
|
+
hardDeleteOldMessages: typeof hardDeleteOldMessages;
|
|
259
|
+
};
|
|
260
|
+
//#endregion
|
|
261
|
+
//#region src/utils/helper.d.ts
|
|
262
|
+
/**
|
|
263
|
+
* 辅助定义一个POST /chat/completions支持的model参数
|
|
264
|
+
* @remarks 只有baseUrl字段是必须的
|
|
265
|
+
*/
|
|
266
|
+
declare const defineModel: (config: Model) => Model;
|
|
267
|
+
/**
|
|
268
|
+
* 辅助定义一个POST /chat/completions支持的tool对象
|
|
269
|
+
* @param execute 实际执行工具的函数
|
|
270
|
+
*/
|
|
271
|
+
declare const defineTool: (name: ToolDefinition["function"]["name"], description: ToolDefinition["function"]["description"], properties: ToolDefinition["function"]["parameters"]["properties"], execute: ToolDefinition["execute"]) => ToolDefinition;
|
|
208
272
|
//#endregion
|
|
209
|
-
export { type AgentEvent, type AudioContent, type ChatCompletionsChunk, type ContentPart, type FinishReason, type ImageContent, type LLMEvent, type Message, type Modality, type Model,
|
|
210
|
-
//# sourceMappingURL=index.d.mts.map
|
|
273
|
+
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 };
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
export {
|
|
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-DzLCCWBV.mjs";
|
|
2
|
+
export { compact, defineModel, defineTool, get_time_default as getTime, get_weather_default as getWeather, runAgent };
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { createXMLText, fetcher, logger, parseSSE, pick, to } from "@nickyzj2023/utils";
|
|
2
|
+
//#region src/llm.ts
|
|
3
|
+
/**
|
|
4
|
+
* 分离ToolDefinition中的valid/invalid字段,前者可以传给模型,后者用于本地运算
|
|
5
|
+
* @returns [validObj, invalidObj]
|
|
6
|
+
*/
|
|
7
|
+
const detachToolArguments = (toolDefinition) => {
|
|
8
|
+
return [pick(toolDefinition, ["type", "function"]), pick(toolDefinition, ["execute"])];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* 从消息中提取模型的思考内容。
|
|
12
|
+
* 不同供应商,即使都用OpenAI API Compatible接口,输出的思考内容字段也可能不同,例如:
|
|
13
|
+
* - OpenRouter里的思考字段为reasoning
|
|
14
|
+
* - 火山引擎的叫reasoning_content
|
|
15
|
+
* @returns 统一返回reasoning作为思考内容字段
|
|
16
|
+
*/
|
|
17
|
+
const extractReasoning = (msgLike) => {
|
|
18
|
+
return msgLike.reasoning || msgLike.reasoning_content;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* 流式请求模型,转发
|
|
22
|
+
*/
|
|
23
|
+
async function* stream(model, messages, tools = []) {
|
|
24
|
+
const validTools = tools.map((tool) => detachToolArguments(tool)[0]);
|
|
25
|
+
const api = fetcher(model.baseUrl, {
|
|
26
|
+
headers: { Authorization: `Bearer ${model.apiKey}` },
|
|
27
|
+
parser: async (res) => res
|
|
28
|
+
});
|
|
29
|
+
const [error, response] = await to(api.post("/chat/completions", {
|
|
30
|
+
stream: true,
|
|
31
|
+
model: model.model,
|
|
32
|
+
messages,
|
|
33
|
+
tools: validTools
|
|
34
|
+
}));
|
|
35
|
+
if (error) {
|
|
36
|
+
yield {
|
|
37
|
+
type: "error",
|
|
38
|
+
message: error.message
|
|
39
|
+
};
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const toolCallBuffers = /* @__PURE__ */ new Map();
|
|
43
|
+
let usage;
|
|
44
|
+
let finishReason = null;
|
|
45
|
+
for await (const chunk of parseSSE(response)) {
|
|
46
|
+
if (typeof chunk === "string") continue;
|
|
47
|
+
if (chunk.usage) usage = chunk.usage;
|
|
48
|
+
const choice = chunk.choices?.[0];
|
|
49
|
+
if (!choice) continue;
|
|
50
|
+
const { delta } = choice;
|
|
51
|
+
const { content: contentDelta, tool_calls: toolCalls } = delta;
|
|
52
|
+
const reasoning = extractReasoning(delta);
|
|
53
|
+
if (reasoning) yield {
|
|
54
|
+
type: "reasoning_delta",
|
|
55
|
+
delta: reasoning
|
|
56
|
+
};
|
|
57
|
+
if (contentDelta) yield {
|
|
58
|
+
type: "content_delta",
|
|
59
|
+
delta: contentDelta.toString()
|
|
60
|
+
};
|
|
61
|
+
if (toolCalls) for (const call of toolCalls) {
|
|
62
|
+
const { index = 0, type = "function", id, function: fn, ...extra } = call;
|
|
63
|
+
const existing = toolCallBuffers.getOrInsert(index, {
|
|
64
|
+
id: "",
|
|
65
|
+
type,
|
|
66
|
+
function: {
|
|
67
|
+
name: "",
|
|
68
|
+
arguments: ""
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
if (id) existing.id = id;
|
|
72
|
+
if (fn?.name) existing.function.name += fn.name;
|
|
73
|
+
if (fn?.arguments) existing.function.arguments += fn.arguments;
|
|
74
|
+
if (extra) Object.assign(existing, extra);
|
|
75
|
+
toolCallBuffers.set(index, existing);
|
|
76
|
+
}
|
|
77
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
78
|
+
}
|
|
79
|
+
for (const [, call] of toolCallBuffers) yield {
|
|
80
|
+
type: "tool_call",
|
|
81
|
+
id: call.id,
|
|
82
|
+
name: call.function.name,
|
|
83
|
+
args: call.function.arguments
|
|
84
|
+
};
|
|
85
|
+
yield {
|
|
86
|
+
type: "done",
|
|
87
|
+
finishReason,
|
|
88
|
+
usage
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/agent.ts
|
|
93
|
+
async function* runAgent(model, messages, tools) {
|
|
94
|
+
const toolMap = new Map(tools.map((tool) => [tool.function.name, tool]));
|
|
95
|
+
while (true) {
|
|
96
|
+
let content = "";
|
|
97
|
+
const toolCalls = [];
|
|
98
|
+
for await (const e of stream(model, messages, tools)) switch (e.type) {
|
|
99
|
+
case "reasoning_delta":
|
|
100
|
+
yield e;
|
|
101
|
+
break;
|
|
102
|
+
case "content_delta":
|
|
103
|
+
content += e.delta;
|
|
104
|
+
yield e;
|
|
105
|
+
break;
|
|
106
|
+
case "tool_call":
|
|
107
|
+
toolCalls.push({
|
|
108
|
+
id: e.id,
|
|
109
|
+
type: "function",
|
|
110
|
+
function: {
|
|
111
|
+
name: e.name,
|
|
112
|
+
arguments: e.args
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
yield e;
|
|
116
|
+
break;
|
|
117
|
+
case "done": yield e;
|
|
118
|
+
}
|
|
119
|
+
const message = {
|
|
120
|
+
role: "assistant",
|
|
121
|
+
content
|
|
122
|
+
};
|
|
123
|
+
if (toolCalls.length > 0) message.tool_calls = toolCalls;
|
|
124
|
+
messages.push(message);
|
|
125
|
+
if (toolCalls.length === 0) return;
|
|
126
|
+
for (const call of toolCalls) {
|
|
127
|
+
const { name, arguments: args } = call.function;
|
|
128
|
+
const tool = toolMap.get(name);
|
|
129
|
+
let result = "";
|
|
130
|
+
if (!tool) result = `不存在工具“${name}”`;
|
|
131
|
+
else {
|
|
132
|
+
const [error, response] = await to(tool.execute(JSON.parse(args)));
|
|
133
|
+
result = error ? `工具“${name}”执行出错:${error.message}` : String(response);
|
|
134
|
+
}
|
|
135
|
+
messages.push({
|
|
136
|
+
role: "tool",
|
|
137
|
+
tool_call_id: call.id,
|
|
138
|
+
content: result
|
|
139
|
+
});
|
|
140
|
+
yield {
|
|
141
|
+
type: "tool_result",
|
|
142
|
+
id: call.id,
|
|
143
|
+
name,
|
|
144
|
+
result
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/utils/helper.ts
|
|
151
|
+
/**
|
|
152
|
+
* 辅助定义一个POST /chat/completions支持的model参数
|
|
153
|
+
* @remarks 只有baseUrl字段是必须的
|
|
154
|
+
*/
|
|
155
|
+
const defineModel = (config) => ({
|
|
156
|
+
modalities: ["text"],
|
|
157
|
+
context: 131072,
|
|
158
|
+
...config
|
|
159
|
+
});
|
|
160
|
+
/**
|
|
161
|
+
* 辅助定义一个POST /chat/completions支持的tool对象
|
|
162
|
+
* @param execute 实际执行工具的函数
|
|
163
|
+
*/
|
|
164
|
+
const defineTool = (name, description, properties, execute) => {
|
|
165
|
+
const _required = [];
|
|
166
|
+
return {
|
|
167
|
+
type: "function",
|
|
168
|
+
function: {
|
|
169
|
+
name,
|
|
170
|
+
description,
|
|
171
|
+
parameters: {
|
|
172
|
+
type: "object",
|
|
173
|
+
properties: Object.entries(properties).reduce((result, [key, property]) => {
|
|
174
|
+
if ("required" in property) {
|
|
175
|
+
_required.push(key);
|
|
176
|
+
delete property.required;
|
|
177
|
+
}
|
|
178
|
+
result[key] = property;
|
|
179
|
+
return result;
|
|
180
|
+
}, {}),
|
|
181
|
+
required: _required
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
execute
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* 根据上下文里的中/英文/多模态消息,估算出可能消耗的token
|
|
189
|
+
* - 单词 ≈ 1.5token
|
|
190
|
+
* - 标点/空白等非词字符每 4 个 ≈ 1token
|
|
191
|
+
* - 图片/音频/视频/文件 ≈ 4096token(不好估算,取个较大的值)
|
|
192
|
+
*/
|
|
193
|
+
const estimateTokens = (messages) => {
|
|
194
|
+
if (!messages?.length) return 0;
|
|
195
|
+
const segmenter = new Intl.Segmenter([], { granularity: "word" });
|
|
196
|
+
const estimateTextTokens = (text) => {
|
|
197
|
+
let words = 0;
|
|
198
|
+
let others = 0;
|
|
199
|
+
for (const seg of segmenter.segment(text)) if (seg.isWordLike) words++;
|
|
200
|
+
else others++;
|
|
201
|
+
return Math.ceil(words * 1.5 + others / 4);
|
|
202
|
+
};
|
|
203
|
+
return messages.reduce((acc, message) => {
|
|
204
|
+
const { content, tool_calls, ...metadata } = message;
|
|
205
|
+
if (typeof content === "string") acc += estimateTextTokens(content);
|
|
206
|
+
else for (const part of content) if (part.type === "text") acc += estimateTextTokens(part.text);
|
|
207
|
+
else acc += 4096;
|
|
208
|
+
if (tool_calls) acc += estimateTextTokens(JSON.stringify(tool_calls));
|
|
209
|
+
acc += estimateTextTokens(JSON.stringify(metadata));
|
|
210
|
+
return acc;
|
|
211
|
+
}, 0);
|
|
212
|
+
};
|
|
213
|
+
//#endregion
|
|
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
|
+
var get_time_default = defineTool("get_time", "查询指定时区的当前时间", { timezone: {
|
|
225
|
+
type: "string",
|
|
226
|
+
description: "完整的IANA时区名称,如Europe/Amsterdam。用户未明确提及时,传入对方语言对应的时区,例如对方使用中文时可传入Asia/Shanghai。",
|
|
227
|
+
required: true
|
|
228
|
+
} }, 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");
|
|
236
|
+
});
|
|
237
|
+
//#endregion
|
|
238
|
+
//#region src/tools/get-weather.ts
|
|
239
|
+
var get_weather_default = defineTool("get_weather", "查询指定城市的天气情况", { city: {
|
|
240
|
+
type: "string",
|
|
241
|
+
description: "城市名,如shanghai、tokyo",
|
|
242
|
+
required: true
|
|
243
|
+
} }, 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");
|
|
254
|
+
});
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/utils/compact/helper.ts
|
|
257
|
+
/**
|
|
258
|
+
* 判断消息是否为带有工具调用的 assistant 消息
|
|
259
|
+
*/
|
|
260
|
+
const hasToolCalls = (message) => message?.role === "assistant" && Array.isArray(message.tool_calls);
|
|
261
|
+
/**
|
|
262
|
+
* 查找tool消息所属的 assistant(tool_calls) + tool 配对组范围
|
|
263
|
+
* @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消息都能定位到整组,供上层按整组范围批量操作
|
|
270
|
+
*/
|
|
271
|
+
const findToolGroupRange = (messages, toolIndex) => {
|
|
272
|
+
let groupStart = toolIndex;
|
|
273
|
+
while (groupStart > 0 && messages[groupStart - 1]?.role === "tool") groupStart--;
|
|
274
|
+
if (!hasToolCalls(messages[groupStart - 1])) return null;
|
|
275
|
+
let groupEnd = groupStart;
|
|
276
|
+
while (groupEnd < messages.length && messages[groupEnd]?.role === "tool") groupEnd++;
|
|
277
|
+
return [groupStart - 1, groupEnd];
|
|
278
|
+
};
|
|
279
|
+
//#endregion
|
|
280
|
+
//#region src/utils/compact/strategy.ts
|
|
281
|
+
const softDeleteToolResults = (messages, replacer) => {
|
|
282
|
+
const softDeleted = [];
|
|
283
|
+
for (const message of messages) if (message?.role === "tool") {
|
|
284
|
+
message.content = replacer(message.content);
|
|
285
|
+
softDeleted.push(message);
|
|
286
|
+
}
|
|
287
|
+
if (softDeleted.length > 0) logger(`软删除了${softDeleted.length}条工具调用结果消息`);
|
|
288
|
+
return softDeleted;
|
|
289
|
+
};
|
|
290
|
+
const softDeleteOldMediaMessages = (messages, replacer) => {
|
|
291
|
+
const mediaTypes = [
|
|
292
|
+
"image_url",
|
|
293
|
+
"input_audio",
|
|
294
|
+
"video_url"
|
|
295
|
+
];
|
|
296
|
+
const softDeleted = [];
|
|
297
|
+
for (const message of messages) if (message && Array.isArray(message.content) && message.content.some((part) => mediaTypes.includes(part.type))) {
|
|
298
|
+
message.content = replacer(message.content);
|
|
299
|
+
softDeleted.push(message);
|
|
300
|
+
}
|
|
301
|
+
if (softDeleted.length > 0) logger(`软删除了${softDeleted.length}条旧图片/音频/视频消息`);
|
|
302
|
+
return softDeleted;
|
|
303
|
+
};
|
|
304
|
+
/**
|
|
305
|
+
* 清理软删除后残留的占位消息(信息在软删除时已丢失,此时删除不损失任何额外信息)
|
|
306
|
+
* @param messages 可压缩区消息数组,直接原地删除
|
|
307
|
+
* @param softDeleted 本轮被软删过的消息引用集合
|
|
308
|
+
* @remarks
|
|
309
|
+
* tool消息不能单独删除:OpenAI API要求assistant(tool_calls)与tool消息按tool_call_id配对,
|
|
310
|
+
* 单独删掉tool会让assistant(tool_calls)变成孤立消息,后续请求返回400。
|
|
311
|
+
* 因此遇到被软删的tool消息时,必须把整个assistant(tool_calls) + tool配对组一起删除。
|
|
312
|
+
* 组内多条tool被软删时,集合去重后只会删除一次。
|
|
313
|
+
*/
|
|
314
|
+
const hardDeleteSoftMessages = (messages, softDeleted) => {
|
|
315
|
+
const deleteIndices = /* @__PURE__ */ new Set();
|
|
316
|
+
for (let i = 0; i < messages.length; i++) {
|
|
317
|
+
const message = messages[i];
|
|
318
|
+
if (!message || !softDeleted.has(message)) continue;
|
|
319
|
+
if (message.role === "tool") {
|
|
320
|
+
const range = findToolGroupRange(messages, i);
|
|
321
|
+
if (range) {
|
|
322
|
+
const [start, end] = range;
|
|
323
|
+
for (let k = start; k < end; k++) deleteIndices.add(k);
|
|
324
|
+
} else deleteIndices.add(i);
|
|
325
|
+
} else deleteIndices.add(i);
|
|
326
|
+
}
|
|
327
|
+
const sortedIndices = [...deleteIndices].sort((a, b) => b - a);
|
|
328
|
+
for (const index of sortedIndices) if (index !== void 0) messages.splice(index, 1);
|
|
329
|
+
if (deleteIndices.size > 0) logger(`清理了${deleteIndices.size}条软删除残留消息`);
|
|
330
|
+
};
|
|
331
|
+
const summarizeMessages = async (messages, options) => {
|
|
332
|
+
const { model, systemPrompt } = options ?? {};
|
|
333
|
+
if (messages.length === 0) {
|
|
334
|
+
logger("消息太少,无需总结");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const summarizableIndices = [];
|
|
338
|
+
const summarizingMessages = [];
|
|
339
|
+
for (let i = 0; i < messages.length; i++) {
|
|
340
|
+
const message = messages[i];
|
|
341
|
+
if (!message) continue;
|
|
342
|
+
if (i === 0 && message.role === "system") continue;
|
|
343
|
+
if (hasToolCalls(message)) {
|
|
344
|
+
let j = i + 1;
|
|
345
|
+
while (j < messages.length && messages[j]?.role === "tool") j++;
|
|
346
|
+
const group = messages.slice(i, j);
|
|
347
|
+
for (let k = i; k < j; k++) summarizableIndices.push(k);
|
|
348
|
+
summarizingMessages.push(...group);
|
|
349
|
+
i = j - 1;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (message.role === "tool") continue;
|
|
353
|
+
summarizableIndices.push(i);
|
|
354
|
+
summarizingMessages.push(message);
|
|
355
|
+
}
|
|
356
|
+
if (summarizableIndices.length === 0) {
|
|
357
|
+
logger("没有可总结的消息");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
summarizingMessages.push({
|
|
361
|
+
role: "system",
|
|
362
|
+
content: systemPrompt
|
|
363
|
+
}, {
|
|
364
|
+
role: "user",
|
|
365
|
+
content: "开始总结"
|
|
366
|
+
});
|
|
367
|
+
let summarized = "";
|
|
368
|
+
let usage;
|
|
369
|
+
for await (const e of runAgent(model, summarizingMessages, [])) if (e.type === "content_delta") summarized += e.delta;
|
|
370
|
+
else if (e.type === "done") usage = e.usage;
|
|
371
|
+
logger(`总结了${summarizingMessages.length}条消息,消耗:`, usage);
|
|
372
|
+
const firstIndex = summarizableIndices[0] ?? 0;
|
|
373
|
+
for (let i = summarizableIndices.length - 1; i >= 0; i--) {
|
|
374
|
+
const index = summarizableIndices[i];
|
|
375
|
+
if (index !== void 0) messages.splice(index, 1);
|
|
376
|
+
}
|
|
377
|
+
messages.splice(firstIndex, 0, {
|
|
378
|
+
role: "user",
|
|
379
|
+
content: createXMLText("summary", summarized)
|
|
380
|
+
});
|
|
381
|
+
};
|
|
382
|
+
const hardDeleteOldMessages = (messages) => {
|
|
383
|
+
const startIndex = messages.findIndex((message) => message.role === "user");
|
|
384
|
+
if (startIndex < 0) {
|
|
385
|
+
logger("消息太少,无需硬删除");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const deletedCount = messages.length - startIndex;
|
|
389
|
+
messages.splice(startIndex, deletedCount);
|
|
390
|
+
logger(`硬删除了${deletedCount}条较早的消息`);
|
|
391
|
+
};
|
|
392
|
+
/**
|
|
393
|
+
* 自动优化上下文,类似AI Coding Agent的/compact命令
|
|
394
|
+
*/
|
|
395
|
+
const compact = Object.assign(async (messages, model, options) => {
|
|
396
|
+
const { usage, keepCount = 10, ratioToCompactToolResult = .5, replacerOfToolResultContent = () => "(工具结果已消费)", ratioToCompactMedia = .6, replacerOfMediaContent = () => "(消息已过期)", ratioToClearSoftDeletedMessages = .7, ratioToSummarize = .8, summarizeOptions } = options ?? {};
|
|
397
|
+
const context = model?.context ?? 131072;
|
|
398
|
+
const tokens = usage?.total_tokens ?? estimateTokens(messages);
|
|
399
|
+
const result = {
|
|
400
|
+
hasCompactedToolResult: false,
|
|
401
|
+
hasCompactedMedia: false,
|
|
402
|
+
hasClearedSoftDeletedMessages: false,
|
|
403
|
+
hasSummarized: false,
|
|
404
|
+
hasDeletedOldMessages: false
|
|
405
|
+
};
|
|
406
|
+
let endIndex = Math.max(0, messages.length - keepCount);
|
|
407
|
+
if (messages[endIndex]?.role === "tool") {
|
|
408
|
+
const range = findToolGroupRange(messages, endIndex);
|
|
409
|
+
if (range) endIndex = range[1];
|
|
410
|
+
else while (messages[endIndex]?.role === "tool") endIndex++;
|
|
411
|
+
}
|
|
412
|
+
const compressible = messages.slice(0, endIndex);
|
|
413
|
+
const reserved = messages.slice(endIndex);
|
|
414
|
+
const softDeleted = /* @__PURE__ */ new Set();
|
|
415
|
+
if (tokens > context * ratioToCompactToolResult) {
|
|
416
|
+
for (const message of softDeleteToolResults(compressible, replacerOfToolResultContent)) softDeleted.add(message);
|
|
417
|
+
result.hasCompactedToolResult = true;
|
|
418
|
+
}
|
|
419
|
+
if (tokens > context * ratioToCompactMedia) {
|
|
420
|
+
for (const message of softDeleteOldMediaMessages(compressible, replacerOfMediaContent)) softDeleted.add(message);
|
|
421
|
+
result.hasCompactedMedia = true;
|
|
422
|
+
}
|
|
423
|
+
if (tokens > context * ratioToClearSoftDeletedMessages) {
|
|
424
|
+
hardDeleteSoftMessages(compressible, softDeleted);
|
|
425
|
+
result.hasClearedSoftDeletedMessages = true;
|
|
426
|
+
messages.splice(0, messages.length, ...compressible, ...reserved);
|
|
427
|
+
}
|
|
428
|
+
if (tokens > context * ratioToSummarize) {
|
|
429
|
+
const { systemPrompt = "你现在的任务是总结历史消息" } = summarizeOptions ?? {};
|
|
430
|
+
const [error] = await to(summarizeMessages(compressible, {
|
|
431
|
+
model,
|
|
432
|
+
systemPrompt
|
|
433
|
+
}));
|
|
434
|
+
result.hasSummarized = true;
|
|
435
|
+
if (error) {
|
|
436
|
+
logger(`总结失败(${error.message}),改用硬删除兜底`);
|
|
437
|
+
hardDeleteOldMessages(compressible);
|
|
438
|
+
result.hasDeletedOldMessages = true;
|
|
439
|
+
}
|
|
440
|
+
messages.splice(0, messages.length, ...compressible, ...reserved);
|
|
441
|
+
}
|
|
442
|
+
return result;
|
|
443
|
+
}, {
|
|
444
|
+
summarizeMessages,
|
|
445
|
+
softDeleteToolResults,
|
|
446
|
+
softDeleteOldMediaMessages,
|
|
447
|
+
hardDeleteSoftMessages,
|
|
448
|
+
hardDeleteOldMessages
|
|
449
|
+
});
|
|
450
|
+
//#endregion
|
|
451
|
+
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.1.2",
|
|
4
4
|
"description": "我的“pi”,参考了pi-from-scratch",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -17,16 +17,6 @@
|
|
|
17
17
|
"bin": {
|
|
18
18
|
"ai": "./dist/cli.mjs"
|
|
19
19
|
},
|
|
20
|
-
"keywords": [
|
|
21
|
-
"agent",
|
|
22
|
-
"llm",
|
|
23
|
-
"openai",
|
|
24
|
-
"react",
|
|
25
|
-
"tool-calling",
|
|
26
|
-
"streaming",
|
|
27
|
-
"cli",
|
|
28
|
-
"tui"
|
|
29
|
-
],
|
|
30
20
|
"author": "nickyzj2023",
|
|
31
21
|
"license": "ISC",
|
|
32
22
|
"engines": {
|
|
@@ -35,15 +25,15 @@
|
|
|
35
25
|
"publishConfig": {
|
|
36
26
|
"access": "public"
|
|
37
27
|
},
|
|
38
|
-
"dependencies": {
|
|
39
|
-
"@nickyzj2023/utils": "^1.0.92"
|
|
40
|
-
},
|
|
41
28
|
"devDependencies": {
|
|
42
29
|
"@biomejs/biome": "^2.5.11",
|
|
43
30
|
"@types/node": "^26.4.0",
|
|
44
31
|
"tsdown": "^0.22.14",
|
|
45
32
|
"typescript": "^7.0.2"
|
|
46
33
|
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@nickyzj2023/utils": "link:../utils"
|
|
36
|
+
},
|
|
47
37
|
"scripts": {
|
|
48
38
|
"build": "tsdown",
|
|
49
39
|
"check": "biome check --diagnostic-level=error --write src/",
|
package/dist/cli.mjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"cli.mjs","names":["getWeather","getTime"],"sources":["../src/tools/get-time.ts","../src/cli.ts"],"sourcesContent":["import { fetcher } from \"@nickyzj2023/utils\";\nimport { defineTool } from \"../helper.js\";\n\n// 英文星期名映射为中文,让输出直接可读;未知值时回退到原始英文\nconst weekdayNames: Record<string, string> = {\n\tSunday: \"星期日\",\n\tMonday: \"星期一\",\n\tTuesday: \"星期二\",\n\tWednesday: \"星期三\",\n\tThursday: \"星期四\",\n\tFriday: \"星期五\",\n\tSaturday: \"星期六\",\n};\n\nexport default defineTool(\n\t\"get_time\",\n\t\"查询指定时区的当前时间\",\n\t{\n\t\ttimezone: {\n\t\t\ttype: \"string\",\n\t\t\tdescription:\n\t\t\t\t\"完整的IANA时区名称,如Europe/Amsterdam。用户未明确提及时,传入对方语言对应的时区,例如对方使用中文时可传入Asia/Shanghai。\",\n\t\t\trequired: true,\n\t\t},\n\t},\n\tasync ({ timezone }) => {\n\t\tconst api = fetcher(\"https://timeapi.io/api\", {\n\t\t\tparams: {\n\t\t\t\ttimezone,\n\t\t\t},\n\t\t});\n\t\tconst data = await api.get<any>(\"/time/current/zone\");\n\t\t// 时/分/秒统一补零成两位,保证输出时间文本格式一致\n\t\tconst pad = (n: number) => String(n).padStart(2, \"0\");\n\t\treturn [\n\t\t\t`时区:${data.timeZone}${data.dstActive ? \"(夏令时已生效)\" : \"\"}`,\n\t\t\t`日期:${data.year}-${pad(data.month)}-${pad(data.day)}(${weekdayNames[data.dayOfWeek] ?? data.dayOfWeek})`,\n\t\t\t`时间:${pad(data.hour)}:${pad(data.minute)}:${pad(data.seconds)}`,\n\t\t].join(\"\\n\");\n\t},\n);\n","import { existsSync } from \"node:fs\";\nimport { loadEnvFile } from \"node:process\";\nimport { runAgent } from \"./agent.js\";\nimport { defineModel } from \"./helper.js\";\nimport getTime from \"./tools/get-time.js\";\nimport getWeather from \"./tools/get-weather.js\";\nimport { TUI } from \"./tui.js\";\nimport type { Message } from \"./types.js\";\n\n// 仅在存在.env时加载:发布后用户可能没有该文件,直接调用会抛错\nif (existsSync(\".env\")) {\n\tloadEnvFile(\".env\");\n}\n\n// 1. 读取配置(环境变量可覆盖默认的DeepSeek地址与模型)\nconst model = defineModel({\n\tbaseUrl: process.env.BASE_URL ?? \"https://api.deepseek.com/v1\",\n\tapiKey: process.env.APIKEY,\n\tmodel: process.env.MODEL ?? \"deepseek-v4-flash\",\n});\n\nif (!model.apiKey) {\n\tconsole.error(\"请先在环境变量或.env文件中填入APIKEY(目前仅支持DeepSeek)\");\n\tprocess.exit(1);\n}\n\n// 2. 读取上下文\nconst messages: Message[] = [];\n\n// 3. 读取工具\nconst tools = [getWeather, getTime];\n\n// 4. 启动TUI,监听用户输入,按下回车后调用Agent\nconst tui = new TUI(async (input) => {\n\tmessages.push({ role: \"user\", content: input });\n\n\tfor await (const e of runAgent(model, messages, tools)) {\n\t\tswitch (e.type) {\n\t\t\tcase \"reasoning_delta\": {\n\t\t\t\ttui.printReasoning(e.delta);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"content_delta\": {\n\t\t\t\ttui.printContent(e.delta);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"tool_call\": {\n\t\t\t\ttui.printToolCall(e.name, e.args);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"tool_result\": {\n\t\t\t\ttui.printToolResult(e.name, e.result);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"done\": {\n\t\t\t\ttui.printFinish(e.finishReason, e.usage);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t}\n});\ntui.start();\n"],"mappings":";;;;;AAIA,MAAM,eAAuC;CAC5C,QAAQ;CACR,QAAQ;CACR,SAAS;CACT,WAAW;CACX,UAAU;CACV,QAAQ;CACR,UAAU;AACX;AAEA,IAAA,mBAAe,WACd,YACA,eACA,EACC,UAAU;CACT,MAAM;CACN,aACC;CACD,UAAU;AACX,EACD,GACA,OAAO,EAAE,eAAe;CAMvB,MAAM,OAAO,MALD,QAAQ,0BAA0B,EAC7C,QAAQ,EACP,SACD,EACD,CACqB,CAAC,CAAC,IAAS,oBAAoB;CAEpD,MAAM,OAAO,MAAc,OAAO,CAAC,CAAC,CAAC,SAAS,GAAG,GAAG;CACpD,OAAO;EACN,MAAM,KAAK,WAAW,KAAK,YAAY,aAAa;EACpD,MAAM,KAAK,KAAK,GAAG,IAAI,KAAK,KAAK,EAAE,GAAG,IAAI,KAAK,GAAG,EAAE,GAAG,aAAa,KAAK,cAAc,KAAK,UAAU;EACtG,MAAM,IAAI,KAAK,IAAI,EAAE,GAAG,IAAI,KAAK,MAAM,EAAE,GAAG,IAAI,KAAK,OAAO;CAC7D,CAAC,CAAC,KAAK,IAAI;AACZ,CACD;;;AC9BA,IAAI,WAAW,MAAM,GACpB,YAAY,MAAM;AAInB,MAAM,QAAQ,YAAY;CACzB,SAAS,QAAQ,IAAI,YAAY;CACjC,QAAQ,QAAQ,IAAI;CACpB,OAAO,QAAQ,IAAI,SAAS;AAC7B,CAAC;AAED,IAAI,CAAC,MAAM,QAAQ;CAClB,QAAQ,MAAM,wCAAwC;CACtD,QAAQ,KAAK,CAAC;AACf;AAGA,MAAM,WAAsB,CAAC;AAG7B,MAAM,QAAQ,CAACA,qBAAYC,gBAAO;AAGlC,MAAM,MAAM,IAAI,IAAI,OAAO,UAAU;CACpC,SAAS,KAAK;EAAE,MAAM;EAAQ,SAAS;CAAM,CAAC;CAE9C,WAAW,MAAM,KAAK,SAAS,OAAO,UAAU,KAAK,GACpD,QAAQ,EAAE,MAAV;EACC,KAAK;GACJ,IAAI,eAAe,EAAE,KAAK;GAC1B;EAED,KAAK;GACJ,IAAI,aAAa,EAAE,KAAK;GACxB;EAED,KAAK;GACJ,IAAI,cAAc,EAAE,MAAM,EAAE,IAAI;GAChC;EAED,KAAK;GACJ,IAAI,gBAAgB,EAAE,MAAM,EAAE,MAAM;GACpC;EAED,KAAK,QACJ,IAAI,YAAY,EAAE,cAAc,EAAE,KAAK;CAGzC;AAEF,CAAC;AACD,IAAI,MAAM"}
|
package/dist/tui-Dz0id9sE.mjs
DELETED
|
@@ -1,300 +0,0 @@
|
|
|
1
|
-
import { fetcher, parseSSE, pick, to } from "@nickyzj2023/utils";
|
|
2
|
-
import readline from "node:readline";
|
|
3
|
-
//#region src/llm.ts
|
|
4
|
-
/**
|
|
5
|
-
* 分离ToolDefinition中的valid/invalid字段,前者可以传给模型,后者用于本地运算
|
|
6
|
-
* @returns [validObj, invalidObj]
|
|
7
|
-
*/
|
|
8
|
-
const detachToolArguments = (toolDefinition) => {
|
|
9
|
-
return [pick(toolDefinition, ["type", "function"]), pick(toolDefinition, ["execute"])];
|
|
10
|
-
};
|
|
11
|
-
/**
|
|
12
|
-
* 从消息中提取模型的思考内容。
|
|
13
|
-
* 不同供应商,即使都用OpenAI API Compatible接口,输出的思考内容字段也可能不同,例如:
|
|
14
|
-
* - OpenRouter里的思考字段为reasoning
|
|
15
|
-
* - 火山引擎的叫reasoning_content
|
|
16
|
-
* @returns 统一返回reasoning作为思考内容字段
|
|
17
|
-
*/
|
|
18
|
-
const extractReasoning = (msgLike) => {
|
|
19
|
-
return msgLike.reasoning || msgLike.reasoning_content;
|
|
20
|
-
};
|
|
21
|
-
/**
|
|
22
|
-
* 流式请求模型,转发
|
|
23
|
-
*/
|
|
24
|
-
async function* stream(model, messages, tools = []) {
|
|
25
|
-
const validTools = tools.map((tool) => detachToolArguments(tool)[0]);
|
|
26
|
-
const api = fetcher(model.baseUrl, {
|
|
27
|
-
headers: { Authorization: `Bearer ${model.apiKey}` },
|
|
28
|
-
parser: async (res) => res
|
|
29
|
-
});
|
|
30
|
-
const [error, response] = await to(api.post("/chat/completions", {
|
|
31
|
-
stream: true,
|
|
32
|
-
model: model.model,
|
|
33
|
-
messages,
|
|
34
|
-
tools: validTools
|
|
35
|
-
}));
|
|
36
|
-
if (error) {
|
|
37
|
-
yield {
|
|
38
|
-
type: "error",
|
|
39
|
-
message: error.message
|
|
40
|
-
};
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
const toolCallBuffers = /* @__PURE__ */ new Map();
|
|
44
|
-
let usage;
|
|
45
|
-
let finishReason = null;
|
|
46
|
-
for await (const chunk of parseSSE(response)) {
|
|
47
|
-
if (typeof chunk === "string") continue;
|
|
48
|
-
if (chunk.usage) usage = chunk.usage;
|
|
49
|
-
const choice = chunk.choices?.[0];
|
|
50
|
-
if (!choice) continue;
|
|
51
|
-
const { delta } = choice;
|
|
52
|
-
const { content: contentDelta, tool_calls: toolCalls } = delta;
|
|
53
|
-
const reasoning = extractReasoning(delta);
|
|
54
|
-
if (reasoning) yield {
|
|
55
|
-
type: "reasoning_delta",
|
|
56
|
-
delta: reasoning
|
|
57
|
-
};
|
|
58
|
-
if (contentDelta) yield {
|
|
59
|
-
type: "content_delta",
|
|
60
|
-
delta: contentDelta.toString()
|
|
61
|
-
};
|
|
62
|
-
if (toolCalls) for (const call of toolCalls) {
|
|
63
|
-
const { index = 0, type = "function", id, function: fn, ...extra } = call;
|
|
64
|
-
const existing = toolCallBuffers.getOrInsert(index, {
|
|
65
|
-
id: "",
|
|
66
|
-
type,
|
|
67
|
-
function: {
|
|
68
|
-
name: "",
|
|
69
|
-
arguments: ""
|
|
70
|
-
}
|
|
71
|
-
});
|
|
72
|
-
if (id) existing.id = id;
|
|
73
|
-
if (fn?.name) existing.function.name += fn.name;
|
|
74
|
-
if (fn?.arguments) existing.function.arguments += fn.arguments;
|
|
75
|
-
if (extra) Object.assign(existing, extra);
|
|
76
|
-
toolCallBuffers.set(index, existing);
|
|
77
|
-
}
|
|
78
|
-
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
79
|
-
}
|
|
80
|
-
for (const [, call] of toolCallBuffers) yield {
|
|
81
|
-
type: "tool_call",
|
|
82
|
-
id: call.id,
|
|
83
|
-
name: call.function.name,
|
|
84
|
-
args: call.function.arguments
|
|
85
|
-
};
|
|
86
|
-
yield {
|
|
87
|
-
type: "done",
|
|
88
|
-
finishReason,
|
|
89
|
-
usage
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
//#endregion
|
|
93
|
-
//#region src/agent.ts
|
|
94
|
-
async function* runAgent(model, messages, tools) {
|
|
95
|
-
const toolMap = new Map(tools.map((tool) => [tool.function.name, tool]));
|
|
96
|
-
while (true) {
|
|
97
|
-
let content = "";
|
|
98
|
-
const toolCalls = [];
|
|
99
|
-
for await (const e of stream(model, messages, tools)) switch (e.type) {
|
|
100
|
-
case "reasoning_delta":
|
|
101
|
-
yield e;
|
|
102
|
-
break;
|
|
103
|
-
case "content_delta":
|
|
104
|
-
content += e.delta;
|
|
105
|
-
yield e;
|
|
106
|
-
break;
|
|
107
|
-
case "tool_call":
|
|
108
|
-
toolCalls.push({
|
|
109
|
-
id: e.id,
|
|
110
|
-
type: "function",
|
|
111
|
-
function: {
|
|
112
|
-
name: e.name,
|
|
113
|
-
arguments: e.args
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
yield e;
|
|
117
|
-
break;
|
|
118
|
-
case "done": yield e;
|
|
119
|
-
}
|
|
120
|
-
const message = {
|
|
121
|
-
role: "assistant",
|
|
122
|
-
content
|
|
123
|
-
};
|
|
124
|
-
if (toolCalls.length > 0) message.tool_calls = toolCalls;
|
|
125
|
-
messages.push(message);
|
|
126
|
-
if (toolCalls.length === 0) return;
|
|
127
|
-
for (const call of toolCalls) {
|
|
128
|
-
const { name, arguments: args } = call.function;
|
|
129
|
-
const tool = toolMap.get(name);
|
|
130
|
-
let result = "";
|
|
131
|
-
if (!tool) result = `不存在工具“${name}”`;
|
|
132
|
-
else {
|
|
133
|
-
const [error, response] = await to(tool.execute(JSON.parse(args)));
|
|
134
|
-
result = error ? `工具“${name}”执行出错:${error.message}` : String(response);
|
|
135
|
-
}
|
|
136
|
-
messages.push({
|
|
137
|
-
role: "tool",
|
|
138
|
-
tool_call_id: call.id,
|
|
139
|
-
content: result
|
|
140
|
-
});
|
|
141
|
-
yield {
|
|
142
|
-
type: "tool_result",
|
|
143
|
-
id: call.id,
|
|
144
|
-
name,
|
|
145
|
-
result
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
//#endregion
|
|
151
|
-
//#region src/helper.ts
|
|
152
|
-
/**
|
|
153
|
-
* 辅助定义一个POST /chat/completions支持的model参数
|
|
154
|
-
* @remarks 只有baseUrl字段是必须的
|
|
155
|
-
*/
|
|
156
|
-
const defineModel = (config) => ({
|
|
157
|
-
modalities: ["text"],
|
|
158
|
-
context: 131072,
|
|
159
|
-
...config
|
|
160
|
-
});
|
|
161
|
-
/**
|
|
162
|
-
* 辅助定义一个POST /chat/completions支持的tool对象
|
|
163
|
-
* @param execute 实际执行工具的函数
|
|
164
|
-
*/
|
|
165
|
-
const defineTool = (name, description, properties, execute) => {
|
|
166
|
-
const _required = [];
|
|
167
|
-
return {
|
|
168
|
-
type: "function",
|
|
169
|
-
function: {
|
|
170
|
-
name,
|
|
171
|
-
description,
|
|
172
|
-
parameters: {
|
|
173
|
-
type: "object",
|
|
174
|
-
properties: Object.entries(properties).reduce((result, [key, property]) => {
|
|
175
|
-
if ("required" in property) {
|
|
176
|
-
_required.push(key);
|
|
177
|
-
delete property.required;
|
|
178
|
-
}
|
|
179
|
-
result[key] = property;
|
|
180
|
-
return result;
|
|
181
|
-
}, {}),
|
|
182
|
-
required: _required
|
|
183
|
-
}
|
|
184
|
-
},
|
|
185
|
-
execute
|
|
186
|
-
};
|
|
187
|
-
};
|
|
188
|
-
//#endregion
|
|
189
|
-
//#region src/tools/get-weather.ts
|
|
190
|
-
var get_weather_default = defineTool("get_weather", "查询指定城市的天气情况", { city: {
|
|
191
|
-
type: "string",
|
|
192
|
-
description: "城市名,如shanghai、tokyo",
|
|
193
|
-
required: true
|
|
194
|
-
} }, async ({ city }) => {
|
|
195
|
-
const data = await fetcher("https://wttr.in", { params: { format: "j1" } }).get(`/${city}`);
|
|
196
|
-
const nearestArea = data.nearest_area[0];
|
|
197
|
-
const currentCondition = data.current_condition[0];
|
|
198
|
-
return [
|
|
199
|
-
`位置:${nearestArea.country[0].value}-${nearestArea.region[0].value}-${nearestArea.areaName[0].value}`,
|
|
200
|
-
`当前温度: ${currentCondition.temp_C}°C`,
|
|
201
|
-
`体感温度: ${currentCondition.FeelsLikeC}°C`,
|
|
202
|
-
`天气状况: ${currentCondition.weatherDesc[0].value}`,
|
|
203
|
-
`湿度: ${currentCondition.humidity}%`
|
|
204
|
-
].join("\n");
|
|
205
|
-
});
|
|
206
|
-
//#endregion
|
|
207
|
-
//#region src/tui.ts
|
|
208
|
-
var TUI = class {
|
|
209
|
-
rl = null;
|
|
210
|
-
onPrompt = null;
|
|
211
|
-
/** 是否允许输入 */
|
|
212
|
-
isBusy = false;
|
|
213
|
-
/** 上次打印内容所属的状态(reasoning/content/tool)
|
|
214
|
-
* 用于在新的状态开始时改变样式、打印前缀
|
|
215
|
-
*/
|
|
216
|
-
prevPrintType = void 0;
|
|
217
|
-
/**
|
|
218
|
-
* 实例化TUI时,接收一个“发出用户提示词”的回调函数
|
|
219
|
-
* UI只做UI的事,提示词发给谁让调用方决定
|
|
220
|
-
*/
|
|
221
|
-
constructor(onPrompt) {
|
|
222
|
-
this.onPrompt = onPrompt;
|
|
223
|
-
}
|
|
224
|
-
/** 启动TUI */
|
|
225
|
-
start() {
|
|
226
|
-
this.rl = readline.createInterface({
|
|
227
|
-
input: process.stdin,
|
|
228
|
-
output: process.stdout
|
|
229
|
-
});
|
|
230
|
-
this.prompting();
|
|
231
|
-
this.rl.on("close", () => {
|
|
232
|
-
this.rl?.close();
|
|
233
|
-
this.rl = null;
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
/** 监听用户输入 */
|
|
237
|
-
prompting() {
|
|
238
|
-
if (this.isBusy) return;
|
|
239
|
-
this.rl?.question("> ", async (answer) => {
|
|
240
|
-
const input = answer.trim();
|
|
241
|
-
if (!input) {
|
|
242
|
-
this.prompting();
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
this.isBusy = true;
|
|
246
|
-
await this.onPrompt?.(input);
|
|
247
|
-
this.isBusy = false;
|
|
248
|
-
this.prompting();
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
/**
|
|
252
|
-
* 所有print方法调用前的统一入口(相当于“父类逻辑”):
|
|
253
|
-
* 状态切换时打印换行,并记录当前状态。
|
|
254
|
-
* @returns 是否发生了状态切换
|
|
255
|
-
*/
|
|
256
|
-
preparePrint(type) {
|
|
257
|
-
if (this.prevPrintType === type) return false;
|
|
258
|
-
process.stdout.write("\n");
|
|
259
|
-
this.prevPrintType = type;
|
|
260
|
-
return true;
|
|
261
|
-
}
|
|
262
|
-
/**
|
|
263
|
-
* 为文本添加ANSI颜色;非TTY输出(重定向/管道)时返回原文本,避免日志出现转义码。
|
|
264
|
-
* @param text 原始文本
|
|
265
|
-
* @param ansiCode ANSI颜色代码,如 "90"(亮黑,大多数终端显示为灰色)
|
|
266
|
-
*/
|
|
267
|
-
colorize(text, ansiCode) {
|
|
268
|
-
if (!process.stdout.isTTY) return text;
|
|
269
|
-
return `\x1b[${ansiCode}m${text}\x1b[0m`;
|
|
270
|
-
}
|
|
271
|
-
/** 流式打印AI思考内容(灰色) */
|
|
272
|
-
printReasoning(delta) {
|
|
273
|
-
if (this.preparePrint("reasoning_delta")) delta = `[思考内容] ${delta.replaceAll("\n", "")}`;
|
|
274
|
-
process.stdout.write(this.colorize(delta, "90"));
|
|
275
|
-
}
|
|
276
|
-
/** 流式打印AI回复内容 */
|
|
277
|
-
printContent(delta) {
|
|
278
|
-
this.preparePrint("content_delta");
|
|
279
|
-
process.stdout.write(delta);
|
|
280
|
-
}
|
|
281
|
-
/** 打印工具调用 */
|
|
282
|
-
printToolCall(name, args) {
|
|
283
|
-
this.preparePrint("tool_call");
|
|
284
|
-
process.stdout.write(`[工具调用:${name}] ${args}`);
|
|
285
|
-
}
|
|
286
|
-
/** 打印工具结果 */
|
|
287
|
-
printToolResult(name, result) {
|
|
288
|
-
this.preparePrint("tool_result");
|
|
289
|
-
process.stdout.write(this.colorize(`[工具结果:${name}] ${result}`, "90"));
|
|
290
|
-
}
|
|
291
|
-
/** 打印轮次结束原因、token消耗 */
|
|
292
|
-
printFinish(finishReason, usage) {
|
|
293
|
-
this.preparePrint("done");
|
|
294
|
-
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"));
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
//#endregion
|
|
298
|
-
export { runAgent as a, defineTool as i, get_weather_default as n, stream as o, defineModel as r, TUI as t };
|
|
299
|
-
|
|
300
|
-
//# sourceMappingURL=tui-Dz0id9sE.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"tui-Dz0id9sE.mjs","names":[],"sources":["../src/llm.ts","../src/agent.ts","../src/helper.ts","../src/tools/get-weather.ts","../src/tui.ts"],"sourcesContent":["// ================================\n// 解析OpenAI API Compatible的SSE事件流\n// ================================\n\nimport { fetcher, parseSSE, pick, to } from \"@nickyzj2023/utils\";\nimport type {\n\tChatCompletionsChunk,\n\tFinishReason,\n\tLLMEvent,\n\tMessage,\n\tModel,\n\tToolCall,\n\tToolDefinition,\n\tUsage,\n} from \"./types.js\";\n\n/**\n * 分离ToolDefinition中的valid/invalid字段,前者可以传给模型,后者用于本地运算\n * @returns [validObj, invalidObj]\n */\nconst detachToolArguments = (toolDefinition: ToolDefinition) => {\n\treturn [\n\t\tpick(toolDefinition, [\"type\", \"function\"]),\n\t\tpick(toolDefinition, [\"execute\"]),\n\t] as const;\n};\n\n/**\n * 从消息中提取模型的思考内容。\n * 不同供应商,即使都用OpenAI API Compatible接口,输出的思考内容字段也可能不同,例如:\n * - OpenRouter里的思考字段为reasoning\n * - 火山引擎的叫reasoning_content\n * @returns 统一返回reasoning作为思考内容字段\n */\nconst extractReasoning = (msgLike: Record<string, any>): string | undefined => {\n\treturn msgLike.reasoning || msgLike.reasoning_content;\n};\n\n/**\n * 流式请求模型,转发\n */\nexport async function* stream(\n\tmodel: Model,\n\tmessages: Message[],\n\ttools: ToolDefinition[] = [],\n): AsyncGenerator<LLMEvent> {\n\t// 剥离ToolDefinition里的私有字段/语法糖\n\tconst validTools = tools.map((tool) => detachToolArguments(tool)[0]);\n\n\tconst api = fetcher(model.baseUrl, {\n\t\theaders: {\n\t\t\tAuthorization: `Bearer ${model.apiKey}`,\n\t\t},\n\t\t// 覆盖默认返回的res.json(),改用野生Response\n\t\tparser: async (res) => res,\n\t});\n\n\t// 发出请求\n\tconst [error, response] = await to(\n\t\tapi.post<Response>(\"/chat/completions\", {\n\t\t\tstream: true,\n\t\t\tmodel: model.model,\n\t\t\tmessages,\n\t\t\ttools: validTools,\n\t\t}),\n\t);\n\tif (error) {\n\t\tyield { type: \"error\", message: error.message };\n\t\treturn;\n\t}\n\n\t// 逐行解析SSE事件\n\tconst toolCallBuffers = new Map<number, ToolCall>();\n\tlet usage: Usage | undefined;\n\tlet finishReason: FinishReason = null;\n\tfor await (const chunk of parseSSE<ChatCompletionsChunk>(response)) {\n\t\t// 字符串(通常是\"[DONE]\"),无需处理\n\t\tif (typeof chunk === \"string\") {\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (chunk.usage) {\n\t\t\tusage = chunk.usage;\n\t\t}\n\n\t\t// 模型无回复,暂不处理\n\t\tconst choice = chunk.choices?.[0];\n\t\tif (!choice) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst { delta } = choice;\n\t\tconst { content: contentDelta, tool_calls: toolCalls } = delta;\n\n\t\t// 模型祈祷中...\n\t\tconst reasoning = extractReasoning(delta);\n\t\tif (reasoning) {\n\t\t\tyield { type: \"reasoning_delta\", delta: reasoning };\n\t\t}\n\n\t\t// 模型确定回复\n\t\tif (contentDelta) {\n\t\t\tyield { type: \"content_delta\", delta: contentDelta.toString() };\n\t\t}\n\n\t\t// 拼接工具调用请求\n\t\tif (toolCalls) {\n\t\t\tfor (const call of toolCalls) {\n\t\t\t\tconst {\n\t\t\t\t\tindex = 0,\n\t\t\t\t\ttype = \"function\",\n\t\t\t\t\tid,\n\t\t\t\t\tfunction: fn,\n\t\t\t\t\t...extra\n\t\t\t\t} = call;\n\n\t\t\t\tconst existing = toolCallBuffers.getOrInsert(index, {\n\t\t\t\t\tid: \"\",\n\t\t\t\t\ttype,\n\t\t\t\t\tfunction: { name: \"\", arguments: \"\" },\n\t\t\t\t});\n\n\t\t\t\tif (id) {\n\t\t\t\t\texisting.id = id;\n\t\t\t\t}\n\t\t\t\tif (fn?.name) {\n\t\t\t\t\texisting.function.name += fn.name;\n\t\t\t\t}\n\t\t\t\tif (fn?.arguments) {\n\t\t\t\t\texisting.function.arguments += fn.arguments;\n\t\t\t\t}\n\t\t\t\t// 一些厂商(Gemini)会在工具调用时要求保留CoT等额外信息\n\t\t\t\tif (extra) {\n\t\t\t\t\tObject.assign(existing, extra);\n\t\t\t\t}\n\n\t\t\t\ttoolCallBuffers.set(index, existing);\n\t\t\t}\n\t\t}\n\n\t\tif (choice.finish_reason) {\n\t\t\tfinishReason = choice.finish_reason;\n\t\t}\n\t}\n\n\t// 流式传输结束:\n\t// 1. 依次发起工具调用\n\tfor (const [, call] of toolCallBuffers) {\n\t\tyield {\n\t\t\ttype: \"tool_call\",\n\t\t\tid: call.id,\n\t\t\tname: call.function.name,\n\t\t\targs: call.function.arguments,\n\t\t};\n\t}\n\t// 2. 发出done事件\n\tyield { type: \"done\", finishReason, usage };\n}\n","// ================================\n// Agent Loop,即Re-Act:用户输入 -> while(模型思考 <-> Agent帮模型调用外部工具) -> 模型输出\n// ================================\n\nimport { to } from \"@nickyzj2023/utils\";\nimport { stream } from \"./llm.js\";\nimport type {\n\tAgentEvent,\n\tMessage,\n\tModel,\n\tToolCall,\n\tToolDefinition,\n} from \"./types.js\";\n\nexport async function* runAgent(\n\tmodel: Model,\n\tmessages: Message[],\n\ttools: ToolDefinition[],\n): AsyncGenerator<AgentEvent> {\n\tconst toolMap = new Map(tools.map((tool) => [tool.function.name, tool]));\n\n\twhile (true) {\n\t\t// 1. 流式调用大模型,收集回复内容\n\t\tlet content = \"\";\n\t\tconst toolCalls: ToolCall[] = [];\n\n\t\tfor await (const e of stream(model, messages, tools)) {\n\t\t\tswitch (e.type) {\n\t\t\t\tcase \"reasoning_delta\": {\n\t\t\t\t\tyield e;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"content_delta\": {\n\t\t\t\t\tcontent += e.delta;\n\t\t\t\t\tyield e;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"tool_call\": {\n\t\t\t\t\ttoolCalls.push({\n\t\t\t\t\t\tid: e.id,\n\t\t\t\t\t\ttype: \"function\",\n\t\t\t\t\t\tfunction: {\n\t\t\t\t\t\t\tname: e.name,\n\t\t\t\t\t\t\targuments: e.args,\n\t\t\t\t\t\t},\n\t\t\t\t\t});\n\t\t\t\t\tyield e;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tcase \"done\": {\n\t\t\t\t\tyield e;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// 2. 把模型的回复推入上下文\n\t\tconst message: Message = {\n\t\t\trole: \"assistant\",\n\t\t\tcontent,\n\t\t};\n\t\tif (toolCalls.length > 0) {\n\t\t\tmessage.tool_calls = toolCalls;\n\t\t}\n\t\tmessages.push(message);\n\n\t\t// 3. 如果没有工具调用,则结束循环\n\t\tif (toolCalls.length === 0) {\n\t\t\treturn;\n\t\t}\n\n\t\t// 4. 调用工具\n\t\tfor (const call of toolCalls) {\n\t\t\tconst { name, arguments: args } = call.function;\n\t\t\tconst tool = toolMap.get(name);\n\n\t\t\tlet result = \"\";\n\t\t\tif (!tool) {\n\t\t\t\tresult = `不存在工具“${name}”`;\n\t\t\t} else {\n\t\t\t\tconst [error, response] = await to(tool.execute(JSON.parse(args)));\n\t\t\t\tresult = error\n\t\t\t\t\t? `工具“${name}”执行出错:${error.message}`\n\t\t\t\t\t: String(response);\n\t\t\t}\n\n\t\t\tmessages.push({\n\t\t\t\trole: \"tool\",\n\t\t\t\ttool_call_id: call.id,\n\t\t\t\tcontent: result,\n\t\t\t});\n\t\t\tyield { type: \"tool_result\", id: call.id, name: name, result };\n\t\t}\n\t}\n}\n","// ================================\n// 外部项目使用本npm包时的便捷方法\n// ================================\n\nimport type { Model, ToolDefinition } from \"./types.js\";\n\n/**\n * 辅助定义一个POST /chat/completions支持的model参数\n * @remarks 只有baseUrl字段是必须的\n */\nexport const defineModel = (config: Model): Model => ({\n\tmodalities: [\"text\"],\n\tcontext: 131072,\n\t...config,\n});\n\n/**\n * 辅助定义一个POST /chat/completions支持的tool对象\n * @param execute 实际执行工具的函数\n */\nexport const defineTool = (\n\tname: ToolDefinition[\"function\"][\"name\"],\n\tdescription: ToolDefinition[\"function\"][\"description\"],\n\tproperties: ToolDefinition[\"function\"][\"parameters\"][\"properties\"],\n\texecute: ToolDefinition[\"execute\"],\n): ToolDefinition => {\n\t// 收集property内部填写的required: true语法糖,推到外面的required数组\n\tconst _required: string[] = [];\n\tconst _properties = Object.entries(properties).reduce(\n\t\t(result, [key, property]) => {\n\t\t\tif (\"required\" in property) {\n\t\t\t\t_required.push(key);\n\t\t\t\tdelete property.required;\n\t\t\t}\n\t\t\tresult[key] = property;\n\t\t\treturn result;\n\t\t},\n\t\t{} as Omit<\n\t\t\tToolDefinition[\"function\"][\"parameters\"][\"properties\"],\n\t\t\t\"required\"\n\t\t>,\n\t);\n\n\treturn {\n\t\ttype: \"function\",\n\t\tfunction: {\n\t\t\tname,\n\t\t\tdescription,\n\t\t\tparameters: {\n\t\t\t\ttype: \"object\",\n\t\t\t\tproperties: _properties,\n\t\t\t\trequired: _required,\n\t\t\t},\n\t\t},\n\t\texecute,\n\t};\n};\n","import { fetcher } from \"@nickyzj2023/utils\";\nimport { defineTool } from \"../helper.js\";\n\nexport default defineTool(\n\t\"get_weather\",\n\t\"查询指定城市的天气情况\",\n\t{\n\t\tcity: {\n\t\t\ttype: \"string\",\n\t\t\tdescription: \"城市名,如shanghai、tokyo\",\n\t\t\trequired: true,\n\t\t},\n\t},\n\tasync ({ city }) => {\n\t\tconst api = fetcher(\"https://wttr.in\", {\n\t\t\tparams: {\n\t\t\t\tformat: \"j1\", // 返回JSON格式\n\t\t\t},\n\t\t});\n\t\tconst data = await api.get<any>(`/${city}`);\n\t\tconst nearestArea = data.nearest_area[0];\n\t\tconst currentCondition = data.current_condition[0];\n\t\treturn [\n\t\t\t`位置:${nearestArea.country[0].value}-${nearestArea.region[0].value}-${nearestArea.areaName[0].value}`,\n\t\t\t`当前温度: ${currentCondition.temp_C}°C`,\n\t\t\t`体感温度: ${currentCondition.FeelsLikeC}°C`,\n\t\t\t`天气状况: ${currentCondition.weatherDesc[0].value}`,\n\t\t\t`湿度: ${currentCondition.humidity}%`,\n\t\t].join(\"\\n\");\n\t},\n);\n","// ================================\n// 终端UI\n// ================================\n\nimport readline from \"node:readline\";\nimport type { AgentEvent, FinishReason, Usage } from \"./types.js\";\n\nexport class TUI {\n\tprivate rl: readline.Interface | null = null;\n\tprivate onPrompt: ((prompt: string) => void | Promise<void>) | null = null;\n\n\t/** 是否允许输入 */\n\tprivate isBusy = false;\n\n\t/** 上次打印内容所属的状态(reasoning/content/tool)\n\t * 用于在新的状态开始时改变样式、打印前缀\n\t */\n\tprivate prevPrintType: AgentEvent[\"type\"] | undefined = undefined;\n\n\t/**\n\t * 实例化TUI时,接收一个“发出用户提示词”的回调函数\n\t * UI只做UI的事,提示词发给谁让调用方决定\n\t */\n\tconstructor(onPrompt: (prompt: string) => void | Promise<void>) {\n\t\tthis.onPrompt = onPrompt;\n\t}\n\n\t/** 启动TUI */\n\tstart() {\n\t\tthis.rl = readline.createInterface({\n\t\t\tinput: process.stdin,\n\t\t\toutput: process.stdout,\n\t\t});\n\t\tthis.prompting();\n\n\t\t// 停止TUI后,清理残留的监听事件\n\t\tthis.rl.on(\"close\", () => {\n\t\t\tthis.rl?.close();\n\t\t\tthis.rl = null;\n\t\t});\n\t}\n\n\t/** 监听用户输入 */\n\tprivate prompting() {\n\t\tif (this.isBusy) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis.rl?.question(\"> \", async (answer) => {\n\t\t\tconst input = answer.trim();\n\t\t\t// 如果输入为空,则重新question\n\t\t\tif (!input) {\n\t\t\t\tthis.prompting();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tthis.isBusy = true;\n\t\t\tawait this.onPrompt?.(input);\n\t\t\tthis.isBusy = false;\n\n\t\t\tthis.prompting();\n\t\t});\n\t}\n\n\t/**\n\t * 所有print方法调用前的统一入口(相当于“父类逻辑”):\n\t * 状态切换时打印换行,并记录当前状态。\n\t * @returns 是否发生了状态切换\n\t */\n\tprivate preparePrint(type: AgentEvent[\"type\"]): boolean {\n\t\tif (this.prevPrintType === type) {\n\t\t\treturn false;\n\t\t}\n\t\tprocess.stdout.write(\"\\n\");\n\t\tthis.prevPrintType = type;\n\t\treturn true;\n\t}\n\n\t/**\n\t * 为文本添加ANSI颜色;非TTY输出(重定向/管道)时返回原文本,避免日志出现转义码。\n\t * @param text 原始文本\n\t * @param ansiCode ANSI颜色代码,如 \"90\"(亮黑,大多数终端显示为灰色)\n\t */\n\tprivate colorize(text: string, ansiCode: string): string {\n\t\tif (!process.stdout.isTTY) {\n\t\t\treturn text;\n\t\t}\n\t\treturn `\\x1b[${ansiCode}m${text}\\x1b[0m`;\n\t}\n\n\t/** 流式打印AI思考内容(灰色) */\n\tprintReasoning(delta: string) {\n\t\tif (this.preparePrint(\"reasoning_delta\")) {\n\t\t\tdelta = `[思考内容] ${delta.replaceAll(\"\\n\", \"\")}`;\n\t\t}\n\t\tprocess.stdout.write(this.colorize(delta, \"90\"));\n\t}\n\n\t/** 流式打印AI回复内容 */\n\tprintContent(delta: string) {\n\t\tthis.preparePrint(\"content_delta\");\n\t\tprocess.stdout.write(delta);\n\t}\n\n\t/** 打印工具调用 */\n\tprintToolCall(name: string, args: any) {\n\t\tthis.preparePrint(\"tool_call\");\n\t\tprocess.stdout.write(`[工具调用:${name}] ${args}`);\n\t}\n\n\t/** 打印工具结果 */\n\tprintToolResult(name: string, result: string) {\n\t\tthis.preparePrint(\"tool_result\");\n\t\tprocess.stdout.write(this.colorize(`[工具结果:${name}] ${result}`, \"90\"));\n\t}\n\n\t/** 打印轮次结束原因、token消耗 */\n\tprintFinish(finishReason: FinishReason, usage?: Usage) {\n\t\tthis.preparePrint(\"done\");\n\t\tprocess.stdout.write(\n\t\t\tthis.colorize(\n\t\t\t\t`[本轮结束:${finishReason}] ${usage ? `输入${usage.prompt_tokens}tok,输出${usage.completion_tokens}tok,总共${usage.total_tokens}tok` : \"\"}${finishReason === \"stop\" ? \"\\n\\n\" : \"\\n\"}`,\n\t\t\t\t\"90\",\n\t\t\t),\n\t\t);\n\t}\n}\n"],"mappings":"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"}
|