@nickyzj2023/ai 1.4.4 → 1.5.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/dist/cli.mjs CHANGED
@@ -1,10 +1,11 @@
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 { a as get_time_default, i as get_weather_default, o as runAgent, r as loadMCPTools, s as defineModel } from "./src-C1Zeveeb.mjs";
2
2
  import { compactStr, extractErrorMessage, isObject } from "@nickyzj2023/utils";
3
3
  import readline from "node:readline";
4
4
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { MarkdownRenderer } from "@wterm/markdown";
8
+ import { execFile } from "node:child_process";
8
9
  //#region src/utils/config.ts
9
10
  /**
10
11
  * 获取全局配置文件路径(Windows/macOS/Linux通用)
@@ -197,10 +198,110 @@ async function runSetup() {
197
198
  }
198
199
  }
199
200
  //#endregion
201
+ //#region src/utils/clipboard.ts
202
+ /** 剪贴板图片的原始字节上限:超过则拒绝,避免把超大图片塞入请求体 */
203
+ const MAX_CLIPBOARD_IMAGE_BYTES = 5242880;
204
+ /**
205
+ * 执行命令并原样取回stdout(二进制安全)
206
+ * @param maxBuffer 允许的最大输出字节数
207
+ */
208
+ const run = (command, args, maxBuffer) => {
209
+ return new Promise((resolve, reject) => {
210
+ execFile(command, args, {
211
+ encoding: "buffer",
212
+ timeout: 5e3,
213
+ maxBuffer
214
+ }, (error, stdout) => {
215
+ if (error) reject(error);
216
+ else resolve(stdout);
217
+ });
218
+ });
219
+ };
220
+ /** 组装成OpenAI接口能接受的data URL */
221
+ const toDataUrl = (buffer, mime = "image/png") => ({
222
+ url: `data:${mime};base64,${buffer.toString("base64")}`,
223
+ bytes: buffer.length
224
+ });
225
+ /** Windows:用PowerShell把剪贴板里的图片转成base64后读出 */
226
+ const readWindows = async () => {
227
+ const script = [
228
+ "Add-Type -AssemblyName System.Windows.Forms",
229
+ "Add-Type -AssemblyName System.Drawing",
230
+ "$img = [System.Windows.Forms.Clipboard]::GetImage()",
231
+ "if ($img) {",
232
+ "$ms = New-Object System.IO.MemoryStream",
233
+ "$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)",
234
+ "[Convert]::ToBase64String($ms.ToArray())",
235
+ "}"
236
+ ].join("; ");
237
+ const base64 = (await run("powershell.exe", [
238
+ "-NoProfile",
239
+ "-Command",
240
+ script
241
+ ], 67108864)).toString("utf8").trim();
242
+ return base64 ? toDataUrl(Buffer.from(base64, "base64")) : null;
243
+ };
244
+ /** Linux:Wayland优先,其次X11,最后用python3的tkinter兜底;命令未安装或剪贴板里不是图片时跳过 */
245
+ const readLinux = async () => {
246
+ for (const candidate of [{
247
+ command: "wl-paste",
248
+ args: [
249
+ "--no-newline",
250
+ "--type",
251
+ "image/png"
252
+ ]
253
+ }, {
254
+ command: "xclip",
255
+ args: [
256
+ "-selection",
257
+ "clipboard",
258
+ "-t",
259
+ "image/png",
260
+ "-o"
261
+ ]
262
+ }]) try {
263
+ const stdout = await run(candidate.command, candidate.args, MAX_CLIPBOARD_IMAGE_BYTES * 2);
264
+ if (stdout.length === 0) continue;
265
+ return candidate.base64 ? toDataUrl(Buffer.from(stdout.toString("ascii").trim(), "base64")) : toDataUrl(stdout);
266
+ } catch {}
267
+ return null;
268
+ };
269
+ /**
270
+ * 读取系统剪贴板里的图片
271
+ * @returns 剪贴板里没有图片、或当前环境拿不到时返回null
272
+ */
273
+ const readClipboardImage = async () => {
274
+ try {
275
+ switch (process.platform) {
276
+ case "win32": return await readWindows();
277
+ default: return await readLinux();
278
+ }
279
+ } catch {
280
+ return null;
281
+ }
282
+ };
283
+ //#endregion
284
+ //#region src/utils/internalHelper.ts
285
+ const formatter = new Intl.NumberFormat("en-US", {
286
+ notation: "compact",
287
+ maximumFractionDigits: 1
288
+ });
289
+ /** 把数字转换成千分位 */
290
+ const humanizeNumber = (number) => {
291
+ return formatter.format(number);
292
+ };
293
+ //#endregion
200
294
  //#region src/interfaces/tui.ts
201
295
  var TUI = class {
202
296
  rl = null;
203
297
  onPrompt = null;
298
+ /**
299
+ * 已粘贴、等待随下次请求一起发出的图片
300
+ * TODO: 改为pendingMedias
301
+ */
302
+ pendingImages = [];
303
+ /** 剪贴板读取是异步的,回车时要先等它结束,避免漏掉刚粘贴的图片 */
304
+ clipboardTask = Promise.resolve();
204
305
  /** content专用的markdown渲染器 */
205
306
  md = null;
206
307
  /** 是否允许输入 */
@@ -222,27 +323,57 @@ var TUI = class {
222
323
  input: process.stdin,
223
324
  output: process.stdout
224
325
  });
326
+ process.stdin.on("keypress", this.onKeypress);
225
327
  this.prompting();
226
328
  this.rl.on("close", () => {
329
+ process.stdin.off("keypress", this.onKeypress);
227
330
  this.rl?.close();
228
331
  this.rl = null;
229
332
  });
230
333
  }
334
+ /** 监听用户的特定按键 */
335
+ onKeypress = (_str, key) => {
336
+ if (this.isBusy) return;
337
+ if (key?.ctrl && key?.name === "v") this.clipboardTask = this.clipboardTask.then(() => this.attachImage());
338
+ };
231
339
  /** 监听用户输入 */
232
340
  prompting() {
233
341
  if (this.isBusy) return;
234
342
  this.rl?.question("> ", async (answer) => {
235
- const input = answer.trim();
236
- if (!input) {
343
+ await this.clipboardTask;
344
+ const text = answer.trim();
345
+ const images = this.pendingImages;
346
+ this.pendingImages = [];
347
+ if (!text && images.length === 0) {
237
348
  this.prompting();
238
349
  return;
239
350
  }
240
351
  this.isBusy = true;
241
- await this.onPrompt?.(input);
352
+ await this.onPrompt?.({
353
+ text,
354
+ images
355
+ });
242
356
  this.isBusy = false;
243
357
  this.prompting();
244
358
  });
245
359
  }
360
+ /** 读取剪贴板图片,推入待发送列表 */
361
+ async attachImage() {
362
+ const image = await readClipboardImage();
363
+ if (!image) {
364
+ this.printNotice("[剪贴板中没有图片]");
365
+ return;
366
+ }
367
+ if (image.bytes > 5242880) {
368
+ this.printNotice(`[图片过大(${humanizeNumber(image.bytes)}B),已忽略]`);
369
+ return;
370
+ }
371
+ this.pendingImages.push({
372
+ type: "image_url",
373
+ image_url: { url: image.url }
374
+ });
375
+ this.printNotice(`[已粘贴图片:共${this.pendingImages.length}张]`);
376
+ }
246
377
  /**
247
378
  * 所有print方法调用前:
248
379
  * 状态切换时打印换行,并记录当前状态
@@ -294,27 +425,33 @@ var TUI = class {
294
425
  this.preparePrint("tool_result");
295
426
  process.stdout.write(this.colorize(`[工具结果:${name}] ${_result}\n`, "90"));
296
427
  }
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
428
  /** 打印轮次结束原因、token消耗 */
305
429
  printFinish(finishReason, usage) {
306
430
  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"));
431
+ 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
432
  }
309
- /**
310
- * 打印一行黄色状态提示(如“等待MCP工具加载完成…”)
311
- */
312
- printStatus(message) {
313
- process.stdout.write(this.colorize(message, "93"));
433
+ /** 打印一行黄字提示,并重绘当前输入行 */
434
+ printNotice(message) {
435
+ process.stdout.write(`${this.colorize(message, "93")}\n`);
436
+ this.rl?.prompt(true);
314
437
  }
315
438
  };
316
439
  //#endregion
317
440
  //#region src/cli.ts
441
+ /**
442
+ * 组装用户消息的content
443
+ * @remarks 有图片时必须用ContentPart[](OpenAI多模态协议),纯文本则保持字符串
444
+ */
445
+ const buildUserContent = (text, images) => {
446
+ if (images.length === 0) return text;
447
+ const content = [];
448
+ if (text) content.push({
449
+ type: "text",
450
+ text
451
+ });
452
+ content.push(...images);
453
+ return content;
454
+ };
318
455
  /** 启动交互对话:配置来自全局配置文件,环境变量可临时覆盖 */
319
456
  const startChat = async () => {
320
457
  const config = loadConfig();
@@ -328,14 +465,17 @@ const startChat = async () => {
328
465
  mcpReady = true;
329
466
  return tools;
330
467
  });
331
- const messages = [];
468
+ const messages = [{
469
+ role: "system",
470
+ content: `你是${model.model},当前时间${(/* @__PURE__ */ new Date()).toLocaleString()}`
471
+ }];
332
472
  let tools = null;
333
- const tui = new TUI(async (input) => {
473
+ const tui = new TUI(async ({ text, images }) => {
334
474
  messages.push({
335
475
  role: "user",
336
- content: input
476
+ content: buildUserContent(text, images)
337
477
  });
338
- if (!mcpReady) tui.printStatus("等待MCP工具加载完成…");
478
+ if (!mcpReady) tui.printNotice("MCP工具还未加载完成,请稍候……");
339
479
  tools ??= [
340
480
  get_weather_default,
341
481
  get_time_default,
package/dist/index.d.mts CHANGED
@@ -131,6 +131,8 @@ type Usage = {
131
131
  prompt_tokens: number;
132
132
  completion_tokens: number;
133
133
  total_tokens: number;
134
+ /** 每秒生成多少个token */
135
+ speed?: number;
134
136
  [key: string]: any;
135
137
  };
136
138
  type AgentEvent = LLMEvent | {
@@ -249,8 +251,15 @@ declare namespace Compact {
249
251
  */
250
252
  summarizeOptions?: Partial<Compact.SummarizeOptions>;
251
253
  };
252
- type ReplacerOfToolResultContent = (content: Message["content"], options?: Record<string, any>) => Promise<string> | string;
253
- type ReplacerOfMediaContent = (content: Message["content"], options?: Record<string, any>) => Promise<string> | string;
254
+ type ReplacerOfToolResultContent = (content: Message["content"], options?: {
255
+ model?: Model;
256
+ [key: string]: any;
257
+ }) => Promise<string> | string;
258
+ type ReplacerOfMediaContent = (content: Message["content"], options?: {
259
+ model?: Model;
260
+ modality?: Modality;
261
+ [key: string]: any;
262
+ }) => Promise<string> | string;
254
263
  type SummarizeOptions = {
255
264
  /** 指导大模型如何总结消息 */
256
265
  systemPrompt: string;
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as get_time_default, c as runAgent, i as get_weather_default, n as MCPRouter, o as defineModel, r as loadMCPTools, s as defineTool, t as compact } from "./src-CuZ-6Jye.mjs";
1
+ import { a as get_time_default, c as defineTool, i as get_weather_default, n as MCPRouter, o as runAgent, r as loadMCPTools, s as defineModel, t as compact } from "./src-C1Zeveeb.mjs";
2
2
  export { MCPRouter, compact, defineModel, defineTool, get_time_default as getTime, get_weather_default as getWeather, loadMCPTools, runAgent };
@@ -91,64 +91,6 @@ async function* stream(model, messages, tools = []) {
91
91
  };
92
92
  }
93
93
  //#endregion
94
- //#region src/agent.ts
95
- async function* runAgent(model, messages, tools) {
96
- const toolMap = new Map(tools.map((tool) => [tool.function.name, tool]));
97
- while (true) {
98
- let content = "";
99
- const toolCalls = [];
100
- for await (const e of stream(model, messages, tools)) switch (e.type) {
101
- case "reasoning_delta":
102
- yield e;
103
- break;
104
- case "content_delta":
105
- content += e.delta;
106
- yield e;
107
- break;
108
- case "tool_call":
109
- toolCalls.push({
110
- id: e.id,
111
- type: "function",
112
- function: {
113
- name: e.name,
114
- arguments: e.args
115
- }
116
- });
117
- yield e;
118
- break;
119
- case "done": yield e;
120
- }
121
- const message = {
122
- role: "assistant",
123
- content
124
- };
125
- if (toolCalls.length > 0) message.tool_calls = toolCalls;
126
- messages.push(message);
127
- if (toolCalls.length === 0) return;
128
- for (const call of toolCalls) {
129
- const { name, arguments: args } = call.function;
130
- const tool = toolMap.get(name);
131
- let result = "";
132
- if (!tool) result = `不存在工具“${name}”`;
133
- else {
134
- const [error, response] = await to(tool.execute(JSON.parse(args)));
135
- result = error ? `工具“${name}”执行出错:${error.message}` : JSON.stringify(response);
136
- }
137
- messages.push({
138
- role: "tool",
139
- tool_call_id: call.id,
140
- content: result
141
- });
142
- yield {
143
- type: "tool_result",
144
- id: call.id,
145
- name,
146
- result
147
- };
148
- }
149
- }
150
- }
151
- //#endregion
152
94
  //#region src/utils/helper.ts
153
95
  /**
154
96
  * 辅助定义一个POST /chat/completions支持的model参数
@@ -186,22 +128,22 @@ const defineTool = (name, description, properties, execute) => {
186
128
  execute
187
129
  };
188
130
  };
131
+ const segmenter = new Intl.Segmenter([], { granularity: "word" });
132
+ const estimateTextTokens = (text) => {
133
+ let words = 0;
134
+ let others = 0;
135
+ for (const seg of segmenter.segment(text)) if (seg.isWordLike) words++;
136
+ else others++;
137
+ return Math.ceil(words * 1.5 + others / 4);
138
+ };
189
139
  /**
190
140
  * 根据上下文里的中/英文/多模态消息,估算出可能消耗的token
191
141
  * - 单词 ≈ 1.5token
192
- * - 标点/空白等非词字符每 4 个 ≈ 1token
142
+ * - 标点/空白等非词字符每4个 ≈ 1token
193
143
  * - 图片/音频/视频/文件 ≈ 4096token(不好估算,取个较大的值)
194
144
  */
195
145
  const estimateTokens = (messages) => {
196
146
  if (!messages?.length) return 0;
197
- const segmenter = new Intl.Segmenter([], { granularity: "word" });
198
- const estimateTextTokens = (text) => {
199
- let words = 0;
200
- let others = 0;
201
- for (const seg of segmenter.segment(text)) if (seg.isWordLike) words++;
202
- else others++;
203
- return Math.ceil(words * 1.5 + others / 4);
204
- };
205
147
  return messages.reduce((acc, message) => {
206
148
  const { content, tool_calls, ...metadata } = message;
207
149
  if (typeof content === "string") acc += estimateTextTokens(content);
@@ -213,6 +155,85 @@ const estimateTokens = (messages) => {
213
155
  }, 0);
214
156
  };
215
157
  //#endregion
158
+ //#region src/agent.ts
159
+ async function* runAgent(model, messages, tools) {
160
+ const toolMap = new Map(tools.map((tool) => [tool.function.name, tool]));
161
+ while (true) {
162
+ let content = "";
163
+ let reasoning = "";
164
+ const toolCalls = [];
165
+ const promptMessage = messages.slice(-1);
166
+ let startTime = 0;
167
+ for await (const e of stream(model, messages, tools)) {
168
+ startTime ||= Date.now();
169
+ switch (e.type) {
170
+ case "reasoning_delta":
171
+ reasoning += e.delta;
172
+ yield e;
173
+ break;
174
+ case "content_delta":
175
+ content += e.delta;
176
+ yield e;
177
+ break;
178
+ case "tool_call":
179
+ toolCalls.push({
180
+ id: e.id,
181
+ type: "function",
182
+ function: {
183
+ name: e.name,
184
+ arguments: e.args
185
+ }
186
+ });
187
+ yield e;
188
+ break;
189
+ case "error":
190
+ yield e;
191
+ break;
192
+ case "done":
193
+ if (!e.usage) {
194
+ const promptTokens = estimateTokens(promptMessage);
195
+ const completionTokens = estimateTextTokens(content + reasoning);
196
+ e.usage = {
197
+ prompt_tokens: promptTokens,
198
+ completion_tokens: completionTokens,
199
+ total_tokens: estimateTokens(messages) + promptTokens + completionTokens
200
+ };
201
+ }
202
+ e.usage.speed = Math.floor(e.usage.completion_tokens / ((Date.now() - startTime) / 1e3));
203
+ yield e;
204
+ }
205
+ }
206
+ const message = {
207
+ role: "assistant",
208
+ content
209
+ };
210
+ if (toolCalls.length > 0) message.tool_calls = toolCalls;
211
+ messages.push(message);
212
+ if (toolCalls.length === 0) return;
213
+ for (const call of toolCalls) {
214
+ const { name, arguments: args } = call.function;
215
+ const tool = toolMap.get(name);
216
+ let result = "";
217
+ if (!tool) result = `不存在工具“${name}”`;
218
+ else {
219
+ const [error, response] = await to(tool.execute(JSON.parse(args)));
220
+ result = error ? `工具“${name}”执行出错:${error.message}` : JSON.stringify(response);
221
+ }
222
+ messages.push({
223
+ role: "tool",
224
+ tool_call_id: call.id,
225
+ content: result
226
+ });
227
+ yield {
228
+ type: "tool_result",
229
+ id: call.id,
230
+ name,
231
+ result
232
+ };
233
+ }
234
+ }
235
+ }
236
+ //#endregion
216
237
  //#region src/tools/get-time.ts
217
238
  var get_time_default = defineTool("get_time", "查询指定时区的当前时间", { timezone: {
218
239
  type: "string",
@@ -296,12 +317,11 @@ const isToolCalls = (message) => {
296
317
  * 校验多模态消息
297
318
  */
298
319
  const isMediaMessage = (message) => {
299
- const MEDIA_TYPES = [
300
- "image_url",
301
- "input_audio",
302
- "video_url"
303
- ];
304
- return message && Array.isArray(message.content) && message.content.some((part) => MEDIA_TYPES.includes(part.type));
320
+ if (message && Array.isArray(message.content)) for (const part of message.content) {
321
+ if (part.type === "image_url") return "image";
322
+ if (part.type === "input_audio") return "audio";
323
+ if (part.type === "video_url") return "video";
324
+ }
305
325
  };
306
326
  /**
307
327
  * 查找assistant(tool_calls) + tool配对组范围
@@ -330,7 +350,8 @@ const defaultReplacerOfToolResultContent = async (content, options) => {
330
350
  content: "请用一句话简述上面这条消息(直击要点,不要添加任何前言后语)"
331
351
  }];
332
352
  let simplifiedContent = "";
333
- for await (const e of runAgent(model, messages, [])) if (e.type === "content_delta") simplifiedContent += e.delta;
353
+ if (!model) simplifiedContent = "工具结果已被消费";
354
+ else for await (const e of runAgent(model, messages, [])) if (e.type === "content_delta") simplifiedContent += e.delta;
334
355
  else if (e.type === "error") throw new Error(e.message);
335
356
  return simplifiedContent;
336
357
  };
@@ -352,7 +373,7 @@ const compactToolResults = async (compressible, options) => {
352
373
  };
353
374
  /** 默认的压缩多模态消息策略:让大模型精简消息内容 */
354
375
  const defaultReplacerOfMediaContent = async (content, options) => {
355
- const { model } = options ?? {};
376
+ const { model, modality = "image" } = options ?? {};
356
377
  const messages = [{
357
378
  role: "user",
358
379
  content
@@ -361,7 +382,8 @@ const defaultReplacerOfMediaContent = async (content, options) => {
361
382
  content: "请用一句话简述上面这条消息(直击要点,不要添加任何前言后语)"
362
383
  }];
363
384
  let simplifiedContent = "";
364
- for await (const e of runAgent(model, messages, [])) if (e.type === "content_delta") simplifiedContent += e.delta;
385
+ if (!model?.modalities?.includes(modality)) simplifiedContent = "媒体资源已被消费";
386
+ else for await (const e of runAgent(model, messages, [])) if (e.type === "content_delta") simplifiedContent += e.delta;
365
387
  else if (e.type === "error") throw new Error(e.message);
366
388
  return simplifiedContent;
367
389
  };
@@ -373,10 +395,16 @@ const compactMediaMessages = async (compressible, options) => {
373
395
  if (!replacer && !model) return 0;
374
396
  const _replacer = replacer || defaultReplacerOfMediaContent;
375
397
  let count = 0;
376
- for (const message of compressible) if (isMediaMessage(message)) {
377
- const compacted = await _replacer(message.content, { model });
378
- message.content = createXMLText("media", compacted);
379
- count++;
398
+ for (const message of compressible) {
399
+ const mediaModality = isMediaMessage(message);
400
+ if (mediaModality) {
401
+ const compacted = await _replacer(message.content, {
402
+ model,
403
+ modality: mediaModality
404
+ });
405
+ message.content = createXMLText("media", compacted);
406
+ count++;
407
+ }
380
408
  }
381
409
  if (count > 0) logger(`压缩了${count}条多模态消息`);
382
410
  return count;
@@ -484,4 +512,4 @@ const compact = Object.assign(async (messages, model, options) => {
484
512
  discardMessagesUntil
485
513
  });
486
514
  //#endregion
487
- export { get_time_default as a, runAgent as c, get_weather_default as i, MCPRouter as n, defineModel as o, loadMCPTools as r, defineTool as s, compact as t };
515
+ export { get_time_default as a, defineTool as c, get_weather_default as i, MCPRouter as n, runAgent as o, loadMCPTools as r, defineModel as s, compact as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nickyzj2023/ai",
3
- "version": "1.4.4",
3
+ "version": "1.5.0",
4
4
  "description": "我的“pi”,参考了pi-from-scratch",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "devDependencies": {
29
29
  "@biomejs/biome": "^2.5.12",
30
- "@types/node": "^26.4.1",
30
+ "@types/node": "^26.5.0",
31
31
  "tsdown": "^0.22.14",
32
32
  "typescript": "^7.0.2"
33
33
  },