@ganziliang/kb 0.1.6 → 0.1.7
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 +24 -1
- package/dist/cli.js +30 -6
- 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/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
|
## 环境要求
|
|
6
6
|
|
|
@@ -116,6 +116,7 @@ kb
|
|
|
116
116
|
- JSON:`.json`
|
|
117
117
|
- CSV:`.csv`
|
|
118
118
|
- Excel:`.xlsx`、`.xls`
|
|
119
|
+
- 图片:`.png`、`.jpg`、`.jpeg`、`.gif`、`.webp`、`.bmp`(需要模型支持视觉)
|
|
119
120
|
|
|
120
121
|
导入命令格式:
|
|
121
122
|
|
|
@@ -138,10 +139,32 @@ import <文件路径>
|
|
|
138
139
|
导入 ./docs/payment.md
|
|
139
140
|
import D:\docs\api.txt
|
|
140
141
|
导入 "D:\资料\产品说明.xlsx"
|
|
142
|
+
导入 "D:\截图\支付报错.png"
|
|
141
143
|
```
|
|
142
144
|
|
|
143
145
|
导入后会复制一份原始文件,并建立版本记录。对同一个文件再次导入时,会创建新版本,不会直接覆盖旧版本。
|
|
144
146
|
|
|
147
|
+
### 图片知识
|
|
148
|
+
|
|
149
|
+
图片本身无法参与关键词检索,因此 `kb` 对图片使用双通道处理:
|
|
150
|
+
|
|
151
|
+
1. **入库时生成描述**:导入图片时会调用当前模型看图,生成一段中文描述(含图中所有文字、数字、表格与界面细节),写入知识库参与全文检索。
|
|
152
|
+
2. **提问时附原图**:当问题检索命中图片记录时,`kb` 会把原始图片一并发送给模型,因此模型看到的是真实图像,而不只是描述。
|
|
153
|
+
|
|
154
|
+
也就是说,导入一次图片后,既可以按内容关键词搜到它,也可以直接针对图片细节提问:
|
|
155
|
+
|
|
156
|
+
```text
|
|
157
|
+
导入 "D:\截图\支付报错.png"
|
|
158
|
+
|
|
159
|
+
支付报错的截图里,订单号和错误码分别是什么?
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
注意:
|
|
163
|
+
|
|
164
|
+
- 图片描述依赖当前模型具备视觉能力;模型不支持视觉时,图片仍会入库,但只能按文件名检索,并在导入结果中提示失败原因。
|
|
165
|
+
- 单次提问最多附带 4 张命中图片。
|
|
166
|
+
- 历史对话中不会重复携带图片数据,每轮提问都会按检索结果重新附图。
|
|
167
|
+
|
|
145
168
|
### 查询知识
|
|
146
169
|
|
|
147
170
|
直接输入问题即可:
|
package/dist/cli.js
CHANGED
|
@@ -4,8 +4,8 @@ import { useEffect, useRef, useState } from "react";
|
|
|
4
4
|
import { Box, Text, render, useApp, useInput } from "ink";
|
|
5
5
|
import { mkdirSync } from "node:fs";
|
|
6
6
|
import { ensureModelConfig, saveModels } from "./config.js";
|
|
7
|
-
import { Agent, fetchTransport } from "./model.js";
|
|
8
|
-
import { copyOriginal, defaultDataRoot, ensureKnowledgeBase, KnowledgeStore, readLocalFile, listKnowledgeBases, deleteKnowledgeBase, backupKnowledgeBase, restoreKnowledgeBase } from "./storage.js";
|
|
7
|
+
import { Agent, describeImage, fetchTransport } from "./model.js";
|
|
8
|
+
import { copyOriginal, defaultDataRoot, ensureKnowledgeBase, isImageFile, KnowledgeStore, readImage, readLocalFile, listKnowledgeBases, deleteKnowledgeBase, backupKnowledgeBase, restoreKnowledgeBase } from "./storage.js";
|
|
9
9
|
const banner = [
|
|
10
10
|
" _ __ ____ ",
|
|
11
11
|
" | |/ // __ ) ",
|
|
@@ -55,7 +55,7 @@ function App({ config, root, knowledgeBase }) {
|
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
57
|
if (value === "/help") {
|
|
58
|
-
setLines((old) => [...old, "/help /clear /sources /backup /restore /cleanup /model /models /kb /quit"]);
|
|
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", "图片入库时自动生成中文描述用于检索,提问命中后会把原图一并发给模型"]);
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
61
|
if (value === "/sources") {
|
|
@@ -161,14 +161,38 @@ async function ingestOrAsk(value, store, modelConfig, previous) {
|
|
|
161
161
|
const source = readLocalFile(pathMatch[1].trim().replace(/^['"]|['"]$/g, ""));
|
|
162
162
|
if (!source.hash)
|
|
163
163
|
return { text: `${source.title}: unable to parse (${source.content})`, messages: previous };
|
|
164
|
+
let content = source.content;
|
|
165
|
+
let note = "";
|
|
166
|
+
if (source.image) {
|
|
167
|
+
try {
|
|
168
|
+
const caption = await describeImage(modelConfig, fetchTransport, source.image);
|
|
169
|
+
if (caption) {
|
|
170
|
+
content = `${source.content}\n\n${caption}`;
|
|
171
|
+
note = ",已生成图片描述";
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
note = `,图片描述生成失败(仅按文件名检索): ${error instanceof Error ? error.message : String(error)}`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
164
178
|
const copied = copyOriginal(source.originalPath, store.root);
|
|
165
|
-
const result = store.addOrUpdateSource(copied, source.title,
|
|
166
|
-
return { text: `${result.isNew ? "Imported" : "New version created for"} ${result.title} (v${result.version})`, messages: previous };
|
|
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 };
|
|
167
181
|
}
|
|
168
182
|
const results = store.search(value);
|
|
169
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 });
|
|
191
|
+
}
|
|
192
|
+
catch { /* 忽略无法读取的图片 */ }
|
|
193
|
+
}
|
|
170
194
|
const agent = new Agent(store, modelConfig, fetchTransport);
|
|
171
|
-
return agent.answer(
|
|
195
|
+
return agent.answer(blocks.length > 1 ? blocks : prompt, previous);
|
|
172
196
|
}
|
|
173
197
|
export async function main() {
|
|
174
198
|
const root = defaultDataRoot();
|
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") {
|