@ganziliang/kb 0.1.7 → 0.3.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.js CHANGED
@@ -1,204 +1,470 @@
1
1
  #!/usr/bin/env node
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useRef, useState } from "react";
4
- import { Box, Text, render, useApp, useInput } from "ink";
5
2
  import { mkdirSync } from "node:fs";
3
+ import { Container, Editor, Loader, ProcessTerminal, SelectList, Spacer, TuiMainScreen, matchesKey, } from "@earendil-works/pi-tui";
6
4
  import { ensureModelConfig, saveModels } from "./config.js";
7
- import { Agent, describeImage, fetchTransport } from "./model.js";
8
- import { copyOriginal, defaultDataRoot, ensureKnowledgeBase, isImageFile, KnowledgeStore, readImage, readLocalFile, listKnowledgeBases, deleteKnowledgeBase, backupKnowledgeBase, restoreKnowledgeBase } from "./storage.js";
9
- const banner = [
10
- " _ __ ____ ",
11
- " | |/ // __ ) ",
12
- " | // __ | ",
13
- " | |/ /_/ / ",
14
- " |_|\\_\\____/ ",
15
- " Welcome to KB Knowledge Base",
5
+ import { SAVE_KNOWLEDGE_TOOL, describeImage, fetchTransport } from "./model.js";
6
+ import { backupKnowledgeBase, copyOriginal, defaultDataRoot, deleteKnowledgeBase, ensureKnowledgeBase, isImageFile, KnowledgeStore, listKnowledgeBases, readImage, readLocalFile, restoreKnowledgeBase, writeNote, } from "./storage.js";
7
+ import { color, editorTheme, selectListTheme } from "./theme.js";
8
+ import { AssistantMessage, HelpPanel, ImageNotice, Notice, Rule, Splash, UserMessage } from "./ui.js";
9
+ const errText = (error) => (error instanceof Error ? error.message : String(error));
10
+ /**
11
+ * 显式录入命令,仅作为模型不可用时的兜底。
12
+ * 正常情况交给模型按语义判断(见 buildSystemPrompt),
13
+ * 因此这里只保留 / 开头的命令,不硬匹配「记住」这类自然语言词。
14
+ */
15
+ const NOTE_PATTERN = /^\/(?:add|note)(?:\s+([\s\S]*))?$/;
16
+ /** 从录入内容里推导一个标题:优先取 Markdown 标题,否则取首个非空行。 */
17
+ function deriveTitle(content) {
18
+ const firstLine = content.split("\n").map((l) => l.trim()).find(Boolean) ?? "笔记";
19
+ const heading = firstLine.match(/^#{1,6}\s+(.+)$/);
20
+ const base = (heading ? heading[1] : firstLine).replace(/[#*`>_]/g, "").trim();
21
+ return base.slice(0, 40) || "���记";
22
+ }
23
+ /** 多行内容回显时只显示首行,避免刷屏。 */
24
+ function shortPreview(text) {
25
+ const lines = text.split("\n");
26
+ return lines.length > 1 ? `${lines[0]} …(共 ${lines.length} 行)` : text;
27
+ }
28
+ /**
29
+ * 把「回答」和「录入」放进同一轮对话:模型自己决定是回答还是调用工具,
30
+ * 因此判断意图不会带来额外的一次模型调用。
31
+ */
32
+ function buildSystemPrompt(context) {
33
+ return [
34
+ "你是本地知识库助手。每一轮输入,先判断用户意图:",
35
+ "",
36
+ "A) 提问 —— 用户想从知识库获取信息。",
37
+ " 用「知识库内容」回答,并标注来源文件名与版本。",
38
+ " 内容不足以回答时明确说明没有找到,不要编造。",
39
+ "",
40
+ "B) 录入 —— 用户想把一段内容长期存进知识库。",
41
+ " 调用 save_knowledge 工具,title 用一句话概括,content 用规范 Markdown 并完整保留用户信息。",
42
+ " 此时不要回答内容本身,也不要反问确认。",
43
+ "",
44
+ "判断依据是用户表达的意图,不要依赖固定关键词。",
45
+ "",
46
+ "知识库内容:",
47
+ "---",
48
+ context,
49
+ "---",
50
+ ].join("\n");
51
+ }
52
+ const HELP_ITEMS = [
53
+ ["/help", "显示本帮助"],
54
+ ["/clear", "清空对话上下文"],
55
+ ["/sources", "列出知识来源与版本"],
56
+ ["/add <内容>", "显式录入(通常直接说「记住…」即可)"],
57
+ ["/model", "查看 / 切换模型"],
58
+ ["/kb", "多知识库管理"],
59
+ ["/backup", "备份当前知识库"],
60
+ ["/restore", "��备份恢复"],
61
+ ["/cleanup", "清理历史版本"],
62
+ ["/quit", "退出"],
63
+ ["", ""],
64
+ ["导入 <路径>", "录入 md / txt / json / csv / xlsx / png / jpg"],
16
65
  ];
17
- function App({ config, root, knowledgeBase }) {
18
- const { exit } = useApp();
19
- const [input, setInput] = useState("");
20
- const [lines, setLines] = useState([...banner, `knowledge base: ${knowledgeBase.name}`, "Type a question, or /help."]);
21
- const [busy, setBusy] = useState(false);
22
- const [messages, setMessages] = useState([]);
23
- const [modelIndex, setModelIndex] = useState(0);
24
- const [currentBase, setCurrentBase] = useState(knowledgeBase);
25
- const storeRef = useRef(new KnowledgeStore(knowledgeBase.path));
26
- const store = storeRef.current;
27
- useEffect(() => () => storeRef.current.close(), []);
28
- useInput((char, key) => {
29
- if (key.ctrl && char === "c") {
30
- store.close();
31
- exit();
32
- return;
66
+ const ROLE = { user: "❯", assistant: "⏺", ok: "✔", err: "✖", warn: "!", image: "★" };
67
+ export class KbApp {
68
+ root;
69
+ tui;
70
+ stream = new Container();
71
+ editor;
72
+ store;
73
+ base;
74
+ models;
75
+ modelIndex = 0;
76
+ messages = [];
77
+ turns = 0;
78
+ busy = false;
79
+ loader = null;
80
+ overlay = null;
81
+ closed = false;
82
+ constructor(root, base, models) {
83
+ this.root = root;
84
+ this.base = base;
85
+ this.models = models;
86
+ this.store = new KnowledgeStore(base.path);
87
+ this.tui = new TuiMainScreen(new ProcessTerminal());
88
+ this.editor = new Editor(this.tui, editorTheme, { paddingX: 1 });
89
+ this.editor.onSubmit = (text) => { void this.submit(text.trim()); };
90
+ }
91
+ // ---------- 启动 ----------
92
+ start() {
93
+ this.tui.addChild(new Splash({
94
+ base: this.base.name,
95
+ model: this.model?.model ?? "-",
96
+ dataPath: this.root,
97
+ sources: this.store.sources().length,
98
+ }));
99
+ this.tui.addChild(this.stream);
100
+ this.tui.addChild(new Spacer(1));
101
+ this.tui.addChild(new Rule());
102
+ this.tui.addChild(this.editor);
103
+ this.tui.setFocus(this.editor);
104
+ this.tui.addInputListener((data) => {
105
+ if (matchesKey(data, "ctrl+c")) {
106
+ this.shutdown();
107
+ return { consume: true };
108
+ }
109
+ if (matchesKey(data, "escape") && this.tui.hasOverlay()) {
110
+ this.closeOverlay();
111
+ return { consume: true };
112
+ }
113
+ return {};
114
+ });
115
+ this.tui.start();
116
+ }
117
+ get model() { return this.models[this.modelIndex]; }
118
+ // ---------- 渲染原语 ----------
119
+ add(component) {
120
+ this.stream.addChild(component);
121
+ this.tui.requestRender();
122
+ }
123
+ setBusy(on, message = "") {
124
+ if (on) {
125
+ this.editor.disableSubmit = true;
126
+ if (this.loader) {
127
+ this.loader.setMessage(message);
128
+ }
129
+ else {
130
+ this.loader = new Loader(this.tui, color.accent, color.muted, message);
131
+ this.stream.addChild(this.loader);
132
+ this.loader.start();
133
+ }
33
134
  }
34
- if (key.return && input.trim() && !busy) {
35
- void submit(input.trim());
36
- setInput("");
37
- return;
135
+ else {
136
+ this.editor.disableSubmit = false;
137
+ if (this.loader) {
138
+ this.loader.stop();
139
+ this.stream.removeChild(this.loader);
140
+ this.loader = null;
141
+ }
38
142
  }
39
- if (key.backspace || key.delete) {
40
- setInput((value) => value.slice(0, -1));
41
- return;
143
+ this.tui.requestRender();
144
+ }
145
+ showOverlay(component, width) {
146
+ this.closeOverlay();
147
+ this.overlay = this.tui.showOverlay(component, { width, anchor: "center", margin: 1 });
148
+ }
149
+ closeOverlay() {
150
+ if (this.overlay) {
151
+ this.overlay.hide();
152
+ this.overlay = null;
42
153
  }
43
- if (!key.ctrl && !key.meta && char)
44
- setInput((value) => value + char);
45
- });
46
- async function submit(value) {
47
- if (value === "/quit" || value === "/exit") {
48
- store.close();
49
- exit();
154
+ this.tui.setFocus(this.editor);
155
+ }
156
+ shutdown() {
157
+ if (this.closed)
50
158
  return;
159
+ this.closed = true;
160
+ try {
161
+ this.store.close();
51
162
  }
52
- if (value === "/clear") {
53
- setLines(["Conversation cleared."]);
54
- setMessages([]);
163
+ catch { /* 已关闭 */ }
164
+ this.tui.stop();
165
+ process.exit(0);
166
+ }
167
+ // ---------- 输入入口 ----------
168
+ async submit(value) {
169
+ if (this.busy || !value)
170
+ return;
171
+ this.editor.setText("");
172
+ this.tui.requestRender();
173
+ const note = value.match(NOTE_PATTERN);
174
+ if (note) {
175
+ const content = (note[1] ?? "").trim();
176
+ if (!content) {
177
+ this.add(new Notice(ROLE.warn, color.warn, "用法:/add <内容>;也可以直接说「记住 ……」,模型会自动识别为录入。"));
178
+ return;
179
+ }
180
+ this.add(new UserMessage(shortPreview(`/add ${content}`)));
181
+ await this.addNote(content);
55
182
  return;
56
183
  }
57
- if (value === "/help") {
58
- setLines((old) => [...old, "/help /clear /sources /backup /restore /cleanup /model /models /kb /quit", "导入:输入「导入 <文件路径>」,支持 .md/.txt/.json/.csv/.xlsx/.xls 及 .png/.jpg/.jpeg/.gif/.webp/.bmp", "图片入库时自动生成中文描述用于检索,提问命中后会把原图一并发给模型"]);
184
+ if (value.startsWith("/")) {
185
+ await this.command(value);
59
186
  return;
60
187
  }
61
- if (value === "/sources") {
62
- setLines((old) => [...old, ...store.sources().map((s) => `${s.name} | ${s.path} | v${s.version} | ${s.updatedAt}`)]);
188
+ this.turns += 1;
189
+ this.add(new UserMessage(value));
190
+ await this.run(value);
191
+ }
192
+ async run(value) {
193
+ const pathMatch = value.match(/(?:录入|导入|整理|ingest|import)\s+(.+)$/i);
194
+ if (pathMatch) {
195
+ await this.ingest(pathMatch[1].trim().replace(/^['"]|['"]$/g, ""));
63
196
  return;
64
197
  }
65
- if (value === "/model" || value === "/models") {
66
- setLines((old) => [...old, ...config.map((item, i) => `${i === modelIndex ? "*" : " "} ${i}: ${item.provider}/${item.model} (${item.api})`)]);
198
+ await this.ask(value);
199
+ }
200
+ // ---------- 直接录入文本 ----------
201
+ async addNote(content, explicitTitle = "") {
202
+ const body = content.trim();
203
+ if (!body) {
204
+ this.add(new Notice(ROLE.warn, color.warn, "没有可录入的内容。"));
67
205
  return;
68
206
  }
69
- if (value.startsWith("/model ")) {
70
- const index = Number(value.slice(7));
71
- if (config[index]) {
72
- setModelIndex(index);
73
- saveModels([config[index], ...config.filter((_, i) => i !== index)]);
74
- setLines((old) => [...old, `Using ${config[index].model}`]);
207
+ try {
208
+ const title = explicitTitle.trim() || deriveTitle(body);
209
+ const file = writeNote(this.store.root, title, body);
210
+ const result = this.store.addOrUpdateSource(file, title, body);
211
+ this.add(new Notice(ROLE.ok, color.ok, `已录入「${result.title}」(v${result.version},${body.length} 字符)`));
212
+ }
213
+ catch (error) {
214
+ this.add(new Notice(ROLE.err, color.err, `录入失败:${errText(error)}`));
215
+ }
216
+ }
217
+ // ---------- 导入 ----------
218
+ async ingest(path) {
219
+ try {
220
+ this.setBusy(true, "读取文件…");
221
+ const source = readLocalFile(path);
222
+ if (!source.hash) {
223
+ this.add(new Notice(ROLE.err, color.err, `${source.title}:无法解析(${source.content})`));
224
+ return;
75
225
  }
76
- else
77
- setLines((old) => [...old, "Unknown model index."]);
226
+ let content = source.content;
227
+ let note = "";
228
+ if (source.image) {
229
+ this.setBusy(true, "识别图片并生成描述…");
230
+ try {
231
+ const caption = await describeImage(this.model, fetchTransport, source.image);
232
+ if (caption) {
233
+ content = `${source.content}\n\n${caption}`;
234
+ note = ",已生成图片描述";
235
+ }
236
+ }
237
+ catch {
238
+ note = ",图片描述生成失败(仅按文件名检索)";
239
+ }
240
+ }
241
+ const copied = copyOriginal(source.originalPath, this.store.root);
242
+ const result = this.store.addOrUpdateSource(copied, source.title, content);
243
+ this.add(new Notice(ROLE.ok, color.ok, `${result.isNew ? "已导入" : "已创建新版本"} ${result.title}(v${result.version})${note}`));
244
+ }
245
+ catch (error) {
246
+ this.add(new Notice(ROLE.err, color.err, `导入失败:${errText(error)}`));
247
+ }
248
+ finally {
249
+ this.setBusy(false);
250
+ }
251
+ }
252
+ // ---------- 问答 ----------
253
+ async ask(question) {
254
+ const config = this.model;
255
+ if (!config) {
256
+ this.add(new Notice(ROLE.err, color.err, "没有可用模型,请先配置模型。"));
78
257
  return;
79
258
  }
80
- if (value.startsWith("/kb ")) {
81
- const [command, ...args] = value.slice(4).trim().split(/\s+/);
82
- if (command === "list") {
83
- setLines((old) => [...old, ...listKnowledgeBases(root).map((item) => `${item.id}${item.id === currentBase.id ? " *" : ""}`)]);
84
- return;
259
+ const started = Date.now();
260
+ try {
261
+ this.setBusy(true, "检索知识库…");
262
+ const results = this.store.search(question);
263
+ const context = results.length
264
+ ? results.map((item) => `[${item.id}] ${item.title} (source: ${item.source}, v${item.version})\n${item.content}`).join("\n\n")
265
+ : "(没有检索到相关内容)";
266
+ const attached = [];
267
+ const questionBlocks = [{ type: "text", text: question }];
268
+ for (const item of results.filter((hit) => hit.originalPath && isImageFile(hit.originalPath)).slice(0, 4)) {
269
+ try {
270
+ const image = readImage(item.originalPath);
271
+ questionBlocks.push({ type: "text", text: `[附图] ${item.source} v${item.version}` });
272
+ questionBlocks.push({ type: "image", mediaType: image.mediaType, base64: image.base64 });
273
+ attached.push({ name: item.source, version: item.version });
274
+ }
275
+ catch { /* 跳过无法读取的图片 */ }
85
276
  }
86
- if (command === "current") {
87
- setLines((old) => [...old, `${currentBase.name} (${currentBase.id})`]);
277
+ this.setBusy(true, attached.length ? `命中 ${results.length} 条,附图 ${attached.length} 张,正在处理…` : `检索到 ${results.length} 条,正在理解意图…`);
278
+ const reply = await fetchTransport.complete(config, [
279
+ { role: "system", content: buildSystemPrompt(context) },
280
+ ...this.messages,
281
+ { role: "user", content: questionBlocks.length > 1 ? questionBlocks : question },
282
+ ], [SAVE_KNOWLEDGE_TOOL]);
283
+ const save = reply.toolCalls?.find((call) => call.name === SAVE_KNOWLEDGE_TOOL.name);
284
+ if (save) {
285
+ this.setBusy(true, "正在录入知识库…");
286
+ await this.addNote(String(save.arguments.content ?? ""), String(save.arguments.title ?? ""));
88
287
  return;
89
288
  }
90
- if (command === "create" && args.length) {
91
- const created = ensureKnowledgeBase(root, args.join(" "));
92
- setLines((old) => [...old, `Created ${created.name} (${created.id})`]);
289
+ const answer = (reply.text ?? "").trim();
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));
298
+ }
299
+ catch (error) {
300
+ this.add(new Notice(ROLE.err, color.err, `模型请求失败:${errText(error)}`));
301
+ }
302
+ finally {
303
+ this.setBusy(false);
304
+ }
305
+ }
306
+ // ---------- 命令 ----------
307
+ async command(value) {
308
+ const [cmd, ...rest] = value.split(/\s+/);
309
+ const arg = rest.join(" ");
310
+ switch (cmd) {
311
+ case "/quit":
312
+ case "/exit":
313
+ this.shutdown();
93
314
  return;
94
- }
95
- if (command === "use" && args[0]) {
96
- const selected = listKnowledgeBases(root).find((item) => item.id === args[0] || item.name === args.join(" "));
97
- if (!selected) {
98
- setLines((old) => [...old, "Knowledge base not found."]);
315
+ case "/help":
316
+ this.showOverlay(new HelpPanel(HELP_ITEMS), 66);
317
+ return;
318
+ case "/clear":
319
+ this.stream.clear();
320
+ this.messages = [];
321
+ this.turns = 0;
322
+ this.add(new Notice(ROLE.ok, color.ok, "已清空对话上下文。"));
323
+ return;
324
+ case "/sources": return this.showSources();
325
+ case "/model":
326
+ case "/models":
327
+ if (!rest.length) {
328
+ this.showModelPicker();
99
329
  return;
100
330
  }
101
- store.close();
102
- storeRef.current = new KnowledgeStore(selected.path);
103
- setCurrentBase(selected);
104
- setMessages([]);
105
- setLines((old) => [...old, `Using ${selected.name}`]);
331
+ this.switchModel(Number(rest[0]));
106
332
  return;
107
- }
108
- if (command === "delete" && args[0] && args[1] === "confirm") {
109
- deleteKnowledgeBase(root, args[0]);
110
- setLines((old) => [...old, `Deleted ${args[0]}; backups were retained.`]);
333
+ case "/kb": return this.kbCommand(rest, arg);
334
+ case "/backup": {
335
+ const target = backupKnowledgeBase(this.base, arg || undefined);
336
+ this.add(new Notice(ROLE.ok, color.ok, `已备份到 ${target}`));
111
337
  return;
112
338
  }
113
- setLines((old) => [...old, "Usage: /kb list | /kb create <name> | /kb use <id> | /kb current | /kb delete <id> confirm"]);
114
- return;
339
+ case "/restore":
340
+ if (!arg) {
341
+ this.add(new Notice(ROLE.warn, color.warn, "用法:/restore <备份目录>"));
342
+ return;
343
+ }
344
+ restoreKnowledgeBase(this.base, arg);
345
+ this.add(new Notice(ROLE.ok, color.ok, "已恢复。重启 kb 后重新打开数据库。"));
346
+ return;
347
+ case "/cleanup": return this.cleanup(rest);
348
+ default:
349
+ this.add(new Notice(ROLE.warn, color.warn, `未知命令 ${cmd},输入 /help 查看可用命令。`));
115
350
  }
116
- if (value === "/backup" || value.startsWith("/backup ")) {
117
- setLines((old) => [...old, `Backup created at ${backupKnowledgeBase(currentBase, value.slice(7).trim() || undefined)}`]);
351
+ }
352
+ showSources() {
353
+ const sources = this.store.sources();
354
+ if (!sources.length) {
355
+ this.add(new Notice(ROLE.warn, color.warn, "知识库还是空的,用「导入 <文件路径>」录入内容。"));
118
356
  return;
119
357
  }
120
- if (value.startsWith("/restore ")) {
121
- restoreKnowledgeBase(currentBase, value.slice(9).trim());
122
- setLines((old) => [...old, "Knowledge base restored. Restart the session to reopen the database."]);
358
+ this.add(new Notice(ROLE.ok, color.ok, `共 ${sources.length} 个来源:`));
359
+ for (const source of sources) {
360
+ this.add(new Notice(" ", color.muted, `${String(source.name)} v${String(source.version)} ${String(source.updatedAt)}`));
361
+ }
362
+ }
363
+ showModelPicker() {
364
+ if (!this.models.length) {
365
+ this.add(new Notice(ROLE.err, color.err, "没有已配置的模型。"));
123
366
  return;
124
367
  }
125
- if (value === "/cleanup") {
126
- const oldVersions = store.oldVersions();
127
- setLines((old) => [...old, oldVersions.length ? `Old versions (delete with /cleanup <id> confirm): ${oldVersions.map((item) => `${item.id} ${item.title} v${item.version}`).join("; ")}` : "No old versions."]);
368
+ const items = this.models.map((m, index) => ({
369
+ value: String(index),
370
+ label: `${m.provider}/${m.model}`,
371
+ description: `${m.api}${index === this.modelIndex ? " ← 当前" : ""}`,
372
+ }));
373
+ const list = new SelectList(items, Math.min(items.length, 10), selectListTheme);
374
+ list.onSelect = (item) => { this.switchModel(Number(item.value)); this.closeOverlay(); };
375
+ list.onCancel = () => { this.closeOverlay(); };
376
+ this.showOverlay(list, 68);
377
+ }
378
+ switchModel(index) {
379
+ const next = this.models[index];
380
+ if (!next) {
381
+ this.add(new Notice(ROLE.err, color.err, `没有编号为 ${index} 的模型。`));
128
382
  return;
129
383
  }
130
- if (value.startsWith("/cleanup ")) {
131
- const [id, confirmation] = value.slice(9).trim().split(/\s+/);
132
- if (confirmation === "confirm") {
133
- store.removeVersions([id]);
134
- setLines((old) => [...old, `Deleted old version ${id}.`]);
384
+ this.modelIndex = index;
385
+ this.models = [next, ...this.models.filter((_, i) => i !== index)];
386
+ saveModels(this.models);
387
+ this.add(new Notice(ROLE.ok, color.ok, `已切换到 ${next.provider}/${next.model}`));
388
+ }
389
+ kbCommand(rest, arg) {
390
+ const [sub, ...args] = rest;
391
+ if (!sub || sub === "list") {
392
+ const bases = listKnowledgeBases(this.root);
393
+ this.add(new Notice(ROLE.ok, color.ok, `共 ${bases.length} 个知识库:`));
394
+ for (const item of bases) {
395
+ this.add(new Notice(" ", color.muted, `${item.id}${item.id === this.base.id ? " ← 当前" : ""}`));
135
396
  }
136
- else
137
- setLines((old) => [...old, "Usage: /cleanup <version-id> confirm"]);
138
397
  return;
139
398
  }
140
- setBusy(true);
141
- setLines((old) => [...old, `> ${value}`]);
142
- try {
143
- const result = await ingestOrAsk(value, store, config[modelIndex], messages);
144
- setMessages(result.messages);
145
- store.saveMessage("user", value);
146
- store.saveMessage("assistant", result.text);
147
- setLines((old) => [...old, result.text]);
399
+ if (sub === "current") {
400
+ this.add(new Notice(ROLE.ok, color.ok, `${this.base.name}(${this.base.id})`));
401
+ return;
148
402
  }
149
- catch (error) {
150
- setLines((old) => [...old, `Error: ${error instanceof Error ? error.message : String(error)}`]);
403
+ if (sub === "create") {
404
+ if (!args.length) {
405
+ this.add(new Notice(ROLE.warn, color.warn, "用法:/kb create <名称>"));
406
+ return;
407
+ }
408
+ const created = ensureKnowledgeBase(this.root, args.join(" "));
409
+ this.add(new Notice(ROLE.ok, color.ok, `已创建 ${created.name}(${created.id})`));
410
+ return;
151
411
  }
152
- finally {
153
- setBusy(false);
412
+ if (sub === "use") {
413
+ const selected = listKnowledgeBases(this.root).find((item) => item.id === args[0] || item.name === args.join(" "));
414
+ if (!selected) {
415
+ this.add(new Notice(ROLE.err, color.err, `未找到知识库 ${arg}`));
416
+ return;
417
+ }
418
+ this.store.close();
419
+ this.store = new KnowledgeStore(selected.path);
420
+ this.base = selected;
421
+ this.messages = [];
422
+ this.turns = 0;
423
+ this.add(new Notice(ROLE.ok, color.ok, `已切换到知识库 ${selected.name}(${this.store.sources().length} 个来源)`));
424
+ return;
154
425
  }
155
- }
156
- return _jsxs(Box, { flexDirection: "column", padding: 1, children: [lines.slice(-18).map((line, i) => _jsx(Text, { children: line }, `${i}-${line}`)), _jsxs(Text, { color: busy ? "yellow" : "cyan", children: [busy ? "Searching..." : "> ", input] })] });
157
- }
158
- async function ingestOrAsk(value, store, modelConfig, previous) {
159
- const pathMatch = value.match(/(?:录入|导入|整理|ingest|import)\s+(.+)$/i);
160
- if (pathMatch) {
161
- const source = readLocalFile(pathMatch[1].trim().replace(/^['"]|['"]$/g, ""));
162
- if (!source.hash)
163
- return { text: `${source.title}: unable to parse (${source.content})`, messages: previous };
164
- let content = source.content;
165
- let note = "";
166
- if (source.image) {
426
+ if (sub === "delete") {
427
+ if (!args[0] || args[1] !== "confirm") {
428
+ this.add(new Notice(ROLE.warn, color.warn, "用法:/kb delete <id> confirm"));
429
+ return;
430
+ }
167
431
  try {
168
- const caption = await describeImage(modelConfig, fetchTransport, source.image);
169
- if (caption) {
170
- content = `${source.content}\n\n${caption}`;
171
- note = ",已生成图片描述";
172
- }
432
+ deleteKnowledgeBase(this.root, args[0]);
433
+ this.add(new Notice(ROLE.ok, color.ok, `已删除 ${args[0]},备份已保留。`));
173
434
  }
174
435
  catch (error) {
175
- note = `,图片描述生成失败(仅按文件名检索): ${error instanceof Error ? error.message : String(error)}`;
436
+ this.add(new Notice(ROLE.err, color.err, errText(error)));
176
437
  }
438
+ return;
177
439
  }
178
- const copied = copyOriginal(source.originalPath, store.root);
179
- const result = store.addOrUpdateSource(copied, source.title, content);
180
- return { text: `${result.isNew ? "Imported" : "New version created for"} ${result.title} (v${result.version})${note}`, messages: previous };
440
+ this.add(new Notice(ROLE.warn, color.warn, "用法:/kb list | /kb create <名称> | /kb use <id> | /kb current | /kb delete <id> confirm"));
181
441
  }
182
- const results = store.search(value);
183
- const context = results.length ? results.map((item) => `[${item.id}] ${item.title} (source: ${item.source}, v${item.version})\n${item.content}`).join("\n\n") : "No matching knowledge was found.";
184
- 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: ${value}`;
185
- const blocks = [{ type: "text", text: prompt }];
186
- for (const item of results.filter((hit) => hit.originalPath && isImageFile(hit.originalPath)).slice(0, 4)) {
187
- try {
188
- const image = readImage(item.originalPath);
189
- blocks.push({ type: "text", text: `[附图] ${item.source} v${item.version}` });
190
- blocks.push({ type: "image", mediaType: image.mediaType, base64: image.base64 });
442
+ cleanup(rest) {
443
+ if (!rest.length) {
444
+ const old = this.store.oldVersions();
445
+ if (!old.length) {
446
+ this.add(new Notice(ROLE.ok, color.ok, "没有历史版本。"));
447
+ return;
448
+ }
449
+ this.add(new Notice(ROLE.warn, color.warn, `历史版本 ${old.length} 个,用 /cleanup <版本ID> confirm 删除:`));
450
+ for (const item of old)
451
+ this.add(new Notice(" ", color.muted, `${String(item.id)} ${String(item.title)} v${String(item.version)}`));
452
+ return;
453
+ }
454
+ const [id, confirmation] = rest;
455
+ if (confirmation !== "confirm") {
456
+ this.add(new Notice(ROLE.warn, color.warn, "用法:/cleanup <版本ID> confirm"));
457
+ return;
191
458
  }
192
- catch { /* 忽略无法读取的图片 */ }
459
+ this.store.removeVersions([id]);
460
+ this.add(new Notice(ROLE.ok, color.ok, `已删除历史版本 ${id}。`));
193
461
  }
194
- const agent = new Agent(store, modelConfig, fetchTransport);
195
- return agent.answer(blocks.length > 1 ? blocks : prompt, previous);
196
462
  }
197
463
  export async function main() {
198
464
  const root = defaultDataRoot();
199
465
  mkdirSync(root, { recursive: true });
200
- const knowledgeRoot = root;
201
466
  const base = ensureKnowledgeBase(root, "default");
202
- const configs = await ensureModelConfig();
203
- render(_jsx(App, { config: configs, root: knowledgeRoot, knowledgeBase: base }));
467
+ const models = await ensureModelConfig();
468
+ const app = new KbApp(root, base, models);
469
+ app.start();
204
470
  }