@ganziliang/kb 0.3.0 → 0.4.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 +38 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +166 -26
- package/dist/model.d.ts +17 -0
- package/dist/model.js +116 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -190,8 +190,46 @@ import D:\docs\api.txt
|
|
|
190
190
|
导入 "D:\截图\支付报错.png"
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
+
**路径写法很宽容**,下面这几种都能识别(只要文件真实存在):
|
|
194
|
+
|
|
195
|
+
```text
|
|
196
|
+
录入 D:\资料\支付渠道配置.md到知识库 # 路径后粘了中文
|
|
197
|
+
导入D:\资料\支付渠道配置.md # 关键词后没空格
|
|
198
|
+
"D:\资料\文件名字带空格.md" # 带空格的路径用引号包起来
|
|
199
|
+
D:\资料\支付渠道配置.md # 直接拖文件进来,不带任何关键词
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
路径识别走的是本地检查(`statSync`),**不消耗模型调用**。
|
|
203
|
+
|
|
193
204
|
导入后会复制一份原始文件,并建立版本记录。对同一个文件再次导入时,会创建新版本,不会直接覆盖旧版本。
|
|
194
205
|
|
|
206
|
+
如果路径不存在或是目录,会直接给提示而不去问模型;目录暂不支持导入,请指定具体文件。
|
|
207
|
+
|
|
208
|
+
### 让 kb 读写本地文件
|
|
209
|
+
|
|
210
|
+
除了导入,kb 也把文件读写作为工具交给了模型,所以可以直接用自然语言:
|
|
211
|
+
|
|
212
|
+
```text
|
|
213
|
+
帮我看看 D:\资料\支付渠道配置.md 写了什么? → 读取文件后回答
|
|
214
|
+
把这段内容导出到 D:\out\纪要.md → 写入文件
|
|
215
|
+
把 D:\资料\渠道.md 收录到知识库 → 导入知识库
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
对应的工具:
|
|
219
|
+
|
|
220
|
+
| 工具 | 作用 |
|
|
221
|
+
| --- | --- |
|
|
222
|
+
| `save_knowledge` | 把对话里的文本存进知识库 |
|
|
223
|
+
| `save_from_file` | 把磁盘文件导入知识库(只传路径,正文由程序读取) |
|
|
224
|
+
| `read_file` | 读取文件内容(不写进知识库) |
|
|
225
|
+
| `write_file` | 把内容写入指定文件 |
|
|
226
|
+
|
|
227
|
+
模型可能多轮调用这些工具(例如先 `read_file` 再回答,或直接 `save_from_file` 完成导入),
|
|
228
|
+
每轮最多 5 次工具调用。
|
|
229
|
+
|
|
230
|
+
> 注意:`read_file` / `write_file` 让模型可以访问你本机的文件。kb 是本地工具,
|
|
231
|
+
> 在你自己的机器上运行,但请不要在输入里让它处理你不想被读取的敏感路径。
|
|
232
|
+
|
|
195
233
|
### 直接录入文本
|
|
196
234
|
|
|
197
235
|
不需要先把内容存成文件,也不需要记命令——用自然语言告诉 kb 就行:
|
package/dist/cli.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
3
4
|
import { Container, Editor, Loader, ProcessTerminal, SelectList, Spacer, TuiMainScreen, matchesKey, } from "@earendil-works/pi-tui";
|
|
4
5
|
import { ensureModelConfig, saveModels } from "./config.js";
|
|
5
|
-
import { SAVE_KNOWLEDGE_TOOL, describeImage, fetchTransport } from "./model.js";
|
|
6
|
+
import { READ_FILE_TOOL, SAVE_FROM_FILE_TOOL, SAVE_KNOWLEDGE_TOOL, WRITE_FILE_TOOL, describeImage, fetchTransport, } from "./model.js";
|
|
6
7
|
import { backupKnowledgeBase, copyOriginal, defaultDataRoot, deleteKnowledgeBase, ensureKnowledgeBase, isImageFile, KnowledgeStore, listKnowledgeBases, readImage, readLocalFile, restoreKnowledgeBase, writeNote, } from "./storage.js";
|
|
7
8
|
import { color, editorTheme, selectListTheme } from "./theme.js";
|
|
8
9
|
import { AssistantMessage, HelpPanel, ImageNotice, Notice, Rule, Splash, UserMessage } from "./ui.js";
|
|
@@ -20,6 +21,51 @@ function deriveTitle(content) {
|
|
|
20
21
|
const base = (heading ? heading[1] : firstLine).replace(/[#*`>_]/g, "").trim();
|
|
21
22
|
return base.slice(0, 40) || "���记";
|
|
22
23
|
}
|
|
24
|
+
/** 工具回传给模型的最大字符数,避免单次工具结果爆掉上下文。 */
|
|
25
|
+
const MAX_TOOL_CHARS = 8000;
|
|
26
|
+
/** 只接受真实存在的「文件」,目录不算(否则会把目录当成待导入文件)。 */
|
|
27
|
+
function isFile(path) {
|
|
28
|
+
try {
|
|
29
|
+
return statSync(path).isFile();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function isDirectory(path) {
|
|
36
|
+
try {
|
|
37
|
+
return statSync(path).isDirectory();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 从输入里找出一个真实存在的文件路径。
|
|
45
|
+
* 容忍「录入 D:\a.md」与「录入D:\a.md到知识库」两种写法:
|
|
46
|
+
* 后者路径尾部粘了中文,靠逐步截断 + 文件存在性检查回退。
|
|
47
|
+
*/
|
|
48
|
+
function extractExistingPath(text) {
|
|
49
|
+
for (const match of text.matchAll(/["'`]([^"'`]+)["'`]/g)) {
|
|
50
|
+
const candidate = match[1].trim();
|
|
51
|
+
if (isFile(candidate))
|
|
52
|
+
return candidate;
|
|
53
|
+
}
|
|
54
|
+
const candidates = text.match(/[A-Za-z]:[\\/][^\s"'`|<>*?]+|\/[^\s"'`|<>*?]+|\.{1,2}[\\/][^\s"'`|<>*?]+/g) ?? [];
|
|
55
|
+
for (const candidate of candidates) {
|
|
56
|
+
if (isFile(candidate))
|
|
57
|
+
return candidate;
|
|
58
|
+
const limit = Math.min(candidate.length - 1, 40);
|
|
59
|
+
for (let cut = 1; cut <= limit; cut += 1) {
|
|
60
|
+
const probe = candidate.slice(0, candidate.length - cut);
|
|
61
|
+
if (isFile(probe))
|
|
62
|
+
return probe;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
/** 输入里出现这些词、且能找到真实路径时,直接本地导入(不消耗模型)。 */
|
|
68
|
+
const IMPORT_WORDS = /(?:录入|导入|收录|整理|加到|加入|添加|入库|ingest|import)/i;
|
|
23
69
|
/** 多行内容回显时只显示首行,避免刷屏。 */
|
|
24
70
|
function shortPreview(text) {
|
|
25
71
|
const lines = text.split("\n");
|
|
@@ -37,10 +83,20 @@ function buildSystemPrompt(context) {
|
|
|
37
83
|
" 用「知识库内容」回答,并标注来源文件名与版本。",
|
|
38
84
|
" 内容不足以回答时明确说明没有找到,不要编造。",
|
|
39
85
|
"",
|
|
40
|
-
"B)
|
|
41
|
-
" 调用 save_knowledge
|
|
86
|
+
"B) 录入文本 —— 用户直接给你一段内容想长期保存。",
|
|
87
|
+
" 调用 save_knowledge(title, content),title 用一句话概括,content 用规范 Markdown 完整保留用户信息。",
|
|
42
88
|
" 此时不要回答内容本身,也不要反问确认。",
|
|
43
89
|
"",
|
|
90
|
+
"C) 录入文件 —— 用户给了一个文件路径,想归档到知识库。",
|
|
91
|
+
" 调用 save_from_file(path, title)。",
|
|
92
|
+
" 重要:只传路径,不要先 read_file 再把内容抄进 save_knowledge,也不要说你无法访问本地文件。",
|
|
93
|
+
"",
|
|
94
|
+
"D) 查看文件 —— 用户想知道某个文件里写了什么。",
|
|
95
|
+
" 调用 read_file(path),然后根据读到的内容回答。",
|
|
96
|
+
"",
|
|
97
|
+
"E) 导出文件 —— 用户要求把内容写成文件、导出到某个路径。",
|
|
98
|
+
" 调用 write_file(path, content)。",
|
|
99
|
+
"",
|
|
44
100
|
"判断依据是用户表达的意图,不要依赖固定关键词。",
|
|
45
101
|
"",
|
|
46
102
|
"知识库内容:",
|
|
@@ -190,9 +246,32 @@ export class KbApp {
|
|
|
190
246
|
await this.run(value);
|
|
191
247
|
}
|
|
192
248
|
async run(value) {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
249
|
+
const trimmed = value.trim();
|
|
250
|
+
const looksLikePath = /[A-Za-z]:[\\/]|(?:^|[\s"'])~?[.\/]/.test(trimmed) || isDirectory(trimmed);
|
|
251
|
+
// 既有导入意图、又能定位到真实文件:直接本地导入,不消耗模型。
|
|
252
|
+
if (IMPORT_WORDS.test(value)) {
|
|
253
|
+
const path = extractExistingPath(value);
|
|
254
|
+
if (path) {
|
|
255
|
+
await this.ingest(path);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (isDirectory(trimmed)) {
|
|
259
|
+
this.add(new Notice(ROLE.warn, color.warn, `${trimmed} 是一个目录,请指定具体文件(目录暂不支持导入)。`));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (looksLikePath) {
|
|
263
|
+
this.add(new Notice(ROLE.err, color.err, "没有找到这个文件,请确认路径是否正确。"));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// 整条输入就是一个存在的文件路径(例如直接把文件拖进来)。
|
|
268
|
+
const direct = extractExistingPath(trimmed);
|
|
269
|
+
if (direct && direct === trimmed) {
|
|
270
|
+
await this.ingest(direct);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (isDirectory(trimmed)) {
|
|
274
|
+
this.add(new Notice(ROLE.warn, color.warn, `${trimmed} 是一个目录,请指定具体文件(目录暂不支持导入)。`));
|
|
196
275
|
return;
|
|
197
276
|
}
|
|
198
277
|
await this.ask(value);
|
|
@@ -215,7 +294,7 @@ export class KbApp {
|
|
|
215
294
|
}
|
|
216
295
|
}
|
|
217
296
|
// ---------- 导入 ----------
|
|
218
|
-
async ingest(path) {
|
|
297
|
+
async ingest(path, explicitTitle = "") {
|
|
219
298
|
try {
|
|
220
299
|
this.setBusy(true, "读取文件…");
|
|
221
300
|
const source = readLocalFile(path);
|
|
@@ -238,8 +317,9 @@ export class KbApp {
|
|
|
238
317
|
note = ",图片描述生成失败(仅按文件名检索)";
|
|
239
318
|
}
|
|
240
319
|
}
|
|
320
|
+
const title = explicitTitle.trim() || source.title;
|
|
241
321
|
const copied = copyOriginal(source.originalPath, this.store.root);
|
|
242
|
-
const result = this.store.addOrUpdateSource(copied,
|
|
322
|
+
const result = this.store.addOrUpdateSource(copied, title, content);
|
|
243
323
|
this.add(new Notice(ROLE.ok, color.ok, `${result.isNew ? "已导入" : "已创建新版本"} ${result.title}(v${result.version})${note}`));
|
|
244
324
|
}
|
|
245
325
|
catch (error) {
|
|
@@ -274,27 +354,44 @@ export class KbApp {
|
|
|
274
354
|
}
|
|
275
355
|
catch { /* 跳过无法读取的图片 */ }
|
|
276
356
|
}
|
|
277
|
-
|
|
278
|
-
const
|
|
357
|
+
const tools = [SAVE_KNOWLEDGE_TOOL, SAVE_FROM_FILE_TOOL, READ_FILE_TOOL, WRITE_FILE_TOOL];
|
|
358
|
+
const messages = [
|
|
279
359
|
{ role: "system", content: buildSystemPrompt(context) },
|
|
280
360
|
...this.messages,
|
|
281
361
|
{ role: "user", content: questionBlocks.length > 1 ? questionBlocks : question },
|
|
282
|
-
]
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
this.setBusy(true,
|
|
286
|
-
|
|
287
|
-
|
|
362
|
+
];
|
|
363
|
+
// 工具循环:模型可能先 read_file 再回答,或者直接调 save_* 完成录入。
|
|
364
|
+
for (let round = 0; round < 5; round += 1) {
|
|
365
|
+
this.setBusy(true, round === 0
|
|
366
|
+
? (attached.length ? `检索到 ${results.length} 条,附图 ${attached.length} 张,正在理解意图…` : `检索到 ${results.length} 条,正在理解意图…`)
|
|
367
|
+
: "正在继续处理…");
|
|
368
|
+
const reply = await fetchTransport.complete(config, messages, tools);
|
|
369
|
+
if (!reply.toolCalls?.length) {
|
|
370
|
+
const answer = (reply.text ?? "").trim();
|
|
371
|
+
this.messages = [...this.messages, { role: "user", content: question }, { role: "assistant", content: answer }];
|
|
372
|
+
this.store.saveMessage("user", question);
|
|
373
|
+
this.store.saveMessage("assistant", answer);
|
|
374
|
+
for (const item of attached)
|
|
375
|
+
this.add(new ImageNotice(item.name, item.version));
|
|
376
|
+
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
|
377
|
+
const meta = `检索 ${results.length} 条 · 用时 ${seconds}s` + (attached.length ? ` · 附图 ${attached.length} 张` : "");
|
|
378
|
+
this.add(new AssistantMessage(answer || "(模型返回空回答)", meta));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
messages.push({ role: "assistant", content: reply.text ?? "", toolCalls: reply.toolCalls });
|
|
382
|
+
let finished = false;
|
|
383
|
+
for (const call of reply.toolCalls) {
|
|
384
|
+
const outcome = await this.runTool(call);
|
|
385
|
+
if (outcome.done) {
|
|
386
|
+
finished = true;
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
messages.push({ role: "tool", toolCallId: call.id, content: outcome.text });
|
|
390
|
+
}
|
|
391
|
+
if (finished)
|
|
392
|
+
return;
|
|
288
393
|
}
|
|
289
|
-
|
|
290
|
-
this.messages = [...this.messages, { role: "user", content: question }, { role: "assistant", content: answer }];
|
|
291
|
-
this.store.saveMessage("user", question);
|
|
292
|
-
this.store.saveMessage("assistant", answer);
|
|
293
|
-
for (const item of attached)
|
|
294
|
-
this.add(new ImageNotice(item.name, item.version));
|
|
295
|
-
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
|
296
|
-
const meta = `检索 ${results.length} 条 · 用时 ${seconds}s` + (attached.length ? ` · 附图 ${attached.length} 张` : "");
|
|
297
|
-
this.add(new AssistantMessage(answer || "(模型返回空回答)", meta));
|
|
394
|
+
this.add(new Notice(ROLE.warn, color.warn, "工具调用轮次过多,已停止。"));
|
|
298
395
|
}
|
|
299
396
|
catch (error) {
|
|
300
397
|
this.add(new Notice(ROLE.err, color.err, `模型请求失败:${errText(error)}`));
|
|
@@ -303,6 +400,49 @@ export class KbApp {
|
|
|
303
400
|
this.setBusy(false);
|
|
304
401
|
}
|
|
305
402
|
}
|
|
403
|
+
/** 执行一次工具调用;done 为 true 表示任务已完成,无需再回模型。 */
|
|
404
|
+
async runTool(call) {
|
|
405
|
+
const arg = (key) => String(call.arguments[key] ?? "").trim();
|
|
406
|
+
if (call.name === SAVE_KNOWLEDGE_TOOL.name) {
|
|
407
|
+
this.setBusy(true, "正在录入知识库…");
|
|
408
|
+
await this.addNote(arg("content"), arg("title"));
|
|
409
|
+
return { done: true, text: "已录入知识库" };
|
|
410
|
+
}
|
|
411
|
+
if (call.name === SAVE_FROM_FILE_TOOL.name) {
|
|
412
|
+
this.setBusy(true, "正在读取文件…");
|
|
413
|
+
await this.ingest(arg("path"), arg("title"));
|
|
414
|
+
return { done: true, text: "已从文件导入知识库" };
|
|
415
|
+
}
|
|
416
|
+
if (call.name === READ_FILE_TOOL.name) {
|
|
417
|
+
const path = arg("path");
|
|
418
|
+
this.setBusy(true, `正在读取 ${path.split(/[\\/]/).pop() ?? "文件"}…`);
|
|
419
|
+
try {
|
|
420
|
+
const source = readLocalFile(path);
|
|
421
|
+
const clipped = source.content.length > MAX_TOOL_CHARS
|
|
422
|
+
? `${source.content.slice(0, MAX_TOOL_CHARS)}\n…(已截断,全文共 ${source.content.length} 字符)`
|
|
423
|
+
: source.content;
|
|
424
|
+
this.add(new Notice(ROLE.ok, color.ok, `已读取 ${source.title}(${source.content.length} 字符)`));
|
|
425
|
+
return { done: false, text: `文件 ${source.title} 的内容如下:\n\n${clipped}` };
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
return { done: false, text: `读取失败:${errText(error)}` };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (call.name === WRITE_FILE_TOOL.name) {
|
|
432
|
+
const path = arg("path");
|
|
433
|
+
const content = String(call.arguments.content ?? "");
|
|
434
|
+
try {
|
|
435
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
436
|
+
writeFileSync(path, content, "utf8");
|
|
437
|
+
this.add(new Notice(ROLE.ok, color.ok, `已写入 ${path}(${content.length} 字符)`));
|
|
438
|
+
return { done: false, text: `已写入 ${path}` };
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
return { done: false, text: `写入失败:${errText(error)}` };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return { done: false, text: `未知工具:${call.name}` };
|
|
445
|
+
}
|
|
306
446
|
// ---------- 命令 ----------
|
|
307
447
|
async command(value) {
|
|
308
448
|
const [cmd, ...rest] = value.split(/\s+/);
|
package/dist/model.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ export type ContentBlock = {
|
|
|
21
21
|
export type ModelMessage = {
|
|
22
22
|
role: "system" | "user" | "assistant" | "tool";
|
|
23
23
|
content: string | ContentBlock[];
|
|
24
|
+
/** assistant 消息回传时携带的工具调用(tool_use / function_call)。 */
|
|
25
|
+
toolCalls?: NativeToolCall[];
|
|
26
|
+
/** role 为 tool 时,关联的 tool_use id。 */
|
|
24
27
|
toolCallId?: string;
|
|
25
28
|
};
|
|
26
29
|
export type ModelReply = {
|
|
@@ -30,6 +33,10 @@ export type ModelReply = {
|
|
|
30
33
|
export declare function toAnthropicContent(content: string | ContentBlock[]): unknown;
|
|
31
34
|
export declare function toOpenAIContent(content: string | ContentBlock[]): unknown;
|
|
32
35
|
export declare function contentToText(content: string | ContentBlock[]): string;
|
|
36
|
+
/** 把内部消息数组转成 Anthropic Messages API 的 messages(tool_result 必须放进 user 消息)。 */
|
|
37
|
+
export declare function toAnthropicMessages(messages: ModelMessage[]): unknown[];
|
|
38
|
+
/** 把内部消息数组转成 OpenAI Responses API 的 input。 */
|
|
39
|
+
export declare function toOpenAIInput(messages: ModelMessage[]): unknown[];
|
|
33
40
|
export type ModelTransport = {
|
|
34
41
|
complete(config: ModelConfig, messages: ModelMessage[], tools?: ToolDefinition[]): Promise<ModelReply>;
|
|
35
42
|
};
|
|
@@ -51,6 +58,16 @@ export declare function formatResults(results: SearchResult[]): string;
|
|
|
51
58
|
* 避免为了判断意图而多跑一次模型调用。
|
|
52
59
|
*/
|
|
53
60
|
export declare const SAVE_KNOWLEDGE_TOOL: ToolDefinition;
|
|
61
|
+
/**
|
|
62
|
+
* 从本地文件导入知识。
|
|
63
|
+
* 单独做成一个工具,是为了避免让模型把整份文件内容搬进参数里
|
|
64
|
+
* (大文件会直接爆掉输出 token):模型只给路径,正文由本地读取。
|
|
65
|
+
*/
|
|
66
|
+
export declare const SAVE_FROM_FILE_TOOL: ToolDefinition;
|
|
67
|
+
/** 读取本地文件内容(用于回答关于文件的问题,不会写进知识库)。 */
|
|
68
|
+
export declare const READ_FILE_TOOL: ToolDefinition;
|
|
69
|
+
/** 写入本地文件。 */
|
|
70
|
+
export declare const WRITE_FILE_TOOL: ToolDefinition;
|
|
54
71
|
type VisionTransport = {
|
|
55
72
|
complete: (config: ModelConfig, messages: ModelMessage[]) => Promise<ModelReply>;
|
|
56
73
|
};
|
package/dist/model.js
CHANGED
|
@@ -15,6 +15,63 @@ export function toOpenAIContent(content) {
|
|
|
15
15
|
export function contentToText(content) {
|
|
16
16
|
return typeof content === "string" ? content : content.map((block) => block.type === "text" ? block.text : `[image:${block.mediaType}]`).join("\n");
|
|
17
17
|
}
|
|
18
|
+
/** 把内部消息数组转成 Anthropic Messages API 的 messages(tool_result 必须放进 user 消息)。 */
|
|
19
|
+
export function toAnthropicMessages(messages) {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const message of messages) {
|
|
22
|
+
if (message.role === "system")
|
|
23
|
+
continue;
|
|
24
|
+
if (message.role === "tool") {
|
|
25
|
+
const block = {
|
|
26
|
+
type: "tool_result",
|
|
27
|
+
tool_use_id: message.toolCallId ?? "",
|
|
28
|
+
content: typeof message.content === "string" ? message.content : contentToText(message.content),
|
|
29
|
+
};
|
|
30
|
+
const last = out[out.length - 1];
|
|
31
|
+
if (last && last.role === "user" && Array.isArray(last.content))
|
|
32
|
+
last.content.push(block);
|
|
33
|
+
else
|
|
34
|
+
out.push({ role: "user", content: [block] });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (message.role === "assistant" && message.toolCalls?.length) {
|
|
38
|
+
const blocks = [];
|
|
39
|
+
const text = typeof message.content === "string" ? message.content : contentToText(message.content);
|
|
40
|
+
if (text)
|
|
41
|
+
blocks.push({ type: "text", text });
|
|
42
|
+
for (const call of message.toolCalls)
|
|
43
|
+
blocks.push({ type: "tool_use", id: call.id, name: call.name, input: call.arguments });
|
|
44
|
+
out.push({ role: "assistant", content: blocks });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
out.push({ role: message.role, content: toAnthropicContent(message.content) });
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/** 把内部消息数组转成 OpenAI Responses API 的 input。 */
|
|
52
|
+
export function toOpenAIInput(messages) {
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const message of messages) {
|
|
55
|
+
if (message.role === "tool") {
|
|
56
|
+
out.push({
|
|
57
|
+
type: "function_call_output",
|
|
58
|
+
call_id: message.toolCallId ?? "",
|
|
59
|
+
output: typeof message.content === "string" ? message.content : contentToText(message.content),
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (message.role === "assistant" && message.toolCalls?.length) {
|
|
64
|
+
const text = typeof message.content === "string" ? message.content : contentToText(message.content);
|
|
65
|
+
if (text)
|
|
66
|
+
out.push({ role: "assistant", content: toOpenAIContent(text) });
|
|
67
|
+
for (const call of message.toolCalls)
|
|
68
|
+
out.push({ type: "function_call", call_id: call.id, name: call.name, arguments: JSON.stringify(call.arguments) });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
out.push({ role: message.role, content: toOpenAIContent(message.content) });
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
18
75
|
// 历史消息里的图片 base64 不重复携带:每轮都会重新检索并附图,避免上下文膨胀。
|
|
19
76
|
function compactHistory(messages) {
|
|
20
77
|
return messages.map((message) => typeof message.content === "string" ? message : { ...message, content: contentToText(message.content) });
|
|
@@ -41,8 +98,8 @@ export const fetchTransport = {
|
|
|
41
98
|
method: "POST",
|
|
42
99
|
headers,
|
|
43
100
|
body: JSON.stringify(config.api === "anthropic-messages"
|
|
44
|
-
? { model: config.model, max_tokens: 4096, messages: messages
|
|
45
|
-
: { model: config.model, input: messages
|
|
101
|
+
? { model: config.model, max_tokens: 4096, messages: toAnthropicMessages(messages), system: messages.find((m) => m.role === "system")?.content, ...anthropicTools }
|
|
102
|
+
: { model: config.model, input: toOpenAIInput(messages), ...openaiTools }),
|
|
46
103
|
});
|
|
47
104
|
if (!response.ok)
|
|
48
105
|
throw new Error(`Model request failed (${response.status}): ${await response.text()}`);
|
|
@@ -89,7 +146,7 @@ export class Agent {
|
|
|
89
146
|
return { text: first.text ?? "", messages: [...compactHistory(messages), { role: "assistant", content: first.text ?? "" }] };
|
|
90
147
|
for (const call of first.toolCalls) {
|
|
91
148
|
const result = this.execute(call);
|
|
92
|
-
messages.push({ role: "tool", content: JSON.stringify(result), toolCallId: call.
|
|
149
|
+
messages.push({ role: "tool", content: JSON.stringify(result), toolCallId: call.id });
|
|
93
150
|
}
|
|
94
151
|
const final = await this.transport.complete(this.config, messages);
|
|
95
152
|
return { text: final.text ?? "", messages: [...compactHistory(messages), { role: "assistant", content: final.text ?? "" }] };
|
|
@@ -127,6 +184,62 @@ export const SAVE_KNOWLEDGE_TOOL = {
|
|
|
127
184
|
required: ["title", "content"],
|
|
128
185
|
},
|
|
129
186
|
};
|
|
187
|
+
/**
|
|
188
|
+
* 从本地文件导入知识。
|
|
189
|
+
* 单独做成一个工具,是为了避免让模型把整份文件内容搬进参数里
|
|
190
|
+
* (大文件会直接爆掉输出 token):模型只给路径,正文由本地读取。
|
|
191
|
+
*/
|
|
192
|
+
export const SAVE_FROM_FILE_TOOL = {
|
|
193
|
+
name: "save_from_file",
|
|
194
|
+
description: [
|
|
195
|
+
"把本地磁盘上的一个文件导入知识库。",
|
|
196
|
+
"当用户提到一个文件路径并要求录入、导入、收录到知识库时使用,",
|
|
197
|
+
"例如:录入 D:/docs/a.md、把这个文件加到知识库、import ./notes.txt。",
|
|
198
|
+
"只需要提供路径,正文由程序自己读取,不要把文件内容写进参数。",
|
|
199
|
+
].join(""),
|
|
200
|
+
inputSchema: {
|
|
201
|
+
type: "object",
|
|
202
|
+
properties: {
|
|
203
|
+
path: { type: "string", description: "文件的绝对路径,例如 D:/docs/支付渠道配置.md" },
|
|
204
|
+
title: { type: "string", description: "可选。知识条目标题,不填则用文件名" },
|
|
205
|
+
},
|
|
206
|
+
required: ["path"],
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
/** 读取本地文件内容(用于回答关于文件的问题,不会写进知识库)。 */
|
|
210
|
+
export const READ_FILE_TOOL = {
|
|
211
|
+
name: "read_file",
|
|
212
|
+
description: [
|
|
213
|
+
"读取本地文件的内容。",
|
|
214
|
+
"当用户想知道某个文件里写了什么、需要你根据文件内容回答、",
|
|
215
|
+
"或者需要先查看文件再决定如何处理时使用。",
|
|
216
|
+
"如果用户只是要把文件录入知识库,应该用 save_from_file 而不是这个工具。",
|
|
217
|
+
].join(""),
|
|
218
|
+
inputSchema: {
|
|
219
|
+
type: "object",
|
|
220
|
+
properties: {
|
|
221
|
+
path: { type: "string", description: "要读取的文件绝对路径" },
|
|
222
|
+
},
|
|
223
|
+
required: ["path"],
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
/** 写入本地文件。 */
|
|
227
|
+
export const WRITE_FILE_TOOL = {
|
|
228
|
+
name: "write_file",
|
|
229
|
+
description: [
|
|
230
|
+
"把文本内容写入本地文件。",
|
|
231
|
+
"仅在用户明确要求导出、另存为、写到某个文件路径时使用。",
|
|
232
|
+
"不要用它来记录知识(那应该用 save_knowledge)。",
|
|
233
|
+
].join(""),
|
|
234
|
+
inputSchema: {
|
|
235
|
+
type: "object",
|
|
236
|
+
properties: {
|
|
237
|
+
path: { type: "string", description: "目标文件绝对路径" },
|
|
238
|
+
content: { type: "string", description: "要写入的完整文本内容" },
|
|
239
|
+
},
|
|
240
|
+
required: ["path", "content"],
|
|
241
|
+
},
|
|
242
|
+
};
|
|
130
243
|
const IMAGE_QUESTION = "\u8bf7\u7528\u4e2d\u6587\u8be6\u7ec6\u63cf\u8ff0\u8fd9\u5f20\u56fe\u7247\u7684\u5185\u5bb9\uff0c\u5199\u6e05\u6240\u6709\u53ef\u89c1\u7684\u6587\u5b57\u3001\u6570\u5b57\u3001\u8868\u683c\u3001\u754c\u9762\u5143\u7d20\u548c\u5173\u952e\u7ec6\u8282\uff0c\u4ee5\u53ca\u56fe\u7247\u6574\u4f53\u5728\u8bb2\u4ec0\u4e48\u3002\u53ea\u8f93\u51fa\u63cf\u8ff0\u6b63\u6587\uff0c\u4e0d\u8981\u5ba2\u5957\u8bdd\u3002";
|
|
131
244
|
export async function describeImage(config, transport, image) {
|
|
132
245
|
const reply = await transport.complete(config, [{
|