@ganziliang/kb 0.1.5 → 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 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
 
@@ -22,20 +22,18 @@ npm install -g @ganziliang/kb
22
22
  kb
23
23
  ```
24
24
 
25
- ### 在源码目录运行
25
+ `@ganziliang/kb` 已经声明依赖 `@ganziliang/kb-model-setup`。通过 npm 安装 `@ganziliang/kb` 时,npm 会自动安装该依赖,正常情况下不需要单独安装。
26
+
27
+ 如果你需要单独安装或更新模型配置模块,可以执行:
26
28
 
27
29
  ```bash
28
- cd D:\IDEAProject\ai_work\zhishiku\kb
29
- npm install
30
- npm run build
31
- node dist/entry.js
30
+ npm install -g @ganziliang/kb-model-setup
32
31
  ```
33
32
 
34
- 如果希望全局的 `kb` 命令使用当前源码构建结果,可以在源码目录执行:
33
+ 注意:`kb-model-setup` 是模型配置模块,不是启动 `kb` 的主命令。日常使用只需要运行:
35
34
 
36
35
  ```bash
37
- npm link
38
- npm run build
36
+ kb
39
37
  ```
40
38
 
41
39
  ## 首次启动和模型配置
@@ -51,6 +49,8 @@ kb
51
49
  1. `api-stats` 页面地址或 `apiId`
52
50
  2. API Key
53
51
 
52
+ `kb` 使用依赖包 `@ganziliang/kb-model-setup` 完成模型配置。该模块会查询智真 LLM Gateway 中当前账号可用的模型,并保存所选模型配置。
53
+
54
54
  当前默认使用智真 LLM Gateway:
55
55
 
56
56
  ```text
@@ -75,6 +75,7 @@ Windows 通常对应:
75
75
  C:\Users\<用户名>\.config\kb\config.json
76
76
  ```
77
77
 
78
+
78
79
  也可以通过环境变量跳过首次交互配置:
79
80
 
80
81
  ```bash
@@ -115,6 +116,7 @@ kb
115
116
  - JSON:`.json`
116
117
  - CSV:`.csv`
117
118
  - Excel:`.xlsx`、`.xls`
119
+ - 图片:`.png`、`.jpg`、`.jpeg`、`.gif`、`.webp`、`.bmp`(需要模型支持视觉)
118
120
 
119
121
  导入命令格式:
120
122
 
@@ -137,10 +139,32 @@ import <文件路径>
137
139
  导入 ./docs/payment.md
138
140
  import D:\docs\api.txt
139
141
  导入 "D:\资料\产品说明.xlsx"
142
+ 导入 "D:\截图\支付报错.png"
140
143
  ```
141
144
 
142
145
  导入后会复制一份原始文件,并建立版本记录。对同一个文件再次导入时,会创建新版本,不会直接覆盖旧版本。
143
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
+
144
168
  ### 查询知识
145
169
 
146
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, source.content);
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(`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);
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") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganziliang/kb",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Local knowledge base agent CLI",
5
5
  "type": "module",
6
6
  "bin": {