@nickyzj2023/ai 1.1.2 → 1.2.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
@@ -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,83 @@
1
- import { i as defineModel, n as get_weather_default, o as runAgent, r as get_time_default } from "./src-DzLCCWBV.mjs";
2
- import { existsSync } from "node:fs";
3
- import { loadEnvFile } from "node:process";
1
+ import { i as defineModel, n as get_weather_default, o as runAgent, r as get_time_default } from "./src-BJFCp7ur.mjs";
2
+ import { extractErrorMessage } 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
+ //#region src/utils/config.ts
8
+ /**
9
+ * 获取全局配置文件路径(Windows/macOS/Linux通用)
10
+ * @returns (Windows下)C:/Users/Administrator/.@nickyzj2023/ai/config.json
11
+ */
12
+ const getConfigPath = () => {
13
+ return join(homedir(), ".@nickyzj2023", "ai", "config.json");
14
+ };
15
+ /**
16
+ * 读取配置文件
17
+ * @returns 正常返回Config,配置不存在或损坏时返回null
18
+ */
19
+ const loadConfig = () => {
20
+ try {
21
+ const config = JSON.parse(readFileSync(getConfigPath(), "utf8"));
22
+ if (typeof config.baseUrl !== "string" || typeof config.apiKey !== "string" || typeof config.model !== "string") return null;
23
+ return config;
24
+ } catch {
25
+ return null;
26
+ }
27
+ };
28
+ /**
29
+ * 将配置写回全局文件(自动创建目录)
30
+ * @remarks 写入失败会抛异常
31
+ */
32
+ const saveConfig = (config) => {
33
+ const configPath = getConfigPath();
34
+ mkdirSync(dirname(configPath), { recursive: true });
35
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
36
+ };
37
+ //#endregion
38
+ //#region src/interfaces/setup.ts
39
+ /**
40
+ * 临时创建readline接口,问答结束就关闭
41
+ * @param question 提问
42
+ * @returns 用户输入的回答
43
+ */
44
+ const ask = (question) => {
45
+ return new Promise((resolve) => {
46
+ const rl = readline.createInterface({
47
+ input: process.stdin,
48
+ output: process.stdout
49
+ });
50
+ rl.question(question, (answer) => {
51
+ rl.close();
52
+ resolve(answer);
53
+ });
54
+ });
55
+ };
56
+ /**
57
+ * setup入口:依次询问BASE_URL / MODEL / APIKEY,确认后写入全局配置
58
+ */
59
+ async function runSetup() {
60
+ const config = loadConfig();
61
+ if (config) {
62
+ console.log(`当前配置:BASE_URL ${config.baseUrl},APIKEY ${config.apiKey},MODEL ${config.model}`);
63
+ console.log("直接回车可沿用当前值。\n");
64
+ }
65
+ const baseUrl = await ask(`BASE_URL [当前为${config?.baseUrl}]: `) || config?.baseUrl;
66
+ const apiKey = await ask(`APIKEY [当前为${config?.apiKey}]: `) || config?.apiKey;
67
+ const model = await ask(`MODEL [当前为${config?.model}]: `) || config?.model;
68
+ try {
69
+ saveConfig({
70
+ baseUrl,
71
+ apiKey,
72
+ model
73
+ });
74
+ console.log(`配置已保存到${getConfigPath()},直接运行ai即可开始对话`);
75
+ } catch (e) {
76
+ console.error(`保存配置失败:${extractErrorMessage(e)}`);
77
+ process.exitCode = 1;
78
+ }
79
+ }
80
+ //#endregion
5
81
  //#region src/interfaces/tui.ts
6
82
  var TUI = class {
7
83
  rl = null;
@@ -94,39 +170,67 @@ var TUI = class {
94
170
  };
95
171
  //#endregion
96
172
  //#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
113
- });
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);
173
+ /** 启动交互对话:配置来自全局配置文件,环境变量可临时覆盖 */
174
+ const startChat = () => {
175
+ const config = loadConfig();
176
+ if (!config) {
177
+ console.error("请先运行 `ai setup` 配置一个模型");
178
+ process.exit(1);
128
179
  }
129
- });
130
- tui.start();
180
+ const model = defineModel(config);
181
+ const messages = [];
182
+ const tools = [get_weather_default, get_time_default];
183
+ const tui = new TUI(async (input) => {
184
+ messages.push({
185
+ role: "user",
186
+ content: input
187
+ });
188
+ for await (const e of runAgent(model, messages, tools)) switch (e.type) {
189
+ case "reasoning_delta":
190
+ tui.printReasoning(e.delta);
191
+ break;
192
+ case "content_delta":
193
+ tui.printContent(e.delta);
194
+ break;
195
+ case "tool_call":
196
+ tui.printToolCall(e.name, e.args);
197
+ break;
198
+ case "tool_result":
199
+ tui.printToolResult(e.name, e.result);
200
+ break;
201
+ case "error":
202
+ tui.printContent(e.message);
203
+ break;
204
+ case "done": tui.printFinish(e.finishReason, e.usage);
205
+ }
206
+ });
207
+ tui.start();
208
+ };
209
+ /** 打印命令用法 */
210
+ const printHelp = () => {
211
+ console.log(`用法: ai [命令]
212
+
213
+ 命令:
214
+ --help 显示帮助
215
+ setup 交互式配置模型APIKEY / BASE_URL / MODEL(保存到 ~/.@nickyzj2023/ai/config.json)
216
+
217
+ 不带命令则启动对话`);
218
+ };
219
+ const command = process.argv[2];
220
+ switch (command) {
221
+ case void 0:
222
+ startChat();
223
+ break;
224
+ case "setup":
225
+ await runSetup();
226
+ break;
227
+ case "--help":
228
+ case "-h":
229
+ printHelp();
230
+ break;
231
+ default:
232
+ console.error(`未知命令:${command}(可以运行ai --help查看用法)`);
233
+ process.exit(1);
234
+ }
131
235
  //#endregion
132
236
  export {};
package/dist/index.d.mts CHANGED
@@ -150,8 +150,8 @@ 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"]) => Message["content"];
154
- type ReplacerOfMediaContent = (content: Message["content"]) => Message["content"];
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"];
155
155
  type SummarizeOptions = {
156
156
  /** 用什么模型总结 */
157
157
  model: Model;
@@ -180,8 +180,16 @@ declare namespace Compact {
180
180
  }
181
181
  //#endregion
182
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[];
183
+ declare const softDeleteToolResults: (messages: Message[], options: {
184
+ replacer?: Compact.ReplacerOfToolResultContent;
185
+ mark: string;
186
+ model?: Model;
187
+ }) => Promise<Message[]>;
188
+ declare const softDeleteOldMediaMessages: (messages: Message[], options: {
189
+ replacer?: Compact.ReplacerOfMediaContent;
190
+ mark: string;
191
+ model?: Model;
192
+ }) => Promise<Message[]>;
185
193
  /**
186
194
  * 清理软删除后残留的占位消息(信息在软删除时已丢失,此时删除不损失任何额外信息)
187
195
  * @param messages 可压缩区消息数组,直接原地删除
@@ -229,6 +237,10 @@ declare const compact: ((messages: Message[], model: Model, options?: {
229
237
  * @default (content) => "(已被丢弃)"
230
238
  */
231
239
  replacerOfMediaContent?: Compact.ReplacerOfMediaContent;
240
+ /**
241
+ * 用于标记哪些消息被软删除了
242
+ */
243
+ softDeletedMessageMark?: string;
232
244
  /**
233
245
  * 上下文>总上下文*ratio时清理软删除残留的占位消息
234
246
  * @default 0.7
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-DzLCCWBV.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-BJFCp7ur.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,16 +226,7 @@ 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
@@ -278,16 +254,52 @@ const findToolGroupRange = (messages, toolIndex) => {
278
254
  };
279
255
  //#endregion
280
256
  //#region src/utils/compact/strategy.ts
281
- const softDeleteToolResults = (messages, replacer) => {
257
+ /** 默认的软删除多模态消息策略:让大模型精简消息内容 */
258
+ const defaultReplacerOfToolResultContent = async (content, options) => {
259
+ const { mark, model } = options ?? {};
260
+ const messages = [{
261
+ role: "user",
262
+ content
263
+ }, {
264
+ role: "user",
265
+ content: "请用一两句话对上条消息做个“省流”"
266
+ }];
267
+ let simplifiedContent = mark;
268
+ for await (const e of runAgent(model, messages, [])) if (e.type === "content_delta") simplifiedContent += e.delta;
269
+ return simplifiedContent;
270
+ };
271
+ const softDeleteToolResults = async (messages, options) => {
272
+ const { replacer = defaultReplacerOfToolResultContent, mark, model } = options ?? {};
273
+ if (!replacer && !model) return [];
282
274
  const softDeleted = [];
283
- for (const message of messages) if (message?.role === "tool") {
284
- message.content = replacer(message.content);
275
+ for (const message of messages) if (message?.role === "tool" && typeof message.content === "string") {
276
+ if (message.content.startsWith(mark)) continue;
277
+ message.content = await replacer(message.content, {
278
+ mark,
279
+ model
280
+ });
285
281
  softDeleted.push(message);
286
282
  }
287
283
  if (softDeleted.length > 0) logger(`软删除了${softDeleted.length}条工具调用结果消息`);
288
284
  return softDeleted;
289
285
  };
290
- const softDeleteOldMediaMessages = (messages, replacer) => {
286
+ /** 默认的软删除多模态消息策略:让大模型精简消息内容 */
287
+ const defaultReplacerOfMediaContent = async (content, options) => {
288
+ const { mark, model } = options ?? {};
289
+ const messages = [{
290
+ role: "user",
291
+ content
292
+ }, {
293
+ role: "user",
294
+ content: "请用一两句话描述上方的多模态消息"
295
+ }];
296
+ let simplifiedContent = mark;
297
+ for await (const e of runAgent(model, messages, [])) if (e.type === "content_delta") simplifiedContent += e.delta;
298
+ return simplifiedContent;
299
+ };
300
+ const softDeleteOldMediaMessages = async (messages, options) => {
301
+ const { replacer = defaultReplacerOfMediaContent, mark, model } = options ?? {};
302
+ if (!replacer && !model) return [];
291
303
  const mediaTypes = [
292
304
  "image_url",
293
305
  "input_audio",
@@ -295,7 +307,10 @@ const softDeleteOldMediaMessages = (messages, replacer) => {
295
307
  ];
296
308
  const softDeleted = [];
297
309
  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);
310
+ message.content = await replacer(message.content, {
311
+ mark,
312
+ model
313
+ });
299
314
  softDeleted.push(message);
300
315
  }
301
316
  if (softDeleted.length > 0) logger(`软删除了${softDeleted.length}条旧图片/音频/视频消息`);
@@ -393,7 +408,7 @@ const hardDeleteOldMessages = (messages) => {
393
408
  * 自动优化上下文,类似AI Coding Agent的/compact命令
394
409
  */
395
410
  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 ?? {};
411
+ const { usage, keepCount = 10, ratioToCompactToolResult = .5, replacerOfToolResultContent, ratioToCompactMedia = .6, replacerOfMediaContent, ratioToClearSoftDeletedMessages = .7, softDeletedMessageMark = "[已简化]", ratioToSummarize = .8, summarizeOptions } = options ?? {};
397
412
  const context = model?.context ?? 131072;
398
413
  const tokens = usage?.total_tokens ?? estimateTokens(messages);
399
414
  const result = {
@@ -413,11 +428,21 @@ const compact = Object.assign(async (messages, model, options) => {
413
428
  const reserved = messages.slice(endIndex);
414
429
  const softDeleted = /* @__PURE__ */ new Set();
415
430
  if (tokens > context * ratioToCompactToolResult) {
416
- for (const message of softDeleteToolResults(compressible, replacerOfToolResultContent)) softDeleted.add(message);
431
+ const softDeletedMessages = await softDeleteToolResults(compressible, {
432
+ replacer: replacerOfToolResultContent,
433
+ mark: softDeletedMessageMark,
434
+ model
435
+ });
436
+ for (const message of softDeletedMessages) softDeleted.add(message);
417
437
  result.hasCompactedToolResult = true;
418
438
  }
419
439
  if (tokens > context * ratioToCompactMedia) {
420
- for (const message of softDeleteOldMediaMessages(compressible, replacerOfMediaContent)) softDeleted.add(message);
440
+ const softDeletedMessages = await softDeleteOldMediaMessages(compressible, {
441
+ replacer: replacerOfMediaContent,
442
+ mark: softDeletedMessageMark,
443
+ model
444
+ });
445
+ for (const message of softDeletedMessages) softDeleted.add(message);
421
446
  result.hasCompactedMedia = true;
422
447
  }
423
448
  if (tokens > context * ratioToClearSoftDeletedMessages) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nickyzj2023/ai",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "我的“pi”,参考了pi-from-scratch",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -26,8 +26,8 @@
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
  },