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