@ganziliang/kb 0.1.7 → 0.2.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
@@ -2,9 +2,13 @@
2
2
 
3
3
  本地知识库命令行工具。它会把本地 Markdown、文本、JSON、CSV 或 Excel 文件导入 SQLite 知识库,然后通过关键词检索相关内容,并调用已配置的模型回答问题。同时支持导入图片:入库时自动生成中文描述参与检索,提问命中后把原图发给模型。
4
4
 
5
+ 界面基于 [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui) 构建,采用差分渲染:Markdown 回答排版、带边框的编辑器、加载动画与命令浮层。
6
+
7
+ > **0.2.0 提示**:这是界面重写版本,Node.js 要求提升到 `>=22.19.0`。存储格式与 `~/.kb` 数据目录未变,旧知识库可直接使用。详见[常见问题](#从-01x-升级到-020-后无法启动)。
8
+
5
9
  ## 环境要求
6
10
 
7
- - Node.js `>=22.5.0`
11
+ - Node.js `>=22.19.0`(当前 UI 依赖的终端渲染库要求,低于此版本无法启动)
8
12
  - 如果使用模型问答,需要可用的模型 API Key
9
13
  - 首次启动需要配置模型
10
14
 
@@ -105,7 +109,48 @@ kb
105
109
 
106
110
  ## 交互操作
107
111
 
108
- 启动 `kb` 后,在底部的 `> ` 提示符后输入内容并按 Enter。
112
+ 启动后界面分为三部分:顶部的启动屏(logo 与环境信息)、中间的对话流、底部的输入框。
113
+
114
+ ```
115
+ ██╗ ██╗ ██████╗
116
+ ██║ ██╔╝ ██╔══██╗
117
+ █████╔╝ ██████╔╝
118
+ ██╔═██╗ ██╔══██╗
119
+ ██║ ██╗ ██████╔╝
120
+ ╚═╝ ╚═╝ ╚═════╝
121
+
122
+ 知识库 · Knowledge Base
123
+
124
+ 知识库 default
125
+ 模型 deepseek-flash
126
+ 来源 1
127
+
128
+ 输入问题开始,或键入 /help 查看命令
129
+ ```
130
+
131
+ 对话流中不同类型的消息有各自的标记:
132
+
133
+ | 标记 | 含义 |
134
+ | --- | --- |
135
+ | `❯` | 你的提问 |
136
+ | `⏺` | 模型回答(Markdown 排版,附检索统计与耗时) |
137
+ | `✔` | 成功(导入完成、命令结果) |
138
+ | `★` | 本次提问附带的原图 |
139
+ | `✖` | 错误 |
140
+ | `!` | 用法提示 |
141
+
142
+ 快捷键:
143
+
144
+ | 按键 | 作用 |
145
+ | --- | --- |
146
+ | `Enter` | 发送 |
147
+ | `Esc` | 关闭浮层 |
148
+ | `Ctrl+C` | 退出 |
149
+ | `Ctrl+A` / `Ctrl+E` | 行首 / 行尾 |
150
+ | `Ctrl+U` / `Ctrl+K` | 删除到行首 / 行尾 |
151
+ | `Ctrl+W` | 删除前一个单词 |
152
+
153
+ 输入框支持多行编辑与撤销:`Ctrl+Z` 撤销,`Ctrl+Y` 重做。
109
154
 
110
155
  ### 导入文件
111
156
 
@@ -257,6 +302,22 @@ npm test
257
302
 
258
303
  ## 常见问题
259
304
 
305
+ ### 从 0.1.x 升级到 0.2.0 后无法启动
306
+
307
+ 0.2.0 换用了新的终端渲染库,Node.js 要求从 `>=22.5.0` 提升到 `>=22.19.0`。
308
+
309
+ ```bash
310
+ node -v # 低于 v22.19.0 会启动失败
311
+ ```
312
+
313
+ 升级 Node 后重装即可:
314
+
315
+ ```bash
316
+ npm i -g @ganziliang/kb@latest
317
+ ```
318
+
319
+ 0.2.0 是界面重写,存储格式和 `~/.kb` 数据目录没有变化,原有知识库和模型配置可以直接沿用。
320
+
260
321
  ### 运行 `kb` 找不到命令
261
322
 
262
323
  确认 npm 全局 bin 目录已经加入 PATH,或者使用源码方式运行:
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,40 @@
1
1
  #!/usr/bin/env node
2
+ import { ensureModelConfig } from "./config.js";
3
+ import { type KnowledgeBase } from "./storage.js";
4
+ type ModelConfig = Awaited<ReturnType<typeof ensureModelConfig>>[number];
5
+ export declare class KbApp {
6
+ private readonly root;
7
+ private readonly tui;
8
+ private readonly stream;
9
+ private readonly editor;
10
+ private store;
11
+ private base;
12
+ private models;
13
+ private modelIndex;
14
+ private messages;
15
+ private turns;
16
+ private busy;
17
+ private loader;
18
+ private overlay;
19
+ private closed;
20
+ constructor(root: string, base: KnowledgeBase, models: ModelConfig[]);
21
+ start(): void;
22
+ private get model();
23
+ private add;
24
+ private setBusy;
25
+ private showOverlay;
26
+ private closeOverlay;
27
+ private shutdown;
28
+ private submit;
29
+ private run;
30
+ private ingest;
31
+ private ask;
32
+ private command;
33
+ private showSources;
34
+ private showModelPicker;
35
+ private switchModel;
36
+ private kbCommand;
37
+ private cleanup;
38
+ }
2
39
  export declare function main(): Promise<void>;
40
+ export {};
package/dist/cli.js CHANGED
@@ -1,204 +1,390 @@
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
5
  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",
6
+ import { backupKnowledgeBase, copyOriginal, defaultDataRoot, deleteKnowledgeBase, ensureKnowledgeBase, isImageFile, KnowledgeStore, listKnowledgeBases, readImage, readLocalFile, restoreKnowledgeBase, } 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
+ const HELP_ITEMS = [
11
+ ["/help", "显示本帮助"],
12
+ ["/clear", "清空对话上下文"],
13
+ ["/sources", "列出知识来源与版本"],
14
+ ["/model", "查看 / 切换模型"],
15
+ ["/kb", "多知识库管理"],
16
+ ["/backup", "备份当前知识库"],
17
+ ["/restore", "��备份恢复"],
18
+ ["/cleanup", "清理历史版本"],
19
+ ["/quit", "退出"],
20
+ ["", ""],
21
+ ["导入 <路径>", "录入 md / txt / json / csv / xlsx / png / jpg"],
16
22
  ];
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;
23
+ const ROLE = { user: "❯", assistant: "⏺", ok: "✔", err: "✖", warn: "!", image: "★" };
24
+ export class KbApp {
25
+ root;
26
+ tui;
27
+ stream = new Container();
28
+ editor;
29
+ store;
30
+ base;
31
+ models;
32
+ modelIndex = 0;
33
+ messages = [];
34
+ turns = 0;
35
+ busy = false;
36
+ loader = null;
37
+ overlay = null;
38
+ closed = false;
39
+ constructor(root, base, models) {
40
+ this.root = root;
41
+ this.base = base;
42
+ this.models = models;
43
+ this.store = new KnowledgeStore(base.path);
44
+ this.tui = new TuiMainScreen(new ProcessTerminal());
45
+ this.editor = new Editor(this.tui, editorTheme, { paddingX: 1 });
46
+ this.editor.onSubmit = (text) => { void this.submit(text.trim()); };
47
+ }
48
+ // ---------- 启动 ----------
49
+ start() {
50
+ this.tui.addChild(new Splash({
51
+ base: this.base.name,
52
+ model: this.model?.model ?? "-",
53
+ dataPath: this.root,
54
+ sources: this.store.sources().length,
55
+ }));
56
+ this.tui.addChild(this.stream);
57
+ this.tui.addChild(new Spacer(1));
58
+ this.tui.addChild(new Rule());
59
+ this.tui.addChild(this.editor);
60
+ this.tui.setFocus(this.editor);
61
+ this.tui.addInputListener((data) => {
62
+ if (matchesKey(data, "ctrl+c")) {
63
+ this.shutdown();
64
+ return { consume: true };
65
+ }
66
+ if (matchesKey(data, "escape") && this.tui.hasOverlay()) {
67
+ this.closeOverlay();
68
+ return { consume: true };
69
+ }
70
+ return {};
71
+ });
72
+ this.tui.start();
73
+ }
74
+ get model() { return this.models[this.modelIndex]; }
75
+ // ---------- 渲染原语 ----------
76
+ add(component) {
77
+ this.stream.addChild(component);
78
+ this.tui.requestRender();
79
+ }
80
+ setBusy(on, message = "") {
81
+ if (on) {
82
+ this.editor.disableSubmit = true;
83
+ if (this.loader) {
84
+ this.loader.setMessage(message);
85
+ }
86
+ else {
87
+ this.loader = new Loader(this.tui, color.accent, color.muted, message);
88
+ this.stream.addChild(this.loader);
89
+ this.loader.start();
90
+ }
33
91
  }
34
- if (key.return && input.trim() && !busy) {
35
- void submit(input.trim());
36
- setInput("");
37
- return;
92
+ else {
93
+ this.editor.disableSubmit = false;
94
+ if (this.loader) {
95
+ this.loader.stop();
96
+ this.stream.removeChild(this.loader);
97
+ this.loader = null;
98
+ }
38
99
  }
39
- if (key.backspace || key.delete) {
40
- setInput((value) => value.slice(0, -1));
41
- return;
100
+ this.tui.requestRender();
101
+ }
102
+ showOverlay(component, width) {
103
+ this.closeOverlay();
104
+ this.overlay = this.tui.showOverlay(component, { width, anchor: "center", margin: 1 });
105
+ }
106
+ closeOverlay() {
107
+ if (this.overlay) {
108
+ this.overlay.hide();
109
+ this.overlay = null;
42
110
  }
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();
111
+ this.tui.setFocus(this.editor);
112
+ }
113
+ shutdown() {
114
+ if (this.closed)
50
115
  return;
116
+ this.closed = true;
117
+ try {
118
+ this.store.close();
51
119
  }
52
- if (value === "/clear") {
53
- setLines(["Conversation cleared."]);
54
- setMessages([]);
120
+ catch { /* 已关闭 */ }
121
+ this.tui.stop();
122
+ process.exit(0);
123
+ }
124
+ // ---------- 输入入口 ----------
125
+ async submit(value) {
126
+ if (this.busy || !value)
55
127
  return;
56
- }
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", "图片入库时自动生成中文描述用于检索,提问命中后会把原图一并发给模型"]);
128
+ this.editor.setText("");
129
+ this.tui.requestRender();
130
+ if (value.startsWith("/")) {
131
+ await this.command(value);
59
132
  return;
60
133
  }
61
- if (value === "/sources") {
62
- setLines((old) => [...old, ...store.sources().map((s) => `${s.name} | ${s.path} | v${s.version} | ${s.updatedAt}`)]);
134
+ this.turns += 1;
135
+ this.add(new UserMessage(value));
136
+ await this.run(value);
137
+ }
138
+ async run(value) {
139
+ const pathMatch = value.match(/(?:录入|导入|整理|ingest|import)\s+(.+)$/i);
140
+ if (pathMatch) {
141
+ await this.ingest(pathMatch[1].trim().replace(/^['"]|['"]$/g, ""));
63
142
  return;
64
143
  }
65
- if (value === "/model" || value === "/models") {
66
- setLines((old) => [...old, ...config.map((item, i) => `${i === modelIndex ? "*" : " "} ${i}: ${item.provider}/${item.model} (${item.api})`)]);
144
+ await this.ask(value);
145
+ }
146
+ // ---------- 导入 ----------
147
+ async ingest(path) {
148
+ try {
149
+ this.setBusy(true, "读取文件…");
150
+ const source = readLocalFile(path);
151
+ if (!source.hash) {
152
+ this.add(new Notice(ROLE.err, color.err, `${source.title}:无法解析(${source.content})`));
153
+ return;
154
+ }
155
+ let content = source.content;
156
+ let note = "";
157
+ if (source.image) {
158
+ this.setBusy(true, "识别图片并生成描述…");
159
+ try {
160
+ const caption = await describeImage(this.model, fetchTransport, source.image);
161
+ if (caption) {
162
+ content = `${source.content}\n\n${caption}`;
163
+ note = ",已生成图片描述";
164
+ }
165
+ }
166
+ catch {
167
+ note = ",图片描述生成失败(仅按文件名检索)";
168
+ }
169
+ }
170
+ const copied = copyOriginal(source.originalPath, this.store.root);
171
+ const result = this.store.addOrUpdateSource(copied, source.title, content);
172
+ this.add(new Notice(ROLE.ok, color.ok, `${result.isNew ? "已导入" : "已创建新版本"} ${result.title}(v${result.version})${note}`));
173
+ }
174
+ catch (error) {
175
+ this.add(new Notice(ROLE.err, color.err, `导入失败:${errText(error)}`));
176
+ }
177
+ finally {
178
+ this.setBusy(false);
179
+ }
180
+ }
181
+ // ---------- 问答 ----------
182
+ async ask(question) {
183
+ const config = this.model;
184
+ if (!config) {
185
+ this.add(new Notice(ROLE.err, color.err, "没有可用模型,请先配置模型。"));
67
186
  return;
68
187
  }
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}`]);
188
+ const started = Date.now();
189
+ try {
190
+ this.setBusy(true, "检索知识库…");
191
+ const results = this.store.search(question);
192
+ const context = results.length
193
+ ? 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 }];
197
+ const attached = [];
198
+ for (const item of results.filter((hit) => hit.originalPath && isImageFile(hit.originalPath)).slice(0, 4)) {
199
+ try {
200
+ 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 });
203
+ attached.push({ name: item.source, version: item.version });
204
+ }
205
+ catch { /* 跳过无法读取的图片 */ }
75
206
  }
76
- else
77
- setLines((old) => [...old, "Unknown model index."]);
78
- return;
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));
218
+ }
219
+ catch (error) {
220
+ this.add(new Notice(ROLE.err, color.err, `模型请求失败:${errText(error)}`));
221
+ }
222
+ finally {
223
+ this.setBusy(false);
79
224
  }
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 ? " *" : ""}`)]);
225
+ }
226
+ // ---------- 命令 ----------
227
+ async command(value) {
228
+ const [cmd, ...rest] = value.split(/\s+/);
229
+ const arg = rest.join(" ");
230
+ switch (cmd) {
231
+ case "/quit":
232
+ case "/exit":
233
+ this.shutdown();
84
234
  return;
85
- }
86
- if (command === "current") {
87
- setLines((old) => [...old, `${currentBase.name} (${currentBase.id})`]);
235
+ case "/help":
236
+ this.showOverlay(new HelpPanel(HELP_ITEMS), 66);
88
237
  return;
89
- }
90
- if (command === "create" && args.length) {
91
- const created = ensureKnowledgeBase(root, args.join(" "));
92
- setLines((old) => [...old, `Created ${created.name} (${created.id})`]);
238
+ case "/clear":
239
+ this.stream.clear();
240
+ this.messages = [];
241
+ this.turns = 0;
242
+ this.add(new Notice(ROLE.ok, color.ok, "已清空对话上下文。"));
93
243
  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."]);
244
+ case "/sources": return this.showSources();
245
+ case "/model":
246
+ case "/models":
247
+ if (!rest.length) {
248
+ this.showModelPicker();
99
249
  return;
100
250
  }
101
- store.close();
102
- storeRef.current = new KnowledgeStore(selected.path);
103
- setCurrentBase(selected);
104
- setMessages([]);
105
- setLines((old) => [...old, `Using ${selected.name}`]);
251
+ this.switchModel(Number(rest[0]));
106
252
  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.`]);
253
+ case "/kb": return this.kbCommand(rest, arg);
254
+ case "/backup": {
255
+ const target = backupKnowledgeBase(this.base, arg || undefined);
256
+ this.add(new Notice(ROLE.ok, color.ok, `已备份到 ${target}`));
111
257
  return;
112
258
  }
113
- setLines((old) => [...old, "Usage: /kb list | /kb create <name> | /kb use <id> | /kb current | /kb delete <id> confirm"]);
114
- return;
259
+ case "/restore":
260
+ if (!arg) {
261
+ this.add(new Notice(ROLE.warn, color.warn, "用法:/restore <备份目录>"));
262
+ return;
263
+ }
264
+ restoreKnowledgeBase(this.base, arg);
265
+ this.add(new Notice(ROLE.ok, color.ok, "已恢复。重启 kb 后重新打开数据库。"));
266
+ return;
267
+ case "/cleanup": return this.cleanup(rest);
268
+ default:
269
+ this.add(new Notice(ROLE.warn, color.warn, `未知命令 ${cmd},输入 /help 查看可用命令。`));
115
270
  }
116
- if (value === "/backup" || value.startsWith("/backup ")) {
117
- setLines((old) => [...old, `Backup created at ${backupKnowledgeBase(currentBase, value.slice(7).trim() || undefined)}`]);
271
+ }
272
+ showSources() {
273
+ const sources = this.store.sources();
274
+ if (!sources.length) {
275
+ this.add(new Notice(ROLE.warn, color.warn, "知识库还是空的,用「导入 <文件路径>」录入内容。"));
118
276
  return;
119
277
  }
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."]);
278
+ this.add(new Notice(ROLE.ok, color.ok, `共 ${sources.length} 个来源:`));
279
+ for (const source of sources) {
280
+ this.add(new Notice(" ", color.muted, `${String(source.name)} v${String(source.version)} ${String(source.updatedAt)}`));
281
+ }
282
+ }
283
+ showModelPicker() {
284
+ if (!this.models.length) {
285
+ this.add(new Notice(ROLE.err, color.err, "没有已配置的模型。"));
123
286
  return;
124
287
  }
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."]);
288
+ const items = this.models.map((m, index) => ({
289
+ value: String(index),
290
+ label: `${m.provider}/${m.model}`,
291
+ description: `${m.api}${index === this.modelIndex ? " ← 当前" : ""}`,
292
+ }));
293
+ const list = new SelectList(items, Math.min(items.length, 10), selectListTheme);
294
+ list.onSelect = (item) => { this.switchModel(Number(item.value)); this.closeOverlay(); };
295
+ list.onCancel = () => { this.closeOverlay(); };
296
+ this.showOverlay(list, 68);
297
+ }
298
+ switchModel(index) {
299
+ const next = this.models[index];
300
+ if (!next) {
301
+ this.add(new Notice(ROLE.err, color.err, `没有编号为 ${index} 的模型。`));
128
302
  return;
129
303
  }
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}.`]);
304
+ this.modelIndex = index;
305
+ this.models = [next, ...this.models.filter((_, i) => i !== index)];
306
+ saveModels(this.models);
307
+ this.add(new Notice(ROLE.ok, color.ok, `已切换到 ${next.provider}/${next.model}`));
308
+ }
309
+ kbCommand(rest, arg) {
310
+ const [sub, ...args] = rest;
311
+ if (!sub || sub === "list") {
312
+ const bases = listKnowledgeBases(this.root);
313
+ this.add(new Notice(ROLE.ok, color.ok, `共 ${bases.length} 个知识库:`));
314
+ for (const item of bases) {
315
+ this.add(new Notice(" ", color.muted, `${item.id}${item.id === this.base.id ? " ← 当前" : ""}`));
135
316
  }
136
- else
137
- setLines((old) => [...old, "Usage: /cleanup <version-id> confirm"]);
138
317
  return;
139
318
  }
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]);
319
+ if (sub === "current") {
320
+ this.add(new Notice(ROLE.ok, color.ok, `${this.base.name}(${this.base.id})`));
321
+ return;
148
322
  }
149
- catch (error) {
150
- setLines((old) => [...old, `Error: ${error instanceof Error ? error.message : String(error)}`]);
323
+ if (sub === "create") {
324
+ if (!args.length) {
325
+ this.add(new Notice(ROLE.warn, color.warn, "用法:/kb create <名称>"));
326
+ return;
327
+ }
328
+ const created = ensureKnowledgeBase(this.root, args.join(" "));
329
+ this.add(new Notice(ROLE.ok, color.ok, `已创建 ${created.name}(${created.id})`));
330
+ return;
151
331
  }
152
- finally {
153
- setBusy(false);
332
+ if (sub === "use") {
333
+ const selected = listKnowledgeBases(this.root).find((item) => item.id === args[0] || item.name === args.join(" "));
334
+ if (!selected) {
335
+ this.add(new Notice(ROLE.err, color.err, `未找到知识库 ${arg}`));
336
+ return;
337
+ }
338
+ this.store.close();
339
+ this.store = new KnowledgeStore(selected.path);
340
+ this.base = selected;
341
+ this.messages = [];
342
+ this.turns = 0;
343
+ this.add(new Notice(ROLE.ok, color.ok, `已切换到知识库 ${selected.name}(${this.store.sources().length} 个来源)`));
344
+ return;
154
345
  }
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) {
346
+ if (sub === "delete") {
347
+ if (!args[0] || args[1] !== "confirm") {
348
+ this.add(new Notice(ROLE.warn, color.warn, "用法:/kb delete <id> confirm"));
349
+ return;
350
+ }
167
351
  try {
168
- const caption = await describeImage(modelConfig, fetchTransport, source.image);
169
- if (caption) {
170
- content = `${source.content}\n\n${caption}`;
171
- note = ",已生成图片描述";
172
- }
352
+ deleteKnowledgeBase(this.root, args[0]);
353
+ this.add(new Notice(ROLE.ok, color.ok, `已删除 ${args[0]},备份已保留。`));
173
354
  }
174
355
  catch (error) {
175
- note = `,图片描述生成失败(仅按文件名检索): ${error instanceof Error ? error.message : String(error)}`;
356
+ this.add(new Notice(ROLE.err, color.err, errText(error)));
176
357
  }
358
+ return;
177
359
  }
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 };
360
+ this.add(new Notice(ROLE.warn, color.warn, "用法:/kb list | /kb create <名称> | /kb use <id> | /kb current | /kb delete <id> confirm"));
181
361
  }
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 });
362
+ cleanup(rest) {
363
+ if (!rest.length) {
364
+ const old = this.store.oldVersions();
365
+ if (!old.length) {
366
+ this.add(new Notice(ROLE.ok, color.ok, "没有历史版本。"));
367
+ return;
368
+ }
369
+ this.add(new Notice(ROLE.warn, color.warn, `历史版本 ${old.length} 个,用 /cleanup <版本ID> confirm 删除:`));
370
+ for (const item of old)
371
+ this.add(new Notice(" ", color.muted, `${String(item.id)} ${String(item.title)} v${String(item.version)}`));
372
+ return;
373
+ }
374
+ const [id, confirmation] = rest;
375
+ if (confirmation !== "confirm") {
376
+ this.add(new Notice(ROLE.warn, color.warn, "用法:/cleanup <版本ID> confirm"));
377
+ return;
191
378
  }
192
- catch { /* 忽略无法读取的图片 */ }
379
+ this.store.removeVersions([id]);
380
+ this.add(new Notice(ROLE.ok, color.ok, `已删除历史版本 ${id}。`));
193
381
  }
194
- const agent = new Agent(store, modelConfig, fetchTransport);
195
- return agent.answer(blocks.length > 1 ? blocks : prompt, previous);
196
382
  }
197
383
  export async function main() {
198
384
  const root = defaultDataRoot();
199
385
  mkdirSync(root, { recursive: true });
200
- const knowledgeRoot = root;
201
386
  const base = ensureKnowledgeBase(root, "default");
202
- const configs = await ensureModelConfig();
203
- render(_jsx(App, { config: configs, root: knowledgeRoot, knowledgeBase: base }));
387
+ const models = await ensureModelConfig();
388
+ const app = new KbApp(root, base, models);
389
+ app.start();
204
390
  }
@@ -0,0 +1,27 @@
1
+ import type { EditorTheme, ImageTheme, MarkdownTheme, SelectListTheme } from "@earendil-works/pi-tui";
2
+ export declare const fg: (r: number, g: number, b: number) => (s: string) => string;
3
+ export declare const bg: (r: number, g: number, b: number) => (s: string) => string;
4
+ export declare const bold: (s: string) => string;
5
+ export declare const dim: (s: string) => string;
6
+ export declare const italic: (s: string) => string;
7
+ export declare const underline: (s: string) => string;
8
+ export declare const strike: (s: string) => string;
9
+ /** 全局配色:slate/sky/teal 冷色调,深浅终端下都有足够对比度。 */
10
+ export declare const color: {
11
+ accent: (s: string) => string;
12
+ accent2: (s: string) => string;
13
+ user: (s: string) => string;
14
+ text: (s: string) => string;
15
+ muted: (s: string) => string;
16
+ dim: (s: string) => string;
17
+ border: (s: string) => string;
18
+ ok: (s: string) => string;
19
+ warn: (s: string) => string;
20
+ err: (s: string) => string;
21
+ chip: (s: string) => string;
22
+ userBg: (s: string) => string;
23
+ };
24
+ export declare const markdownTheme: MarkdownTheme;
25
+ export declare const selectListTheme: SelectListTheme;
26
+ export declare const editorTheme: EditorTheme;
27
+ export declare const imageTheme: ImageTheme;
package/dist/theme.js ADDED
@@ -0,0 +1,52 @@
1
+ export const fg = (r, g, b) => (s) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
2
+ export const bg = (r, g, b) => (s) => `\x1b[48;2;${r};${g};${b}m${s}\x1b[49m`;
3
+ export const bold = (s) => `\x1b[1m${s}\x1b[22m`;
4
+ export const dim = (s) => `\x1b[2m${s}\x1b[22m`;
5
+ export const italic = (s) => `\x1b[3m${s}\x1b[23m`;
6
+ export const underline = (s) => `\x1b[4m${s}\x1b[24m`;
7
+ export const strike = (s) => `\x1b[9m${s}\x1b[29m`;
8
+ /** 全局配色:slate/sky/teal 冷色调,深浅终端下都有足够对比度。 */
9
+ export const color = {
10
+ accent: fg(56, 189, 248),
11
+ accent2: fg(45, 212, 191),
12
+ user: fg(226, 232, 240),
13
+ text: fg(203, 213, 225),
14
+ muted: fg(100, 116, 139),
15
+ dim: fg(71, 85, 105),
16
+ border: fg(51, 65, 85),
17
+ ok: fg(74, 222, 128),
18
+ warn: fg(251, 191, 36),
19
+ err: fg(248, 113, 113),
20
+ chip: bg(30, 41, 59),
21
+ userBg: bg(15, 23, 42),
22
+ };
23
+ export const markdownTheme = {
24
+ heading: (t) => bold(color.accent2(t)),
25
+ link: (t) => color.accent(t),
26
+ linkUrl: (t) => dim(color.muted(t)),
27
+ code: (t) => color.warn(t),
28
+ codeBlock: (t) => color.text(t),
29
+ codeBlockBorder: (t) => color.border(t),
30
+ quote: (t) => dim(color.muted(t)),
31
+ quoteBorder: (t) => color.accent2(t),
32
+ hr: (t) => color.border(t),
33
+ listBullet: (t) => color.accent(t),
34
+ bold: (t) => bold(color.user(t)),
35
+ italic: (t) => italic(color.text(t)),
36
+ strikethrough: (t) => strike(color.text(t)),
37
+ underline: (t) => underline(color.text(t)),
38
+ };
39
+ export const selectListTheme = {
40
+ selectedPrefix: (t) => color.accent(bold(t)),
41
+ selectedText: (t) => bold(color.user(t)),
42
+ description: (t) => color.muted(t),
43
+ scrollInfo: (t) => dim(color.muted(t)),
44
+ noMatch: (t) => dim(color.muted(t)),
45
+ };
46
+ export const editorTheme = {
47
+ borderColor: (t) => color.border(t),
48
+ selectList: selectListTheme,
49
+ };
50
+ export const imageTheme = {
51
+ fallbackColor: (t) => color.muted(t),
52
+ };
package/dist/ui.d.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { type Component } from "@earendil-works/pi-tui";
2
+ import { dim, selectListTheme } from "./theme.js";
3
+ /** 把文本按可见宽度换行;保证每行都不超过 width。 */
4
+ export declare function wrap(text: string, width: number): string[];
5
+ /** 通用多行文本组件,按渲染宽度截断,保证永不超宽。 */
6
+ export declare class Lines implements Component {
7
+ private lines;
8
+ constructor(lines?: string[]);
9
+ setLines(lines: string[]): void;
10
+ invalidate(): void;
11
+ render(width: number): string[];
12
+ }
13
+ /** 启动屏:logo + 环境信息。 */
14
+ export declare class Splash implements Component {
15
+ private info;
16
+ constructor(info: {
17
+ base: string;
18
+ model: string;
19
+ dataPath: string;
20
+ sources: number;
21
+ });
22
+ invalidate(): void;
23
+ render(width: number): string[];
24
+ }
25
+ /** 顶部状态条:带圆角边框的一行摘要。 */ export declare class StatusBar implements Component {
26
+ private info;
27
+ constructor(info: {
28
+ base: string;
29
+ model: string;
30
+ sources: number;
31
+ turns: number;
32
+ });
33
+ setInfo(info: {
34
+ base: string;
35
+ model: string;
36
+ sources: number;
37
+ turns: number;
38
+ }): void;
39
+ invalidate(): void;
40
+ render(width: number): string[];
41
+ }
42
+ /** 用户提问。 */
43
+ export declare class UserMessage implements Component {
44
+ private text;
45
+ constructor(text: string);
46
+ invalidate(): void;
47
+ render(width: number): string[];
48
+ }
49
+ /** 助手回答:图标 + Markdown 正文 + 元信息脚注。 */
50
+ export declare class AssistantMessage implements Component {
51
+ private meta;
52
+ private md;
53
+ constructor(text: string, meta?: string);
54
+ setMeta(meta: string): void;
55
+ invalidate(): void;
56
+ render(width: number): string[];
57
+ }
58
+ /** 单行提示(成功 / 信息 / 警告 / 错误)。 */
59
+ export declare class Notice implements Component {
60
+ private icon;
61
+ private style;
62
+ private text;
63
+ constructor(icon: string, style: (s: string) => string, text: string);
64
+ invalidate(): void;
65
+ render(width: number): string[];
66
+ }
67
+ /** 检索命中的附图标记。 */
68
+ export declare class ImageNotice implements Component {
69
+ private name;
70
+ private version;
71
+ constructor(name: string, version: number);
72
+ invalidate(): void;
73
+ render(width: number): string[];
74
+ }
75
+ /** 水平分隔线。 */
76
+ export declare class Rule implements Component {
77
+ invalidate(): void;
78
+ render(width: number): string[];
79
+ }
80
+ /** 帮助面板(作为 overlay 内容)。 */
81
+ export declare class HelpPanel implements Component {
82
+ private items;
83
+ constructor(items: Array<[string, string]>);
84
+ invalidate(): void;
85
+ render(width: number): string[];
86
+ }
87
+ /** 供 SelectList 复用的主题。 */
88
+ export { selectListTheme, dim };
package/dist/ui.js ADDED
@@ -0,0 +1,171 @@
1
+ import { Markdown, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+ import { bold, color, dim, markdownTheme, selectListTheme } from "./theme.js";
3
+ /** 把文本按可见宽度换行;保证每行都不超过 width。 */
4
+ export function wrap(text, width) {
5
+ if (width <= 0)
6
+ return [""];
7
+ const out = [];
8
+ for (const raw of text.split("\n")) {
9
+ if (raw === "") {
10
+ out.push("");
11
+ continue;
12
+ }
13
+ for (const piece of wrapTextWithAnsi(raw, width))
14
+ out.push(piece);
15
+ }
16
+ return out;
17
+ }
18
+ const pad = (s, w) => s + " ".repeat(Math.max(0, w - visibleWidth(s)));
19
+ /** 统一裁剪:保证组件输出永不超过给定宽度(pi-tui 会因超宽报错)。 */
20
+ const clip = (lines, width) => lines.map((l) => truncateToWidth(l, Math.max(0, width)));
21
+ /** 通用多行文本组件,按渲染宽度截断,保证永不超宽。 */
22
+ export class Lines {
23
+ lines = [];
24
+ constructor(lines = []) { this.lines = lines; }
25
+ setLines(lines) { this.lines = lines; }
26
+ invalidate() { }
27
+ render(width) {
28
+ return this.lines.map((l) => truncateToWidth(l, Math.max(0, width)));
29
+ }
30
+ }
31
+ /** 启动屏:logo + 环境信息。 */
32
+ export class Splash {
33
+ info;
34
+ constructor(info) {
35
+ this.info = info;
36
+ }
37
+ invalidate() { }
38
+ render(width) {
39
+ const logo = ["██╗ ██╗ ██████╗", "██║ ██╔╝ ██╔══██╗", "█████╔╝ ██████╔╝", "██╔═██╗ ██╔══██╗", "██║ ██╗ ██████╔╝", "╚═╝ ╚═╝ ╚═════╝ "];
40
+ const field = (k, v, val) => ` ${color.muted(pad(k, 8))}${val(v)}`;
41
+ const lines = [
42
+ "",
43
+ ...logo.map((l) => " " + color.accent(bold(l))),
44
+ "",
45
+ " " + color.text(bold("知识库")) + color.border(" · ") + color.muted("Knowledge Base"),
46
+ "",
47
+ field("知识库", this.info.base, color.user),
48
+ field("模型", this.info.model, color.user),
49
+ field("来源", String(this.info.sources), color.user),
50
+ field("数据", this.info.dataPath, (s) => color.dim(s)),
51
+ "",
52
+ " " + color.dim("输入问题开始,或键入 ") + color.accent("/help") + color.dim(" 查看命令"),
53
+ "",
54
+ ];
55
+ return lines.map((l) => truncateToWidth(l, Math.max(0, width)));
56
+ }
57
+ }
58
+ /** 顶部状态条:带圆角边框的一行摘要。 */ export class StatusBar {
59
+ info;
60
+ constructor(info) {
61
+ this.info = info;
62
+ }
63
+ setInfo(info) { this.info = info; }
64
+ invalidate() { }
65
+ render(width) {
66
+ const w = Math.max(0, width);
67
+ const inner = Math.max(0, w - 4);
68
+ const label = " KB ";
69
+ const top = color.border("╭─") + color.accent(bold(label)) + color.border("─".repeat(Math.max(0, w - 3 - visibleWidth(label))) + "╮");
70
+ const bot = color.border("╰" + "─".repeat(Math.max(0, w - 2)) + "╯");
71
+ const stat = (k, v) => `${color.muted(k)} ${color.user(v)}`;
72
+ const content = " " + [
73
+ stat("知识库", this.info.base),
74
+ stat("模型", this.info.model),
75
+ stat("来源", String(this.info.sources)),
76
+ stat("会话", String(this.info.turns)),
77
+ ].join(color.border(" · "));
78
+ const row = color.border("│") + " " + pad(truncateToWidth(content, inner), inner) + " " + color.border("│");
79
+ return clip([top, row, bot], w);
80
+ }
81
+ }
82
+ /** 用户提问。 */
83
+ export class UserMessage {
84
+ text;
85
+ constructor(text) {
86
+ this.text = text;
87
+ }
88
+ invalidate() { }
89
+ render(width) {
90
+ const prefix = ` ${color.accent2(bold("❯"))} `;
91
+ const body = wrap(this.text, Math.max(1, width - 4));
92
+ return body.map((l, i) => (i === 0 ? prefix + color.user(l) : " " + color.user(l)));
93
+ }
94
+ }
95
+ /** 助手回答:图标 + Markdown 正文 + 元信息脚注。 */
96
+ export class AssistantMessage {
97
+ meta;
98
+ md;
99
+ constructor(text, meta = "") {
100
+ this.meta = meta;
101
+ this.md = new Markdown(text, 0, 0, markdownTheme);
102
+ }
103
+ setMeta(meta) { this.meta = meta; }
104
+ invalidate() { this.md.invalidate(); }
105
+ render(width) {
106
+ const indent = " ";
107
+ const bodyWidth = Math.max(10, width - indent.length);
108
+ const lines = [` ${color.ok(bold("⏺"))}`, ""];
109
+ for (const l of this.md.render(bodyWidth))
110
+ lines.push(indent + l.replace(/\s+$/, ""));
111
+ if (this.meta)
112
+ lines.push("", `${indent}${color.dim("·")} ${color.muted(this.meta)}`);
113
+ return lines.map((l) => truncateToWidth(l, Math.max(0, width)));
114
+ }
115
+ }
116
+ /** 单行提示(成功 / 信息 / 警告 / 错误)。 */
117
+ export class Notice {
118
+ icon;
119
+ style;
120
+ text;
121
+ constructor(icon, style, text) {
122
+ this.icon = icon;
123
+ this.style = style;
124
+ this.text = text;
125
+ }
126
+ invalidate() { }
127
+ render(width) {
128
+ const prefix = ` ${this.style(this.icon)} `;
129
+ return wrap(this.text, Math.max(1, width - 4)).map((l, i) => (i === 0 ? prefix + color.text(l) : " " + color.text(l)));
130
+ }
131
+ }
132
+ /** 检索命中的附图标记。 */
133
+ export class ImageNotice {
134
+ name;
135
+ version;
136
+ constructor(name, version) {
137
+ this.name = name;
138
+ this.version = version;
139
+ }
140
+ invalidate() { }
141
+ render(width) {
142
+ const line = ` ${color.accent2("★")} ${color.muted("附图")} ${color.chip(color.text(` ${this.name} `))} ${color.dim("v" + this.version)}`;
143
+ return [truncateToWidth(line, Math.max(0, width))];
144
+ }
145
+ }
146
+ /** 水平分隔线。 */
147
+ export class Rule {
148
+ invalidate() { }
149
+ render(width) {
150
+ return [color.dim("─".repeat(Math.max(0, width)))];
151
+ }
152
+ }
153
+ /** 帮助面板(作为 overlay 内容)。 */
154
+ export class HelpPanel {
155
+ items;
156
+ constructor(items) { this.items = items; }
157
+ invalidate() { }
158
+ render(width) {
159
+ const w = Math.min(Math.max(0, width), 72);
160
+ const inner = Math.max(0, w - 4);
161
+ const title = " 命令 ";
162
+ const keyWidth = Math.max(10, ...this.items.map(([k]) => visibleWidth(k))) + 2;
163
+ const top = color.border("╭─") + color.accent(bold(title)) + color.border("─".repeat(Math.max(0, w - 3 - visibleWidth(title))) + "╮");
164
+ const bot = color.border("╰" + "─".repeat(Math.max(0, w - 2)) + "╯");
165
+ const row = (c) => color.border("│") + " " + pad(truncateToWidth(c, inner), inner) + " " + color.border("│");
166
+ const body = this.items.map(([k, d]) => row(k ? " " + color.accent(k) + " ".repeat(Math.max(1, keyWidth - visibleWidth(k))) + color.muted(d) : ""));
167
+ return clip([top, row(""), ...body, row(""), bot], w);
168
+ }
169
+ }
170
+ /** 供 SelectList 复用的主题。 */
171
+ export { selectListTheme, dim };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ganziliang/kb",
3
- "version": "0.1.7",
4
- "description": "Local knowledge base agent CLI",
3
+ "version": "0.2.0",
4
+ "description": "Local knowledge base agent CLI with a pi-tui interface",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "kb": "dist/entry.js"
@@ -17,17 +17,15 @@
17
17
  "test": "npm run build && node --test"
18
18
  },
19
19
  "engines": {
20
- "node": ">=22.5.0"
20
+ "node": ">=22.19.0"
21
21
  },
22
22
  "dependencies": {
23
+ "@earendil-works/pi-tui": "^0.85.1",
23
24
  "@ganziliang/kb-model-setup": "^0.1.2",
24
- "ink": "^5.1.0",
25
- "react": "^18.3.1",
26
25
  "xlsx": "^0.18.5"
27
26
  },
28
27
  "devDependencies": {
29
28
  "@types/node": "^22.10.0",
30
- "@types/react": "^18.3.12",
31
29
  "typescript": "^5.7.2"
32
30
  }
33
31
  }