@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 +80 -64
- package/dist/cli.mjs +163 -21
- package/dist/helper-juLVGxlQ.mjs +65 -0
- package/dist/index.d.mts +29 -197
- package/dist/index.mjs +3 -2
- package/dist/mcp-DcntQg-a.mjs +58 -0
- package/dist/{src-CuZ-6Jye.mjs → src-CKKPKPMG.mjs} +114 -192
- package/dist/tools/mcp.d.mts +55 -0
- package/dist/tools/mcp.mjs +2 -0
- package/dist/types--dXFoUow.d.mts +144 -0
- package/package.json +6 -2
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
|
-

|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
1
|
+
# ai
|
|
2
|
+
|
|
3
|
+
男生自用Coding Agent,参考了[nanopi教学](https://pi-from-scratch.vercel.app/)
|
|
4
|
+
|
|
5
|
+
不断学习+开发中,当前项目仍属于比nano还简陋的“bare-pi”,but one day...
|
|
6
|
+
|
|
7
|
+

|
|
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 {
|
|
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
|
-
|
|
236
|
-
|
|
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?.(
|
|
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 ? `输入${
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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 (
|
|
475
|
+
const tui = new TUI(async ({ text, images }) => {
|
|
334
476
|
messages.push({
|
|
335
477
|
role: "user",
|
|
336
|
-
content:
|
|
478
|
+
content: buildUserContent(text, images)
|
|
337
479
|
});
|
|
338
|
-
if (!mcpReady) tui.
|
|
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 };
|