@ganziliang/kb 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @ganziliang/kb
2
2
 
3
- 本地知识库命令行工具。它会把本地 Markdown、文本、JSON、CSV 或 Excel 文件导入 SQLite 知识库,然后通过关键词检索相关内容,并调用已配置的模型回答问题。同时支持导入图片:入库时自动生成中文描述参与检索,提问命中后把原图发给模型。
3
+ 本地知识库命令行工具。它会把本地 Markdown、文本、JSON、CSV 或 Excel 文件导入 SQLite 知识库,也可以直接录入输入框中的文字,然后通过关键词检索相关内容,并调用已配置的模型回答问题。同时支持导入图片:入库时自动生成中文描述参与检索,提问命中后把原图发给模型。
4
4
 
5
5
  界面基于 [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui) 构建,采用差分渲染:Markdown 回答排版、带边框的编辑器、加载动画与命令浮层。
6
6
 
@@ -144,13 +144,16 @@ kb
144
144
  | 按键 | 作用 |
145
145
  | --- | --- |
146
146
  | `Enter` | 发送 |
147
+ | `Shift+Enter` / `Ctrl+J` | 输入换行(录入多行内容用) |
147
148
  | `Esc` | 关闭浮层 |
148
149
  | `Ctrl+C` | 退出 |
149
150
  | `Ctrl+A` / `Ctrl+E` | 行首 / 行尾 |
150
151
  | `Ctrl+U` / `Ctrl+K` | 删除到行首 / 行尾 |
151
152
  | `Ctrl+W` | 删除前一个单词 |
153
+ | `Ctrl+-` | 撤销 |
154
+ | `Ctrl+Y` | 粘贴(yank 最近删除的内容) |
152
155
 
153
- 输入框支持多行编辑与撤销:`Ctrl+Z` 撤销,`Ctrl+Y` 重做。
156
+ 光标可以用方向键或 `Ctrl+B` / `Ctrl+F` 左右移动;按单词移动用 `Alt+←` / `Alt+→`。
154
157
 
155
158
  ### 导入文件
156
159
 
@@ -187,8 +190,95 @@ import D:\docs\api.txt
187
190
  导入 "D:\截图\支付报错.png"
188
191
  ```
189
192
 
193
+ **路径写法很宽容**,下面这几种都能识别(只要文件真实存在):
194
+
195
+ ```text
196
+ 录入 D:\资料\支付渠道配置.md到知识库 # 路径后粘了中文
197
+ 导入D:\资料\支付渠道配置.md # 关键词后没空格
198
+ "D:\资料\文件名字带空格.md" # 带空格的路径用引号包起来
199
+ D:\资料\支付渠道配置.md # 直接拖文件进来,不带任何关键词
200
+ ```
201
+
202
+ 路径识别走的是本地检查(`statSync`),**不消耗模型调用**。
203
+
190
204
  导入后会复制一份原始文件,并建立版本记录。对同一个文件再次导入时,会创建新版本,不会直接覆盖旧版本。
191
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
+
233
+ ### 直接录入文本
234
+
235
+ 不需要先把内容存成文件,也不需要记命令——用自然语言告诉 kb 就行:
236
+
237
+ ```text
238
+ 记住:我的工位在 A 区 12 号,门禁卡号 8823
239
+ 记一下,下周三下午两点和产品对需求
240
+ 帮我存个配置:接口超时时间 30 秒,失败重试 3 次
241
+ 这段你帮我记着:报销单必须贴发票原件
242
+ ```
243
+
244
+ kb 每一轮输入都会先判断你的意图:
245
+
246
+ - **提问** → 检索知识库并回答(附来源与版本)
247
+ - **录入** → 调用 `save_knowledge` 工具存入知识库
248
+
249
+ 判断由模型根据语义完成,**不依赖固定关键词**。识别为录入后,模型会:
250
+
251
+ 1. **生成标题**:用一句话概括内容,作为知识条目标题。
252
+ 2. **整理内容**:转成规范 Markdown,完整保留你给的信息。
253
+ 3. **落盘建索引**:写入 `<知识库>/originals/`,并入关键词检索,随后提问就能命中。
254
+
255
+ 多行内容按 **Shift+Enter**(或 `Ctrl+J`)换行,全部输入完再按 `Enter` 提交:
256
+
257
+ ```text
258
+ 帮我记下值班规范:
259
+
260
+ - 线上告警先看 Grafana 面板 pay-dashboard
261
+ - P0 故障 15 分钟内必须响应
262
+ - 值班电话 8001,仅限 P0 使用
263
+ ```
264
+
265
+ 录入的内容同样遵循版本规则:内容完全相同时再次录入,会生成新版本而不是重复来源。
266
+
267
+ #### 显式命令(兜底)
268
+
269
+ 模型判断有误、或模型暂时不可用时,可以用 `/add` 强制录入;
270
+ 以 `/` 开头的形式不会与自然语言混淆:
271
+
272
+ ```text
273
+ /add 支付网关的退款接口是 /api/pay/refund,请求方式 POST
274
+ /note 团队周会是每周三下午三点
275
+ ```
276
+
277
+ 显式命令不调用模型,标题由程序从内容推导(优先取 Markdown 标题,否则取首个非空行)。
278
+
279
+ > 区别:`导入 <路径>` 是把**已有的文件**收录进来,`/add <内容>` 是把**输入框里的文字**收录进来,
280
+ > 而直接说「记住 …」则由模型自己判断。
281
+
192
282
  ### 图片知识
193
283
 
194
284
  图片本身无法参与关键词检索,因此 `kb` 对图片使用双通道处理:
@@ -229,6 +319,7 @@ import D:\docs\api.txt
229
319
  | `/help` | 查看命令列表 |
230
320
  | `/clear` | 清空当前对话上下文 |
231
321
  | `/sources` | 查看当前知识库中的来源文件、路径和版本 |
322
+ | `/add <内容>` | 显式录入文本(正常直接说「记住…」即可) |
232
323
  | `/models` 或 `/model` | 查看已配置模型 |
233
324
  | `/model <编号>` | 切换模型,例如 `/model 1` |
234
325
  | `/kb list` | 查看所有知识库 |
package/dist/cli.d.ts CHANGED
@@ -27,8 +27,11 @@ export declare class KbApp {
27
27
  private shutdown;
28
28
  private submit;
29
29
  private run;
30
+ private addNote;
30
31
  private ingest;
31
32
  private ask;
33
+ /** 执行一次工具调用;done 为 true 表示任务已完成,无需再回模型。 */
34
+ private runTool;
32
35
  private command;
33
36
  private showSources;
34
37
  private showModelPicker;
package/dist/cli.js CHANGED
@@ -1,16 +1,115 @@
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 { Agent, describeImage, fetchTransport } from "./model.js";
6
- import { backupKnowledgeBase, copyOriginal, defaultDataRoot, deleteKnowledgeBase, ensureKnowledgeBase, isImageFile, KnowledgeStore, listKnowledgeBases, readImage, readLocalFile, restoreKnowledgeBase, } from "./storage.js";
6
+ import { READ_FILE_TOOL, SAVE_FROM_FILE_TOOL, SAVE_KNOWLEDGE_TOOL, WRITE_FILE_TOOL, describeImage, fetchTransport, } from "./model.js";
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";
9
10
  const errText = (error) => (error instanceof Error ? error.message : String(error));
11
+ /**
12
+ * 显式录入命令,仅作为模型不可用时的兜底。
13
+ * 正常情况交给模型按语义判断(见 buildSystemPrompt),
14
+ * 因此这里只保留 / 开头的命令,不硬匹配「记住」这类自然语言词。
15
+ */
16
+ const NOTE_PATTERN = /^\/(?:add|note)(?:\s+([\s\S]*))?$/;
17
+ /** 从录入内容里推导一个标题:优先取 Markdown 标题,否则取首个非空行。 */
18
+ function deriveTitle(content) {
19
+ const firstLine = content.split("\n").map((l) => l.trim()).find(Boolean) ?? "笔记";
20
+ const heading = firstLine.match(/^#{1,6}\s+(.+)$/);
21
+ const base = (heading ? heading[1] : firstLine).replace(/[#*`>_]/g, "").trim();
22
+ return base.slice(0, 40) || "���记";
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;
69
+ /** 多行内容回显时只显示首行,避免刷屏。 */
70
+ function shortPreview(text) {
71
+ const lines = text.split("\n");
72
+ return lines.length > 1 ? `${lines[0]} …(共 ${lines.length} 行)` : text;
73
+ }
74
+ /**
75
+ * 把「回答」和「录入」放进同一轮对话:模型自己决定是回答还是调用工具,
76
+ * 因此判断意图不会带来额外的一次模型调用。
77
+ */
78
+ function buildSystemPrompt(context) {
79
+ return [
80
+ "你是本地知识库助手。每一轮输入,先判断用户意图:",
81
+ "",
82
+ "A) 提问 —— 用户想从知识库获取信息。",
83
+ " 用「知识库内容」回答,并标注来源文件名与版本。",
84
+ " 内容不足以回答时明确说明没有找到,不要编造。",
85
+ "",
86
+ "B) 录入文本 —— 用户直接给你一段内容想长期保存。",
87
+ " 调用 save_knowledge(title, content),title 用一句话概括,content 用规范 Markdown 完整保留用户信息。",
88
+ " 此时不要回答内容本身,也不要反问确认。",
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
+ "",
100
+ "判断依据是用户表达的意图,不要依赖固定关键词。",
101
+ "",
102
+ "知识库内容:",
103
+ "---",
104
+ context,
105
+ "---",
106
+ ].join("\n");
107
+ }
10
108
  const HELP_ITEMS = [
11
109
  ["/help", "显示本帮助"],
12
110
  ["/clear", "清空对话上下文"],
13
111
  ["/sources", "列出知识来源与版本"],
112
+ ["/add <内容>", "显式录入(通常直接说「记住…」即可)"],
14
113
  ["/model", "查看 / 切换模型"],
15
114
  ["/kb", "多知识库管理"],
16
115
  ["/backup", "备份当前知识库"],
@@ -127,6 +226,17 @@ export class KbApp {
127
226
  return;
128
227
  this.editor.setText("");
129
228
  this.tui.requestRender();
229
+ const note = value.match(NOTE_PATTERN);
230
+ if (note) {
231
+ const content = (note[1] ?? "").trim();
232
+ if (!content) {
233
+ this.add(new Notice(ROLE.warn, color.warn, "用法:/add <内容>;也可以直接说「记住 ……」,模型会自动识别为录入。"));
234
+ return;
235
+ }
236
+ this.add(new UserMessage(shortPreview(`/add ${content}`)));
237
+ await this.addNote(content);
238
+ return;
239
+ }
130
240
  if (value.startsWith("/")) {
131
241
  await this.command(value);
132
242
  return;
@@ -136,15 +246,55 @@ export class KbApp {
136
246
  await this.run(value);
137
247
  }
138
248
  async run(value) {
139
- const pathMatch = value.match(/(?:录入|导入|整理|ingest|import)\s+(.+)$/i);
140
- if (pathMatch) {
141
- await this.ingest(pathMatch[1].trim().replace(/^['"]|['"]$/g, ""));
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} 是一个目录,请指定具体文件(目录暂不支持导入)。`));
142
275
  return;
143
276
  }
144
277
  await this.ask(value);
145
278
  }
279
+ // ---------- 直接录入文本 ----------
280
+ async addNote(content, explicitTitle = "") {
281
+ const body = content.trim();
282
+ if (!body) {
283
+ this.add(new Notice(ROLE.warn, color.warn, "没有可录入的内容。"));
284
+ return;
285
+ }
286
+ try {
287
+ const title = explicitTitle.trim() || deriveTitle(body);
288
+ const file = writeNote(this.store.root, title, body);
289
+ const result = this.store.addOrUpdateSource(file, title, body);
290
+ this.add(new Notice(ROLE.ok, color.ok, `已录入「${result.title}」(v${result.version},${body.length} 字符)`));
291
+ }
292
+ catch (error) {
293
+ this.add(new Notice(ROLE.err, color.err, `录入失败:${errText(error)}`));
294
+ }
295
+ }
146
296
  // ---------- 导入 ----------
147
- async ingest(path) {
297
+ async ingest(path, explicitTitle = "") {
148
298
  try {
149
299
  this.setBusy(true, "读取文件…");
150
300
  const source = readLocalFile(path);
@@ -167,8 +317,9 @@ export class KbApp {
167
317
  note = ",图片描述生成失败(仅按文件名检索)";
168
318
  }
169
319
  }
320
+ const title = explicitTitle.trim() || source.title;
170
321
  const copied = copyOriginal(source.originalPath, this.store.root);
171
- const result = this.store.addOrUpdateSource(copied, source.title, content);
322
+ const result = this.store.addOrUpdateSource(copied, title, content);
172
323
  this.add(new Notice(ROLE.ok, color.ok, `${result.isNew ? "已导入" : "已创建新版本"} ${result.title}(v${result.version})${note}`));
173
324
  }
174
325
  catch (error) {
@@ -191,30 +342,56 @@ export class KbApp {
191
342
  const results = this.store.search(question);
192
343
  const context = results.length
193
344
  ? results.map((item) => `[${item.id}] ${item.title} (source: ${item.source}, v${item.version})\n${item.content}`).join("\n\n")
194
- : "No matching knowledge was found.";
195
- const prompt = `Answer only from the local knowledge below. If it is insufficient, say no knowledge was found. Include source filename and version.\n\n${context}\n\nQuestion: ${question}`;
196
- const blocks = [{ type: "text", text: prompt }];
345
+ : "(没有检索到相关内容)";
197
346
  const attached = [];
347
+ const questionBlocks = [{ type: "text", text: question }];
198
348
  for (const item of results.filter((hit) => hit.originalPath && isImageFile(hit.originalPath)).slice(0, 4)) {
199
349
  try {
200
350
  const image = readImage(item.originalPath);
201
- blocks.push({ type: "text", text: `[附图] ${item.source} v${item.version}` });
202
- blocks.push({ type: "image", mediaType: image.mediaType, base64: image.base64 });
351
+ questionBlocks.push({ type: "text", text: `[附图] ${item.source} v${item.version}` });
352
+ questionBlocks.push({ type: "image", mediaType: image.mediaType, base64: image.base64 });
203
353
  attached.push({ name: item.source, version: item.version });
204
354
  }
205
355
  catch { /* 跳过无法读取的图片 */ }
206
356
  }
207
- this.setBusy(true, attached.length ? `命中 ${results.length} 条,附图 ${attached.length} 张,正在生成回答…` : `命中 ${results.length} 条,正在生成回答…`);
208
- const agent = new Agent(this.store, config, fetchTransport);
209
- const answer = await agent.answer(blocks.length > 1 ? blocks : prompt, this.messages);
210
- this.messages = answer.messages;
211
- this.store.saveMessage("user", question);
212
- this.store.saveMessage("assistant", answer.text);
213
- for (const item of attached)
214
- this.add(new ImageNotice(item.name, item.version));
215
- const seconds = ((Date.now() - started) / 1000).toFixed(1);
216
- const meta = `检索 ${results.length} · 命中 ${results.length} · ${seconds}s` + (attached.length ? ` · 附图 ${attached.length} 张` : "");
217
- this.add(new AssistantMessage(answer.text || "(模型返回空回答)", meta));
357
+ const tools = [SAVE_KNOWLEDGE_TOOL, SAVE_FROM_FILE_TOOL, READ_FILE_TOOL, WRITE_FILE_TOOL];
358
+ const messages = [
359
+ { role: "system", content: buildSystemPrompt(context) },
360
+ ...this.messages,
361
+ { role: "user", content: questionBlocks.length > 1 ? questionBlocks : question },
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;
393
+ }
394
+ this.add(new Notice(ROLE.warn, color.warn, "工具调用轮次过多,已停止。"));
218
395
  }
219
396
  catch (error) {
220
397
  this.add(new Notice(ROLE.err, color.err, `模型请求失败:${errText(error)}`));
@@ -223,6 +400,49 @@ export class KbApp {
223
400
  this.setBusy(false);
224
401
  }
225
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
+ }
226
446
  // ---------- 命令 ----------
227
447
  async command(value) {
228
448
  const [cmd, ...rest] = value.split(/\s+/);
package/dist/model.d.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import type { ModelConfig } from "@ganziliang/kb-model-setup";
2
2
  import type { SearchResult, KnowledgeStore } from "./storage.js";
3
- export type ToolCall = {
4
- name: "search" | "read" | "write";
3
+ export type NativeToolCall = {
4
+ id: string;
5
+ name: string;
5
6
  arguments: Record<string, unknown>;
6
7
  };
8
+ export type ToolDefinition = {
9
+ name: string;
10
+ description: string;
11
+ inputSchema: Record<string, unknown>;
12
+ };
7
13
  export type ContentBlock = {
8
14
  type: "text";
9
15
  text: string;
@@ -15,17 +21,24 @@ export type ContentBlock = {
15
21
  export type ModelMessage = {
16
22
  role: "system" | "user" | "assistant" | "tool";
17
23
  content: string | ContentBlock[];
24
+ /** assistant 消息回传时携带的工具调用(tool_use / function_call)。 */
25
+ toolCalls?: NativeToolCall[];
26
+ /** role 为 tool 时,关联的 tool_use id。 */
18
27
  toolCallId?: string;
19
28
  };
20
29
  export type ModelReply = {
21
30
  text?: string;
22
- toolCalls?: ToolCall[];
31
+ toolCalls?: NativeToolCall[];
23
32
  };
24
33
  export declare function toAnthropicContent(content: string | ContentBlock[]): unknown;
25
34
  export declare function toOpenAIContent(content: string | ContentBlock[]): unknown;
26
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[];
27
40
  export type ModelTransport = {
28
- complete(config: ModelConfig, messages: ModelMessage[]): Promise<ModelReply>;
41
+ complete(config: ModelConfig, messages: ModelMessage[], tools?: ToolDefinition[]): Promise<ModelReply>;
29
42
  };
30
43
  export declare const fetchTransport: ModelTransport;
31
44
  export declare class Agent {
@@ -40,6 +53,21 @@ export declare class Agent {
40
53
  private execute;
41
54
  }
42
55
  export declare function formatResults(results: SearchResult[]): string;
56
+ /**
57
+ * 让模型在一轮对话里自己决定「回答」还是「录入」,
58
+ * 避免为了判断意图而多跑一次模型调用。
59
+ */
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;
43
71
  type VisionTransport = {
44
72
  complete: (config: ModelConfig, messages: ModelMessage[]) => Promise<ModelReply>;
45
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) });
@@ -23,7 +80,7 @@ function endpoint(config) {
23
80
  return `${config.baseURL.replace(/\/$/, "")}${config.api === "anthropic-messages" ? "/v1/messages" : "/v1/responses"}`;
24
81
  }
25
82
  export const fetchTransport = {
26
- async complete(config, messages) {
83
+ async complete(config, messages, tools) {
27
84
  const headers = { "content-type": "application/json" };
28
85
  if (config.api === "anthropic-messages") {
29
86
  headers["x-api-key"] = config.apiKey;
@@ -31,19 +88,46 @@ export const fetchTransport = {
31
88
  }
32
89
  else
33
90
  headers.authorization = `Bearer ${config.apiKey}`;
91
+ const anthropicTools = tools?.length
92
+ ? { tools: tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.inputSchema })) }
93
+ : {};
94
+ const openaiTools = tools?.length
95
+ ? { tools: tools.map((tool) => ({ type: "function", name: tool.name, description: tool.description, parameters: tool.inputSchema })) }
96
+ : {};
34
97
  const response = await fetch(endpoint(config), {
35
98
  method: "POST",
36
99
  headers,
37
100
  body: JSON.stringify(config.api === "anthropic-messages"
38
- ? { model: config.model, max_tokens: 4096, messages: messages.filter((m) => m.role !== "system").map((m) => ({ role: m.role, content: toAnthropicContent(m.content) })), system: messages.find((m) => m.role === "system")?.content }
39
- : { model: config.model, input: messages.map((m) => ({ role: m.role, content: toOpenAIContent(m.content) })) }),
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 }),
40
103
  });
41
104
  if (!response.ok)
42
105
  throw new Error(`Model request failed (${response.status}): ${await response.text()}`);
43
106
  const data = await response.json();
44
- if (config.api === "anthropic-messages")
45
- return { text: data.content?.map((item) => item.text ?? "").join("") };
46
- return { text: data.output_text ?? data.output?.map((item) => item.content?.map((part) => part.text ?? "").join("")).join("") ?? "" };
107
+ if (config.api === "anthropic-messages") {
108
+ let text = "";
109
+ const toolCalls = [];
110
+ for (const item of (data.content ?? [])) {
111
+ if (item.type === "text")
112
+ text += item.text ?? "";
113
+ else if (item.type === "tool_use")
114
+ toolCalls.push({ id: String(item.id ?? ""), name: String(item.name), arguments: (item.input ?? {}) });
115
+ }
116
+ return toolCalls.length ? { text, toolCalls } : { text };
117
+ }
118
+ const toolCalls = [];
119
+ for (const item of (data.output ?? [])) {
120
+ if (item.type !== "function_call")
121
+ continue;
122
+ let parsed = {};
123
+ try {
124
+ parsed = JSON.parse(item.arguments ?? "{}");
125
+ }
126
+ catch { /* 保留空参数 */ }
127
+ toolCalls.push({ id: String(item.call_id ?? item.id ?? ""), name: String(item.name), arguments: parsed });
128
+ }
129
+ const text = data.output_text ?? (data.output ?? []).map((item) => (item.content ?? []).map((part) => part.text ?? "").join("")).join("") ?? "";
130
+ return toolCalls.length ? { text, toolCalls } : { text };
47
131
  },
48
132
  };
49
133
  export class Agent {
@@ -62,7 +146,7 @@ export class Agent {
62
146
  return { text: first.text ?? "", messages: [...compactHistory(messages), { role: "assistant", content: first.text ?? "" }] };
63
147
  for (const call of first.toolCalls) {
64
148
  const result = this.execute(call);
65
- messages.push({ role: "tool", content: JSON.stringify(result), toolCallId: call.name });
149
+ messages.push({ role: "tool", content: JSON.stringify(result), toolCallId: call.id });
66
150
  }
67
151
  const final = await this.transport.complete(this.config, messages);
68
152
  return { text: final.text ?? "", messages: [...compactHistory(messages), { role: "assistant", content: final.text ?? "" }] };
@@ -72,12 +156,90 @@ export class Agent {
72
156
  return this.store.search(String(call.arguments.query ?? ""));
73
157
  if (call.name === "read")
74
158
  return this.store.read(String(call.arguments.id ?? "")) ?? { error: "Knowledge record not found" };
75
- return { error: "write requires CLI ingestion flow" };
159
+ return { error: `Unknown tool: ${call.name}` };
76
160
  }
77
161
  }
78
162
  export function formatResults(results) {
79
163
  return results.map((result) => `[${result.id}] ${result.title} (source: ${result.source}, v${result.version})\n${result.content}`).join("\n\n");
80
164
  }
165
+ /**
166
+ * 让模型在一轮对话里自己决定「回答」还是「录入」,
167
+ * 避免为了判断意图而多跑一次模型调用。
168
+ */
169
+ export const SAVE_KNOWLEDGE_TOOL = {
170
+ name: "save_knowledge",
171
+ description: [
172
+ "把用户提供的内容保存到本地知识库。",
173
+ "当用户意图是「记录 / 保存 / 录入 / 收藏 / 备忘 / 以后要用」某段内容时调用,",
174
+ "例如:记住…、记一下…、记录一下…、把这段存起来、帮我存个配置、以后就按这个来。",
175
+ "只有用户确实在提供一段希望长期保存的知识时才调用。",
176
+ "如果用户在询问、查询、确认、闲聊,不要调用,直接回答。",
177
+ ].join(""),
178
+ inputSchema: {
179
+ type: "object",
180
+ properties: {
181
+ title: { type: "string", description: "对内容的一句话概括,作为知识条目标题,20 字以内" },
182
+ content: { type: "string", description: "要保存的完整内容,规范 Markdown。必须完整保留用户给出的全部信息,不要自行增删或改写事实" },
183
+ },
184
+ required: ["title", "content"],
185
+ },
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
+ };
81
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";
82
244
  export async function describeImage(config, transport, image) {
83
245
  const reply = await transport.complete(config, [{
package/dist/storage.d.ts CHANGED
@@ -54,6 +54,8 @@ type KnowledgeStoreResult = {
54
54
  };
55
55
  export declare function readLocalFile(filePath: string): LocalFile;
56
56
  export declare function copyOriginal(filePath: string, root: string): string;
57
+ /** \u628a\u624b\u52a8\u5f55\u5165\u7684\u6587\u672c\u843d\u76d8\u4e3a originals/ \u4e0b\u7684 Markdown\uff0c\u8fd4\u56de\u8be5\u6587\u4ef6\u8def\u5f84\u3002 */
58
+ export declare function writeNote(root: string, title: string, content: string): string;
57
59
  export declare function defaultDataRoot(): string;
58
60
  export declare function knowledgeBasePath(root: string, id: string): string;
59
61
  export declare function listKnowledgeBases(root: string): KnowledgeBase[];
package/dist/storage.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdirSync, copyFileSync, existsSync, readFileSync, statSync, cpSync, rmSync, readdirSync } from "node:fs";
1
+ import { mkdirSync, copyFileSync, existsSync, readFileSync, statSync, cpSync, rmSync, readdirSync, writeFileSync } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { join, resolve, extname, basename } from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
@@ -115,6 +115,15 @@ export function copyOriginal(filePath, root) {
115
115
  copyFileSync(filePath, destination);
116
116
  return destination;
117
117
  }
118
+ /** \u628a\u624b\u52a8\u5f55\u5165\u7684\u6587\u672c\u843d\u76d8\u4e3a originals/ \u4e0b\u7684 Markdown\uff0c\u8fd4\u56de\u8be5\u6587\u4ef6\u8def\u5f84\u3002 */
119
+ export function writeNote(root, title, content) {
120
+ const dir = join(root, "originals");
121
+ mkdirSync(dir, { recursive: true });
122
+ const slug = title.replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "note";
123
+ const target = join(dir, `${Date.now()}-${slug}.md`);
124
+ writeFileSync(target, content, "utf8");
125
+ return target;
126
+ }
118
127
  export function defaultDataRoot() {
119
128
  const home = process.env.HOME ?? process.env.USERPROFILE ?? process.cwd();
120
129
  return process.env.KB_DATA_DIR ?? join(home, ".kb");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganziliang/kb",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Local knowledge base agent CLI with a pi-tui interface",
5
5
  "type": "module",
6
6
  "bin": {