@nickyzj2023/ai 1.4.4 → 1.6.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,64 +1,80 @@
1
- # ai
2
-
3
- 男生自用Coding Agent,参考了[nanopi教学](https://pi-from-scratch.vercel.app/)
4
-
5
- 不断学习+开发中,当前项目仍属于比nano还简陋的“bare-pi”,but one day...
6
-
7
- ![demo](demo.gif)
8
-
9
- ## 安装
10
-
11
- ```bash
12
- # npm
13
- npm install @nickyzj2023/ai
14
-
15
- # yarn
16
- yarn add @nickyzj2023/ai
17
-
18
- # pnpm
19
- pnpm add @nickyzj2023/ai
20
- ```
21
-
22
- ## 使用方式
23
-
24
- ### 在项目里使用
25
-
26
- ```typescript
27
- import { defineModel, runAgent, getWeather } from "@nickyzj2023/ai";
28
-
29
- const model = defineModel({
30
- baseUrl: "https://api.deepseek.com/v1",
31
- apiKey: process.env.APIKEY,
32
- model: "deepseek-v4-flash",
33
- });
34
-
35
- const tools = [
36
- // 内置工具
37
- getWeather,
38
- getTime,
39
- // 自定义工具
40
- defineTool("getRandomNumber", "生成随机数字", {min: {type: "number", description: "最小区间"}, max: {type:"number", description:"最大区间"}}, () => 33550336),
41
- ];
42
-
43
- const messages = [{ role: "user", content: "随机一个负无穷到正无穷的整数" }];
44
-
45
- for await (const e of runAgent(model, messages, tools)) {
46
- console.log(e);
47
- }
48
- ```
49
-
50
- ### 在终端里使用
51
-
52
- ```bash
53
- # 首次使用:交互式配置BASE_URL / APIKEY / MODEL / ...
54
- ai setup
55
-
56
- # 启动对话
57
- ai
58
- ```
59
-
60
- 配置保存在 `~/.@nickyzj2023/ai/config.json`,任意目录下执行`ai`都能读取到
61
-
62
- ## License
63
-
64
- ISC
1
+ # ai
2
+
3
+ 男生自用Coding Agent,参考了[nanopi教学](https://pi-from-scratch.vercel.app/)
4
+
5
+ 不断学习+开发中,当前项目仍属于比nano还简陋的“bare-pi”,but one day...
6
+
7
+ ![demo](demo.gif)
8
+
9
+ ## 安装
10
+
11
+ ```bash
12
+ # npm
13
+ npm install @nickyzj2023/ai
14
+
15
+ # yarn
16
+ yarn add @nickyzj2023/ai
17
+
18
+ # pnpm
19
+ pnpm add @nickyzj2023/ai
20
+ ```
21
+
22
+ ## 使用方式
23
+
24
+ ### 在项目里使用
25
+
26
+ ```typescript
27
+ import { defineModel, runAgent, getWeather } from "@nickyzj2023/ai";
28
+ // MCP体积较大(200kb),有需要时单独引入
29
+ import { loadMCPTools } from "@nickyzj2023/ai/mcp";
30
+
31
+ const model = defineModel({
32
+ baseUrl: "https://api.deepseek.com/v1",
33
+ apiKey: process.env.APIKEY,
34
+ model: "deepseek-v4-flash",
35
+ });
36
+
37
+ const tools = [
38
+ // 内置工具
39
+ getWeather,
40
+ getTime,
41
+ // MCP工具
42
+ ..(await loadMCPTools({
43
+ exa: {
44
+ type: "streamable_http",
45
+ url: "https://mcp.exa.ai/mcp",
46
+ headers: {
47
+ "x-api-key": "xxxxx",
48
+ },
49
+ }
50
+ })),
51
+ // 自定义工具
52
+ defineTool("skipReply", "跳过本轮回复", {reason: {type: "string", description: "不回复的理由"}}, () => {
53
+ const error = new Error(`模型保持沉默,理由:${reason}`);
54
+ error.name = "skipReply";
55
+ throw error;
56
+ }),
57
+ ];
58
+
59
+ const messages = [{ role: "user", content: "随机一个负无穷到正无穷的整数" }];
60
+
61
+ for await (const e of runAgent(model, messages, tools)) {
62
+ console.log(e);
63
+ }
64
+ ```
65
+
66
+ ### 在终端里使用
67
+
68
+ ```bash
69
+ # 首次使用:交互式配置BASE_URL / APIKEY / MODEL / ...
70
+ ai setup
71
+
72
+ # 启动对话
73
+ ai
74
+ ```
75
+
76
+ 配置保存在 `~/.@nickyzj2023/ai/config.json`,任意目录下执行`ai`都能读取到
77
+
78
+ ## License
79
+
80
+ ISC
package/dist/cli.mjs CHANGED
@@ -1,10 +1,13 @@
1
- import { a as get_time_default, c as runAgent, i as get_weather_default, o as defineModel, r as loadMCPTools } from "./src-CuZ-6Jye.mjs";
1
+ import { i as runAgent, n as get_weather_default, r as get_time_default } from "./src-CKKPKPMG.mjs";
2
+ import { t as defineModel } from "./helper-juLVGxlQ.mjs";
3
+ import { n as loadMCPTools } from "./mcp-DcntQg-a.mjs";
2
4
  import { compactStr, extractErrorMessage, isObject } from "@nickyzj2023/utils";
3
5
  import readline from "node:readline";
4
6
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
7
  import { homedir } from "node:os";
6
8
  import { dirname, join } from "node:path";
7
9
  import { MarkdownRenderer } from "@wterm/markdown";
10
+ import { execFile } from "node:child_process";
8
11
  //#region src/utils/config.ts
9
12
  /**
10
13
  * 获取全局配置文件路径(Windows/macOS/Linux通用)
@@ -197,10 +200,110 @@ async function runSetup() {
197
200
  }
198
201
  }
199
202
  //#endregion
203
+ //#region src/utils/clipboard.ts
204
+ /** 剪贴板图片的原始字节上限:超过则拒绝,避免把超大图片塞入请求体 */
205
+ const MAX_CLIPBOARD_IMAGE_BYTES = 5242880;
206
+ /**
207
+ * 执行命令并原样取回stdout(二进制安全)
208
+ * @param maxBuffer 允许的最大输出字节数
209
+ */
210
+ const run = (command, args, maxBuffer) => {
211
+ return new Promise((resolve, reject) => {
212
+ execFile(command, args, {
213
+ encoding: "buffer",
214
+ timeout: 5e3,
215
+ maxBuffer
216
+ }, (error, stdout) => {
217
+ if (error) reject(error);
218
+ else resolve(stdout);
219
+ });
220
+ });
221
+ };
222
+ /** 组装成OpenAI接口能接受的data URL */
223
+ const toDataUrl = (buffer, mime = "image/png") => ({
224
+ url: `data:${mime};base64,${buffer.toString("base64")}`,
225
+ bytes: buffer.length
226
+ });
227
+ /** Windows:用PowerShell把剪贴板里的图片转成base64后读出 */
228
+ const readWindows = async () => {
229
+ const script = [
230
+ "Add-Type -AssemblyName System.Windows.Forms",
231
+ "Add-Type -AssemblyName System.Drawing",
232
+ "$img = [System.Windows.Forms.Clipboard]::GetImage()",
233
+ "if ($img) {",
234
+ "$ms = New-Object System.IO.MemoryStream",
235
+ "$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)",
236
+ "[Convert]::ToBase64String($ms.ToArray())",
237
+ "}"
238
+ ].join("; ");
239
+ const base64 = (await run("powershell.exe", [
240
+ "-NoProfile",
241
+ "-Command",
242
+ script
243
+ ], 67108864)).toString("utf8").trim();
244
+ return base64 ? toDataUrl(Buffer.from(base64, "base64")) : null;
245
+ };
246
+ /** Linux:Wayland优先,其次X11,最后用python3的tkinter兜底;命令未安装或剪贴板里不是图片时跳过 */
247
+ const readLinux = async () => {
248
+ for (const candidate of [{
249
+ command: "wl-paste",
250
+ args: [
251
+ "--no-newline",
252
+ "--type",
253
+ "image/png"
254
+ ]
255
+ }, {
256
+ command: "xclip",
257
+ args: [
258
+ "-selection",
259
+ "clipboard",
260
+ "-t",
261
+ "image/png",
262
+ "-o"
263
+ ]
264
+ }]) try {
265
+ const stdout = await run(candidate.command, candidate.args, MAX_CLIPBOARD_IMAGE_BYTES * 2);
266
+ if (stdout.length === 0) continue;
267
+ return candidate.base64 ? toDataUrl(Buffer.from(stdout.toString("ascii").trim(), "base64")) : toDataUrl(stdout);
268
+ } catch {}
269
+ return null;
270
+ };
271
+ /**
272
+ * 读取系统剪贴板里的图片
273
+ * @returns 剪贴板里没有图片、或当前环境拿不到时返回null
274
+ */
275
+ const readClipboardImage = async () => {
276
+ try {
277
+ switch (process.platform) {
278
+ case "win32": return await readWindows();
279
+ default: return await readLinux();
280
+ }
281
+ } catch {
282
+ return null;
283
+ }
284
+ };
285
+ //#endregion
286
+ //#region src/utils/internalHelper.ts
287
+ const formatter = new Intl.NumberFormat("en-US", {
288
+ notation: "compact",
289
+ maximumFractionDigits: 1
290
+ });
291
+ /** 把数字转换成千分位 */
292
+ const humanizeNumber = (number) => {
293
+ return formatter.format(number);
294
+ };
295
+ //#endregion
200
296
  //#region src/interfaces/tui.ts
201
297
  var TUI = class {
202
298
  rl = null;
203
299
  onPrompt = null;
300
+ /**
301
+ * 已粘贴、等待随下次请求一起发出的图片
302
+ * TODO: 改为pendingMedias
303
+ */
304
+ pendingImages = [];
305
+ /** 剪贴板读取是异步的,回车时要先等它结束,避免漏掉刚粘贴的图片 */
306
+ clipboardTask = Promise.resolve();
204
307
  /** content专用的markdown渲染器 */
205
308
  md = null;
206
309
  /** 是否允许输入 */
@@ -222,27 +325,57 @@ var TUI = class {
222
325
  input: process.stdin,
223
326
  output: process.stdout
224
327
  });
328
+ process.stdin.on("keypress", this.onKeypress);
225
329
  this.prompting();
226
330
  this.rl.on("close", () => {
331
+ process.stdin.off("keypress", this.onKeypress);
227
332
  this.rl?.close();
228
333
  this.rl = null;
229
334
  });
230
335
  }
336
+ /** 监听用户的特定按键 */
337
+ onKeypress = (_str, key) => {
338
+ if (this.isBusy) return;
339
+ if (key?.ctrl && key?.name === "v") this.clipboardTask = this.clipboardTask.then(() => this.attachImage());
340
+ };
231
341
  /** 监听用户输入 */
232
342
  prompting() {
233
343
  if (this.isBusy) return;
234
344
  this.rl?.question("> ", async (answer) => {
235
- const input = answer.trim();
236
- if (!input) {
345
+ await this.clipboardTask;
346
+ const text = answer.trim();
347
+ const images = this.pendingImages;
348
+ this.pendingImages = [];
349
+ if (!text && images.length === 0) {
237
350
  this.prompting();
238
351
  return;
239
352
  }
240
353
  this.isBusy = true;
241
- await this.onPrompt?.(input);
354
+ await this.onPrompt?.({
355
+ text,
356
+ images
357
+ });
242
358
  this.isBusy = false;
243
359
  this.prompting();
244
360
  });
245
361
  }
362
+ /** 读取剪贴板图片,推入待发送列表 */
363
+ async attachImage() {
364
+ const image = await readClipboardImage();
365
+ if (!image) {
366
+ this.printNotice("[剪贴板中没有图片]");
367
+ return;
368
+ }
369
+ if (image.bytes > 5242880) {
370
+ this.printNotice(`[图片过大(${humanizeNumber(image.bytes)}B),已忽略]`);
371
+ return;
372
+ }
373
+ this.pendingImages.push({
374
+ type: "image_url",
375
+ image_url: { url: image.url }
376
+ });
377
+ this.printNotice(`[已粘贴图片:共${this.pendingImages.length}张]`);
378
+ }
246
379
  /**
247
380
  * 所有print方法调用前:
248
381
  * 状态切换时打印换行,并记录当前状态
@@ -294,27 +427,33 @@ var TUI = class {
294
427
  this.preparePrint("tool_result");
295
428
  process.stdout.write(this.colorize(`[工具结果:${name}] ${_result}\n`, "90"));
296
429
  }
297
- formatter = new Intl.NumberFormat("en-US", {
298
- notation: "compact",
299
- maximumFractionDigits: 1
300
- });
301
- format(number) {
302
- return this.formatter.format(number);
303
- }
304
430
  /** 打印轮次结束原因、token消耗 */
305
431
  printFinish(finishReason, usage) {
306
432
  this.preparePrint("done");
307
- 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"));
433
+ process.stdout.write(this.colorize(`[本轮结束:${finishReason}] ${usage ? `输入${humanizeNumber(usage.prompt_tokens)},输出${humanizeNumber(usage.completion_tokens)},总共${humanizeNumber(usage.total_tokens)}` : ""}${usage?.speed ? `,速度${usage.speed}/秒` : ""}${finishReason === "stop" ? "\n\n" : "\n"}`, "90"));
308
434
  }
309
- /**
310
- * 打印一行黄色状态提示(如“等待MCP工具加载完成…”)
311
- */
312
- printStatus(message) {
313
- process.stdout.write(this.colorize(message, "93"));
435
+ /** 打印一行黄字提示,并重绘当前输入行 */
436
+ printNotice(message) {
437
+ process.stdout.write(`${this.colorize(message, "93")}\n`);
438
+ this.rl?.prompt(true);
314
439
  }
315
440
  };
316
441
  //#endregion
317
442
  //#region src/cli.ts
443
+ /**
444
+ * 组装用户消息的content
445
+ * @remarks 有图片时必须用ContentPart[](OpenAI多模态协议),纯文本则保持字符串
446
+ */
447
+ const buildUserContent = (text, images) => {
448
+ if (images.length === 0) return text;
449
+ const content = [];
450
+ if (text) content.push({
451
+ type: "text",
452
+ text
453
+ });
454
+ content.push(...images);
455
+ return content;
456
+ };
318
457
  /** 启动交互对话:配置来自全局配置文件,环境变量可临时覆盖 */
319
458
  const startChat = async () => {
320
459
  const config = loadConfig();
@@ -328,14 +467,17 @@ const startChat = async () => {
328
467
  mcpReady = true;
329
468
  return tools;
330
469
  });
331
- const messages = [];
470
+ const messages = [{
471
+ role: "system",
472
+ content: `你是${model.model},当前时间${(/* @__PURE__ */ new Date()).toLocaleString()}`
473
+ }];
332
474
  let tools = null;
333
- const tui = new TUI(async (input) => {
475
+ const tui = new TUI(async ({ text, images }) => {
334
476
  messages.push({
335
477
  role: "user",
336
- content: input
478
+ content: buildUserContent(text, images)
337
479
  });
338
- if (!mcpReady) tui.printStatus("等待MCP工具加载完成…");
480
+ if (!mcpReady) tui.printNotice("MCP工具还未加载完成,请稍候……");
339
481
  tools ??= [
340
482
  get_weather_default,
341
483
  get_time_default,
@@ -0,0 +1,65 @@
1
+ //#region src/utils/helper.ts
2
+ /**
3
+ * 辅助定义一个POST /chat/completions支持的model参数
4
+ * @remarks 只有baseUrl字段是必须的
5
+ */
6
+ const defineModel = (config) => ({
7
+ modalities: ["text"],
8
+ context: 131072,
9
+ ...config
10
+ });
11
+ /**
12
+ * 辅助定义一个POST /chat/completions支持的tool对象
13
+ * @param execute 实际执行工具的函数
14
+ */
15
+ const defineTool = (name, description, properties, execute) => {
16
+ const _required = [];
17
+ return {
18
+ type: "function",
19
+ function: {
20
+ name,
21
+ description,
22
+ parameters: {
23
+ type: "object",
24
+ properties: Object.entries(properties).reduce((result, [key, property]) => {
25
+ if ("required" in property) {
26
+ _required.push(key);
27
+ delete property.required;
28
+ }
29
+ result[key] = property;
30
+ return result;
31
+ }, {}),
32
+ required: _required
33
+ }
34
+ },
35
+ execute
36
+ };
37
+ };
38
+ const segmenter = new Intl.Segmenter([], { granularity: "word" });
39
+ const estimateTextTokens = (text) => {
40
+ let words = 0;
41
+ let others = 0;
42
+ for (const seg of segmenter.segment(text)) if (seg.isWordLike) words++;
43
+ else others++;
44
+ return Math.ceil(words * 1.5 + others / 4);
45
+ };
46
+ /**
47
+ * 根据上下文里的中/英文/多模态消息,估算出可能消耗的token
48
+ * - 单词 ≈ 1.5token
49
+ * - 标点/空白等非词字符每4个 ≈ 1token
50
+ * - 图片/音频/视频/文件 ≈ 4096token(不好估算,取个较大的值)
51
+ */
52
+ const estimateTokens = (messages) => {
53
+ if (!messages?.length) return 0;
54
+ return messages.reduce((acc, message) => {
55
+ const { content, tool_calls, ...metadata } = message;
56
+ if (typeof content === "string") acc += estimateTextTokens(content);
57
+ else for (const part of content) if (part.type === "text") acc += estimateTextTokens(part.text);
58
+ else acc += 4096;
59
+ if (tool_calls) acc += estimateTextTokens(JSON.stringify(tool_calls));
60
+ acc += estimateTextTokens(JSON.stringify(metadata));
61
+ return acc;
62
+ }, 0);
63
+ };
64
+ //#endregion
65
+ export { estimateTokens as i, defineTool as n, estimateTextTokens as r, defineModel as t };