@ganziliang/kb 0.1.6 → 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 +87 -3
- package/dist/cli.d.ts +38 -0
- package/dist/cli.js +343 -133
- package/dist/model.d.ts +21 -2
- package/dist/model.js +33 -4
- package/dist/storage.d.ts +16 -6
- package/dist/storage.js +31 -2
- package/dist/theme.d.ts +27 -0
- package/dist/theme.js +52 -0
- package/dist/ui.d.ts +88 -0
- package/dist/ui.js +171 -0
- package/package.json +4 -6
package/README.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# @ganziliang/kb
|
|
2
2
|
|
|
3
|
-
本地知识库命令行工具。它会把本地 Markdown、文本、JSON、CSV 或 Excel 文件导入 SQLite
|
|
3
|
+
本地知识库命令行工具。它会把本地 Markdown、文本、JSON、CSV 或 Excel 文件导入 SQLite 知识库,然后通过关键词检索相关内容,并调用已配置的模型回答问题。同时支持导入图片:入库时自动生成中文描述参与检索,提问命中后把原图发给模型。
|
|
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-后无法启动)。
|
|
4
8
|
|
|
5
9
|
## 环境要求
|
|
6
10
|
|
|
7
|
-
- Node.js `>=22.
|
|
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
|
-
|
|
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
|
|
|
@@ -116,6 +161,7 @@ kb
|
|
|
116
161
|
- JSON:`.json`
|
|
117
162
|
- CSV:`.csv`
|
|
118
163
|
- Excel:`.xlsx`、`.xls`
|
|
164
|
+
- 图片:`.png`、`.jpg`、`.jpeg`、`.gif`、`.webp`、`.bmp`(需要模型支持视觉)
|
|
119
165
|
|
|
120
166
|
导入命令格式:
|
|
121
167
|
|
|
@@ -138,10 +184,32 @@ import <文件路径>
|
|
|
138
184
|
导入 ./docs/payment.md
|
|
139
185
|
import D:\docs\api.txt
|
|
140
186
|
导入 "D:\资料\产品说明.xlsx"
|
|
187
|
+
导入 "D:\截图\支付报错.png"
|
|
141
188
|
```
|
|
142
189
|
|
|
143
190
|
导入后会复制一份原始文件,并建立版本记录。对同一个文件再次导入时,会创建新版本,不会直接覆盖旧版本。
|
|
144
191
|
|
|
192
|
+
### 图片知识
|
|
193
|
+
|
|
194
|
+
图片本身无法参与关键词检索,因此 `kb` 对图片使用双通道处理:
|
|
195
|
+
|
|
196
|
+
1. **入库时生成描述**:导入图片时会调用当前模型看图,生成一段中文描述(含图中所有文字、数字、表格与界面细节),写入知识库参与全文检索。
|
|
197
|
+
2. **提问时附原图**:当问题检索命中图片记录时,`kb` 会把原始图片一并发送给模型,因此模型看到的是真实图像,而不只是描述。
|
|
198
|
+
|
|
199
|
+
也就是说,导入一次图片后,既可以按内容关键词搜到它,也可以直接针对图片细节提问:
|
|
200
|
+
|
|
201
|
+
```text
|
|
202
|
+
导入 "D:\截图\支付报错.png"
|
|
203
|
+
|
|
204
|
+
支付报错的截图里,订单号和错误码分别是什么?
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
注意:
|
|
208
|
+
|
|
209
|
+
- 图片描述依赖当前模型具备视觉能力;模型不支持视觉时,图片仍会入库,但只能按文件名检索,并在导入结果中提示失败原因。
|
|
210
|
+
- 单次提问最多附带 4 张命中图片。
|
|
211
|
+
- 历史对话中不会重复携带图片数据,每轮提问都会按检索结果重新附图。
|
|
212
|
+
|
|
145
213
|
### 查询知识
|
|
146
214
|
|
|
147
215
|
直接输入问题即可:
|
|
@@ -234,6 +302,22 @@ npm test
|
|
|
234
302
|
|
|
235
303
|
## 常见问题
|
|
236
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
|
+
|
|
237
321
|
### 运行 `kb` 找不到命令
|
|
238
322
|
|
|
239
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,180 +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
|
-
import { Agent, fetchTransport } from "./model.js";
|
|
8
|
-
import { copyOriginal, defaultDataRoot, ensureKnowledgeBase,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
5
|
+
import { Agent, describeImage, fetchTransport } from "./model.js";
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
128
|
+
this.editor.setText("");
|
|
129
|
+
this.tui.requestRender();
|
|
130
|
+
if (value.startsWith("/")) {
|
|
131
|
+
await this.command(value);
|
|
59
132
|
return;
|
|
60
133
|
}
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
setLines((old) => [...old, `${currentBase.name} (${currentBase.id})`]);
|
|
235
|
+
case "/help":
|
|
236
|
+
this.showOverlay(new HelpPanel(HELP_ITEMS), 66);
|
|
88
237
|
return;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
if (!
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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 查看可用命令。`));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
showSources() {
|
|
273
|
+
const sources = this.store.sources();
|
|
274
|
+
if (!sources.length) {
|
|
275
|
+
this.add(new Notice(ROLE.warn, color.warn, "知识库还是空的,用「导入 <文件路径>」录入内容。"));
|
|
114
276
|
return;
|
|
115
277
|
}
|
|
116
|
-
|
|
117
|
-
|
|
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, "没有已配置的模型。"));
|
|
118
286
|
return;
|
|
119
287
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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} 的模型。`));
|
|
123
302
|
return;
|
|
124
303
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 ? " ← 当前" : ""}`));
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (sub === "current") {
|
|
320
|
+
this.add(new Notice(ROLE.ok, color.ok, `${this.base.name}(${this.base.id})`));
|
|
128
321
|
return;
|
|
129
322
|
}
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
setLines((old) => [...old, `Deleted old version ${id}.`]);
|
|
323
|
+
if (sub === "create") {
|
|
324
|
+
if (!args.length) {
|
|
325
|
+
this.add(new Notice(ROLE.warn, color.warn, "用法:/kb create <名称>"));
|
|
326
|
+
return;
|
|
135
327
|
}
|
|
136
|
-
|
|
137
|
-
|
|
328
|
+
const created = ensureKnowledgeBase(this.root, args.join(" "));
|
|
329
|
+
this.add(new Notice(ROLE.ok, color.ok, `已创建 ${created.name}(${created.id})`));
|
|
138
330
|
return;
|
|
139
331
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
store.
|
|
147
|
-
|
|
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;
|
|
148
345
|
}
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
deleteKnowledgeBase(this.root, args[0]);
|
|
353
|
+
this.add(new Notice(ROLE.ok, color.ok, `已删除 ${args[0]},备份已保留。`));
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
this.add(new Notice(ROLE.err, color.err, errText(error)));
|
|
357
|
+
}
|
|
358
|
+
return;
|
|
151
359
|
}
|
|
152
|
-
|
|
153
|
-
|
|
360
|
+
this.add(new Notice(ROLE.warn, color.warn, "用法:/kb list | /kb create <名称> | /kb use <id> | /kb current | /kb delete <id> confirm"));
|
|
361
|
+
}
|
|
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;
|
|
154
378
|
}
|
|
379
|
+
this.store.removeVersions([id]);
|
|
380
|
+
this.add(new Notice(ROLE.ok, color.ok, `已删除历史版本 ${id}。`));
|
|
155
381
|
}
|
|
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
|
-
const copied = copyOriginal(source.originalPath, store.root);
|
|
165
|
-
const result = store.addOrUpdateSource(copied, source.title, source.content);
|
|
166
|
-
return { text: `${result.isNew ? "Imported" : "New version created for"} ${result.title} (v${result.version})`, messages: previous };
|
|
167
|
-
}
|
|
168
|
-
const results = store.search(value);
|
|
169
|
-
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.";
|
|
170
|
-
const agent = new Agent(store, modelConfig, fetchTransport);
|
|
171
|
-
return agent.answer(`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}`, previous);
|
|
172
382
|
}
|
|
173
383
|
export async function main() {
|
|
174
384
|
const root = defaultDataRoot();
|
|
175
385
|
mkdirSync(root, { recursive: true });
|
|
176
|
-
const knowledgeRoot = root;
|
|
177
386
|
const base = ensureKnowledgeBase(root, "default");
|
|
178
|
-
const
|
|
179
|
-
|
|
387
|
+
const models = await ensureModelConfig();
|
|
388
|
+
const app = new KbApp(root, base, models);
|
|
389
|
+
app.start();
|
|
180
390
|
}
|
package/dist/model.d.ts
CHANGED
|
@@ -4,15 +4,26 @@ export type ToolCall = {
|
|
|
4
4
|
name: "search" | "read" | "write";
|
|
5
5
|
arguments: Record<string, unknown>;
|
|
6
6
|
};
|
|
7
|
+
export type ContentBlock = {
|
|
8
|
+
type: "text";
|
|
9
|
+
text: string;
|
|
10
|
+
} | {
|
|
11
|
+
type: "image";
|
|
12
|
+
mediaType: string;
|
|
13
|
+
base64: string;
|
|
14
|
+
};
|
|
7
15
|
export type ModelMessage = {
|
|
8
16
|
role: "system" | "user" | "assistant" | "tool";
|
|
9
|
-
content: string;
|
|
17
|
+
content: string | ContentBlock[];
|
|
10
18
|
toolCallId?: string;
|
|
11
19
|
};
|
|
12
20
|
export type ModelReply = {
|
|
13
21
|
text?: string;
|
|
14
22
|
toolCalls?: ToolCall[];
|
|
15
23
|
};
|
|
24
|
+
export declare function toAnthropicContent(content: string | ContentBlock[]): unknown;
|
|
25
|
+
export declare function toOpenAIContent(content: string | ContentBlock[]): unknown;
|
|
26
|
+
export declare function contentToText(content: string | ContentBlock[]): string;
|
|
16
27
|
export type ModelTransport = {
|
|
17
28
|
complete(config: ModelConfig, messages: ModelMessage[]): Promise<ModelReply>;
|
|
18
29
|
};
|
|
@@ -22,10 +33,18 @@ export declare class Agent {
|
|
|
22
33
|
private readonly config;
|
|
23
34
|
private readonly transport;
|
|
24
35
|
constructor(store: KnowledgeStore, config: ModelConfig, transport?: ModelTransport);
|
|
25
|
-
answer(question: string, previous?: ModelMessage[]): Promise<{
|
|
36
|
+
answer(question: string | ContentBlock[], previous?: ModelMessage[]): Promise<{
|
|
26
37
|
text: string;
|
|
27
38
|
messages: ModelMessage[];
|
|
28
39
|
}>;
|
|
29
40
|
private execute;
|
|
30
41
|
}
|
|
31
42
|
export declare function formatResults(results: SearchResult[]): string;
|
|
43
|
+
type VisionTransport = {
|
|
44
|
+
complete: (config: ModelConfig, messages: ModelMessage[]) => Promise<ModelReply>;
|
|
45
|
+
};
|
|
46
|
+
export declare function describeImage(config: ModelConfig, transport: VisionTransport, image: {
|
|
47
|
+
mediaType: string;
|
|
48
|
+
base64: string;
|
|
49
|
+
}): Promise<string>;
|
|
50
|
+
export {};
|
package/dist/model.js
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
export function toAnthropicContent(content) {
|
|
2
|
+
if (typeof content === "string")
|
|
3
|
+
return content;
|
|
4
|
+
return content.map((block) => block.type === "text"
|
|
5
|
+
? { type: "text", text: block.text }
|
|
6
|
+
: { type: "image", source: { type: "base64", media_type: block.mediaType, data: block.base64 } });
|
|
7
|
+
}
|
|
8
|
+
export function toOpenAIContent(content) {
|
|
9
|
+
if (typeof content === "string")
|
|
10
|
+
return content;
|
|
11
|
+
return content.map((block) => block.type === "text"
|
|
12
|
+
? { type: "input_text", text: block.text }
|
|
13
|
+
: { type: "input_image", image_url: `data:${block.mediaType};base64,${block.base64}` });
|
|
14
|
+
}
|
|
15
|
+
export function contentToText(content) {
|
|
16
|
+
return typeof content === "string" ? content : content.map((block) => block.type === "text" ? block.text : `[image:${block.mediaType}]`).join("\n");
|
|
17
|
+
}
|
|
18
|
+
// 历史消息里的图片 base64 不重复携带:每轮都会重新检索并附图,避免上下文膨胀。
|
|
19
|
+
function compactHistory(messages) {
|
|
20
|
+
return messages.map((message) => typeof message.content === "string" ? message : { ...message, content: contentToText(message.content) });
|
|
21
|
+
}
|
|
1
22
|
function endpoint(config) {
|
|
2
23
|
return `${config.baseURL.replace(/\/$/, "")}${config.api === "anthropic-messages" ? "/v1/messages" : "/v1/responses"}`;
|
|
3
24
|
}
|
|
@@ -14,8 +35,8 @@ export const fetchTransport = {
|
|
|
14
35
|
method: "POST",
|
|
15
36
|
headers,
|
|
16
37
|
body: JSON.stringify(config.api === "anthropic-messages"
|
|
17
|
-
? { model: config.model, max_tokens: 4096, messages: messages.filter((m) => m.role !== "system"), system: messages.find((m) => m.role === "system")?.content }
|
|
18
|
-
: { model: config.model, input: messages.map((m) => ({ role: m.role, content: m.content })) }),
|
|
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) })) }),
|
|
19
40
|
});
|
|
20
41
|
if (!response.ok)
|
|
21
42
|
throw new Error(`Model request failed (${response.status}): ${await response.text()}`);
|
|
@@ -38,13 +59,13 @@ export class Agent {
|
|
|
38
59
|
const messages = [...previous, { role: "user", content: question }];
|
|
39
60
|
const first = await this.transport.complete(this.config, messages);
|
|
40
61
|
if (!first.toolCalls?.length)
|
|
41
|
-
return { text: first.text ?? "", messages: [...messages, { role: "assistant", content: first.text ?? "" }] };
|
|
62
|
+
return { text: first.text ?? "", messages: [...compactHistory(messages), { role: "assistant", content: first.text ?? "" }] };
|
|
42
63
|
for (const call of first.toolCalls) {
|
|
43
64
|
const result = this.execute(call);
|
|
44
65
|
messages.push({ role: "tool", content: JSON.stringify(result), toolCallId: call.name });
|
|
45
66
|
}
|
|
46
67
|
const final = await this.transport.complete(this.config, messages);
|
|
47
|
-
return { text: final.text ?? "", messages: [...messages, { role: "assistant", content: final.text ?? "" }] };
|
|
68
|
+
return { text: final.text ?? "", messages: [...compactHistory(messages), { role: "assistant", content: final.text ?? "" }] };
|
|
48
69
|
}
|
|
49
70
|
execute(call) {
|
|
50
71
|
if (call.name === "search")
|
|
@@ -57,3 +78,11 @@ export class Agent {
|
|
|
57
78
|
export function formatResults(results) {
|
|
58
79
|
return results.map((result) => `[${result.id}] ${result.title} (source: ${result.source}, v${result.version})\n${result.content}`).join("\n\n");
|
|
59
80
|
}
|
|
81
|
+
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
|
+
export async function describeImage(config, transport, image) {
|
|
83
|
+
const reply = await transport.complete(config, [{
|
|
84
|
+
role: "user",
|
|
85
|
+
content: [{ type: "text", text: IMAGE_QUESTION }, { type: "image", mediaType: image.mediaType, base64: image.base64 }],
|
|
86
|
+
}]);
|
|
87
|
+
return (reply.text ?? "").trim();
|
|
88
|
+
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -11,7 +11,22 @@ export type SearchResult = {
|
|
|
11
11
|
source: string;
|
|
12
12
|
version: number;
|
|
13
13
|
updatedAt: string;
|
|
14
|
+
originalPath?: string;
|
|
14
15
|
};
|
|
16
|
+
export declare const IMAGE_MEDIA_TYPES: Record<string, string>;
|
|
17
|
+
export type ImageData = {
|
|
18
|
+
mediaType: string;
|
|
19
|
+
base64: string;
|
|
20
|
+
};
|
|
21
|
+
export type LocalFile = {
|
|
22
|
+
title: string;
|
|
23
|
+
content: string;
|
|
24
|
+
originalPath: string;
|
|
25
|
+
hash: string;
|
|
26
|
+
image?: ImageData;
|
|
27
|
+
};
|
|
28
|
+
export declare function isImageFile(filePath: string): boolean;
|
|
29
|
+
export declare function readImage(filePath: string): ImageData;
|
|
15
30
|
export declare function tokenize(text: string): string;
|
|
16
31
|
export declare class KnowledgeStore {
|
|
17
32
|
readonly root: string;
|
|
@@ -37,12 +52,7 @@ type KnowledgeStoreResult = {
|
|
|
37
52
|
title: string;
|
|
38
53
|
isNew: boolean;
|
|
39
54
|
};
|
|
40
|
-
export declare function readLocalFile(filePath: string):
|
|
41
|
-
title: string;
|
|
42
|
-
content: string;
|
|
43
|
-
originalPath: string;
|
|
44
|
-
hash: string;
|
|
45
|
-
};
|
|
55
|
+
export declare function readLocalFile(filePath: string): LocalFile;
|
|
46
56
|
export declare function copyOriginal(filePath: string, root: string): string;
|
|
47
57
|
export declare function defaultDataRoot(): string;
|
|
48
58
|
export declare function knowledgeBasePath(root: string, id: string): string;
|
package/dist/storage.js
CHANGED
|
@@ -3,6 +3,24 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { join, resolve, extname, basename } from "node:path";
|
|
4
4
|
import { DatabaseSync } from "node:sqlite";
|
|
5
5
|
import * as XLSX from "xlsx";
|
|
6
|
+
export const IMAGE_MEDIA_TYPES = {
|
|
7
|
+
".png": "image/png",
|
|
8
|
+
".jpg": "image/jpeg",
|
|
9
|
+
".jpeg": "image/jpeg",
|
|
10
|
+
".gif": "image/gif",
|
|
11
|
+
".webp": "image/webp",
|
|
12
|
+
".bmp": "image/bmp",
|
|
13
|
+
};
|
|
14
|
+
export function isImageFile(filePath) {
|
|
15
|
+
return extname(filePath).toLowerCase() in IMAGE_MEDIA_TYPES;
|
|
16
|
+
}
|
|
17
|
+
export function readImage(filePath) {
|
|
18
|
+
const ext = extname(filePath).toLowerCase();
|
|
19
|
+
const mediaType = IMAGE_MEDIA_TYPES[ext];
|
|
20
|
+
if (!mediaType)
|
|
21
|
+
throw new Error(`Unsupported image format: ${ext || "unknown"}`);
|
|
22
|
+
return { mediaType, base64: readFileSync(filePath).toString("base64") };
|
|
23
|
+
}
|
|
6
24
|
export function tokenize(text) {
|
|
7
25
|
const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
|
|
8
26
|
const words = [...segmenter.segment(text)]
|
|
@@ -48,10 +66,10 @@ export class KnowledgeStore {
|
|
|
48
66
|
const match = tokenize(query).split(/\s+/).filter(Boolean).map((part) => `"${part.replaceAll('"', '""')}"`).join(" OR ");
|
|
49
67
|
if (!match)
|
|
50
68
|
return [];
|
|
51
|
-
return this.db.prepare(`SELECT k.id, k.title, k.content, s.name AS source, k.version, k.updated_at AS updatedAt FROM knowledge_fts f JOIN knowledge k ON k.id = f.knowledge_id JOIN sources s ON s.id = k.source_id WHERE f.knowledge_fts MATCH ? AND k.current = 1 ORDER BY rank LIMIT ?`).all(match, limit);
|
|
69
|
+
return this.db.prepare(`SELECT k.id, k.title, k.content, s.name AS source, k.version, k.updated_at AS updatedAt, s.original_path AS originalPath FROM knowledge_fts f JOIN knowledge k ON k.id = f.knowledge_id JOIN sources s ON s.id = k.source_id WHERE f.knowledge_fts MATCH ? AND k.current = 1 ORDER BY rank LIMIT ?`).all(match, limit);
|
|
52
70
|
}
|
|
53
71
|
read(id) {
|
|
54
|
-
return this.db.prepare("SELECT k.id,k.title,k.content,s.name AS source,k.version,k.updated_at AS updatedAt FROM knowledge k JOIN sources s ON s.id=k.source_id WHERE k.id=?").get(id);
|
|
72
|
+
return this.db.prepare("SELECT k.id,k.title,k.content,s.name AS source,k.version,k.updated_at AS updatedAt,s.original_path AS originalPath FROM knowledge k JOIN sources s ON s.id=k.source_id WHERE k.id=?").get(id);
|
|
55
73
|
}
|
|
56
74
|
sources() { return this.db.prepare("SELECT s.name,s.original_path AS path,MAX(k.updated_at) AS updatedAt,MAX(k.version) AS version FROM sources s JOIN knowledge k ON k.source_id=s.id AND k.current=1 GROUP BY s.id ORDER BY updatedAt DESC").all(); }
|
|
57
75
|
oldVersions() { return this.db.prepare("SELECT k.id,k.title,k.version,s.name AS source FROM knowledge k JOIN sources s ON s.id=k.source_id WHERE k.current=0 ORDER BY s.name,k.version").all(); }
|
|
@@ -66,6 +84,17 @@ export function readLocalFile(filePath) {
|
|
|
66
84
|
const originalPath = resolve(filePath);
|
|
67
85
|
const ext = extname(originalPath).toLowerCase();
|
|
68
86
|
let content;
|
|
87
|
+
if (IMAGE_MEDIA_TYPES[ext]) {
|
|
88
|
+
const image = readImage(originalPath);
|
|
89
|
+
const size = statSync(originalPath).size;
|
|
90
|
+
return {
|
|
91
|
+
title: basename(originalPath),
|
|
92
|
+
content: `[\u56fe\u7247] ${basename(originalPath)} (${image.mediaType}, ${size} bytes)`,
|
|
93
|
+
originalPath,
|
|
94
|
+
hash: createHash("sha256").update(image.base64).digest("hex"),
|
|
95
|
+
image,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
69
98
|
if ([".md", ".txt", ".json", ".csv"].includes(ext))
|
|
70
99
|
content = readFileSync(originalPath, "utf8");
|
|
71
100
|
else if (ext === ".xlsx" || ext === ".xls") {
|
package/dist/theme.d.ts
ADDED
|
@@ -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.
|
|
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.
|
|
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
|
}
|