@puwenhui/dsh-rag-kb 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,101 @@
1
- # Temporary Holding Version
1
+ # @puwenhui/dsh-rag-kb
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DSH RAG 知识库插件:**文档导入 → 智能切块 → 本地向量化 → 语义检索**,让 DSH 对话拥有私域知识。
4
+
5
+ > 面向公司非技术人员的使用帮助请阅读 **[USAGE.zh.md](./USAGE.zh.md)**。
6
+
7
+ ## 核心特性
8
+
9
+ - **语义检索**:自然语言提问,agent 自动从知识库找答案(中文/英文均可)
10
+ - **多格式支持**:pdf / docx / md / txt / csv / json
11
+ - **完全本地**:向量化模型本地运行(bge-small-zh ONNX),数据不出机器
12
+ - **会话语义管理**:所有操作(添加/删除/保存/检索)直接在对话里用大白话完成
13
+ - **目录自动索引**:配置一个目录,文件放入/变更自动入库
14
+ - **对话内容保存**:把对话中的结论/总结一键存入知识库
15
+
16
+ ## 版本要求
17
+
18
+ - **DSH ≥ 0.2.0**(依赖 settings 标签页体系与 volatile 字段机制)
19
+
20
+ ## 安装
21
+
22
+ ```sh
23
+ dsh plugin --profile web add @puwenhui/dsh-rag-kb
24
+ ```
25
+
26
+ (源码宿主用 `pnpm dsh plugin --profile web add @puwenhui/dsh-rag-kb`)
27
+
28
+ ## 配置
29
+
30
+ 安装后打开 `dsh web`,**设置 → 插件 →「知识库」**标签页:
31
+
32
+ | 字段 | 说明 |
33
+ |---|---|
34
+ | 知识库描述 | agent 检索前阅读,知道库里有什么 |
35
+ | 自动索引目录 | 放入文档自动入库(可选) |
36
+ | 检索返回条数 | 默认 5 |
37
+ | 相似度阈值 | 0~1,默认 0.3 |
38
+
39
+ **首次使用**会自动下载中文向量化模型(约 100MB,下载后完全离线,模型缓存在 `$DSH_HOME/rag-kb/hf-cache/`)。
40
+
41
+ ## 会话语义操作
42
+
43
+ 所有知识库操作直接在对话里说,agent 自动调用对应工具:
44
+
45
+ | 你说 | agent 调用 |
46
+ |---|---|
47
+ | 「把 D:\docs 加进知识库」 | `knowledge_manage add` |
48
+ | 「把刚才的总结存到知识库」 | `knowledge_manage save` |
49
+ | 「知识库里有什么?」 | `knowledge_status` |
50
+ | 「删掉旧版手册」 | `knowledge_manage remove` |
51
+ | 「根据知识库回答:XXX」 | `knowledge_search` |
52
+
53
+ ## 模型工具(3 个)
54
+
55
+ | 工具 | 说明 |
56
+ |---|---|
57
+ | `knowledge_search` | 语义检索:query + top_k → 带来源/相似度/原文的结果 |
58
+ | `knowledge_status` | 知识库状态:文档列表/配置/描述 |
59
+ | `knowledge_manage` | 管理操作:save / add / remove / reindex / describe |
60
+
61
+ ## 技术架构
62
+
63
+ ```
64
+ 文档 → unpdf/mammoth 提取纯文本
65
+ → 标题感知切块(400 token + 10% 重叠)
66
+ → transformers.js 向量化(bge-small-zh-v1.5, 512 维, GPU 自动检测/CPU fallback)
67
+ → node:sqlite 存储(chunk + 向量 BLOB)
68
+ → 检索时内存余弦相似 top-K(万级 chunk 毫秒级)
69
+ ```
70
+
71
+ - **向量库**:`node:sqlite`(Node 22.5+ 内置,零 npm 依赖,万级 chunk 内存检索 <50ms)
72
+ - **Embedding**:`@huggingface/transformers` + `Xenova/bge-small-zh-v1.5`(纯 JS ONNX,本地 CPU 3.6ms/chunk)
73
+ - **PDF 解析**:`unpdf`(纯 JS)| **DOCX**:`mammoth`(纯 JS)
74
+ - **数据目录**:`$DSH_HOME/rag-kb/`(index.db + hf-cache/)
75
+
76
+ ## 已知限制
77
+
78
+ - 单库设计(不分知识库),适合 <1000 篇文档
79
+ - 512 维向量(bge-small-zh),如需多语言可换 bge-m3(1024 维)
80
+ - 附件出站(图片/文件回传 QQ)走路径文本,暂不支持直接推送
81
+
82
+ ## 目录结构
83
+
84
+ ```
85
+ src/ # 宿主半边(工具 + 索引 + 检索 + systemPrompt 注入 + SSE + 目录监视)
86
+ client-src/ # 浏览器半边(设置标签页)
87
+ lib/plugin.mjs # 宿主半边构建产物
88
+ lib/client.js # 浏览器半边构建产物
89
+ cordis.patch.yml
90
+ ```
91
+
92
+ ## 开发
93
+
94
+ - 宿主半边:`npx tsdown`(产出 lib/plugin.mjs)
95
+ - 浏览器半边:`cd client-src && npm install && npm run build`
96
+ - 部署到本地 profile:构建后复制 `lib/plugin.mjs` 和 `lib/client.js` 到 profile 的 node_modules 目录
97
+ - 发布:bump version → `npm publish`
98
+
99
+ ## License
100
+
101
+ MIT
package/USAGE.zh.md ADDED
@@ -0,0 +1,107 @@
1
+ # DSH 知识库 · 使用帮助
2
+
3
+ DSH 知识库让你**在对话里拥有自己的私域知识**——把公司文档、技术手册、会议纪要放进去,直接用大白话提问,AI 自动从知识库里找答案。不需要任何技术操作。
4
+
5
+ ---
6
+
7
+ ## 首次使用:两步配置(约 1 分钟 + 模型下载等待)
8
+
9
+ 管理员执行 `dsh plugin --profile web add @puwenhui/dsh-rag-kb` 安装后:
10
+
11
+ ### 第一步:填写知识库描述(所有人必做)
12
+
13
+ 1. 打开 DSH 网页(`dsh web` 启动后浏览器打开的那个地址)
14
+ 2. 点左侧栏底部的 **⚙ 设置** → 在左侧列表点 **插件**
15
+ 3. 在标签页行点 **「知识库」**
16
+ 4. 在「知识库描述」里写一段话,说明知识库里放的是什么类型的内容。例如:
17
+
18
+ > 公司产品手册、技术规范、常见问题解答
19
+
20
+ 这段话 AI 每次检索前都会读到——写得越清楚,AI 越知道什么时候该查知识库。
21
+
22
+ 5. 点 **保存**
23
+
24
+ > **首次保存后**会自动下载中文向量化模型(约 100MB),下载完成后**完全离线运行**,后续不再需要网络。
25
+
26
+ ### 第二步(可选):设置自动索引目录
27
+
28
+ 如果你有一个固定的文档目录(如 `D:\公司文档`),可以填在「自动索引目录」里——之后往目录里丢文件就自动入库,不用手动添加。
29
+
30
+ ---
31
+
32
+ ## 日常使用:直接在对话里说
33
+
34
+ 所有知识库操作**不需要打开设置页面**,直接在对话里用大白话说就行:
35
+
36
+ ### 📥 添加文档
37
+
38
+ > 💬 把 D:\公司文档 目录加进知识库
39
+ > 💬 帮我把这份 PDF 加入知识库:D:\docs\产品手册.pdf
40
+
41
+ ### 💾 保存对话内容
42
+
43
+ 对话中产出了有价值的结论、总结、流程说明?直接说:
44
+
45
+ > 💬 把刚才的报销流程总结存到知识库
46
+ > 💬 这段分析很好,保存到知识库里
47
+
48
+ AI 会自动提取内容、起标题、入库。
49
+
50
+ ### 🔍 检索知识
51
+
52
+ > 💬 根据知识库回答:公司的报销流程是什么?
53
+ > 💬 知识库里有没有关于 XX 系统部署的说明?
54
+ > 💬 查一下产品手册里关于安全设置的部分
55
+
56
+ ### 📋 查看知识库状态
57
+
58
+ > 💬 知识库里有哪些文档?
59
+ > 💬 知识库里现在有多少个知识块?
60
+
61
+ ### 🗑️ 删除文档
62
+
63
+ > 💬 删掉知识库里的旧版手册
64
+ > 💬 把那个「对话保存」的文档从知识库移除
65
+
66
+ ---
67
+
68
+ ## 知识库支持哪些文件?
69
+
70
+ | 格式 | 说明 |
71
+ |---|---|
72
+ | PDF | 扫描件暂不支持(需文字版 PDF) |
73
+ | Word (.docx) | 自动提取正文 |
74
+ | Markdown (.md) | 保留标题结构 |
75
+ | 纯文本 (.txt) | 自动分段 |
76
+ | CSV / JSON | 结构化数据 |
77
+
78
+ **不支持的**:图片、音视频、加密文档、扫描版 PDF(无文字层)。
79
+
80
+ ---
81
+
82
+ ## 常见问题
83
+
84
+ **Q:刚添加的文档搜不到?**
85
+ 添加后需要几秒钟建索引(切块+向量化)。如果文档很大(如 200 页 PDF),可能需要 10-30 秒。等一下再搜。
86
+
87
+ **Q:模型下载很慢或失败了?**
88
+ 首次使用需要从 HuggingFace 下载约 100MB 模型。如果网络不通,联系管理员获取离线模型包。
89
+
90
+ **Q:检索结果不准确?**
91
+ 尝试以下方法:
92
+ - 把「相似度阈值」调低(如 0.2)——能搜到更多结果
93
+ - 把「相似度阈值」调高(如 0.5)——结果更精准但可能漏掉
94
+ - 在「知识库描述」里写得更具体,帮助 AI 判断什么时候该检索
95
+
96
+ **Q:能同时存多少文档?**
97
+ 建议 1000 篇以内。超过后检索仍可用,但不同主题的文档可能互相干扰。
98
+
99
+ **Q:数据存在哪里?安全吗?**
100
+ 所有数据(文档内容 + 向量索引)存储在你本机的 `$DSH_HOME/rag-kb/` 目录。向量化模型也运行在本机,**文档内容不会发送到任何外部服务器**。
101
+
102
+ **Q:换电脑怎么办?**
103
+ 把 `$DSH_HOME/rag-kb/` 目录整个复制到新电脑的相同位置即可(包含索引库和模型缓存)。
104
+
105
+ ---
106
+
107
+ *文档随 `@puwenhui/dsh-rag-kb` 插件提供 · 有问题找管理员*
@@ -0,0 +1,4 @@
1
+ # dsh bundle patch: inserts this plugin into a profile's layer stack.
2
+ - insert:
3
+ - id: rag-kb
4
+ name: '@puwenhui/dsh-rag-kb'
package/lib/client.js ADDED
@@ -0,0 +1,387 @@
1
+ window.__ModuleLoader__.load({ id: "@puwenhui/dsh-rag-kb", factory: (require) => {
2
+
3
+
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ let react_jsx_runtime = require("react/jsx-runtime");
9
+ //#region settings-tab.tsx
10
+ /**
11
+ * 设置 → 插件 →「知识库」标签页:配置项 + 使用指引。
12
+ * 复杂操作按设计走会话语义(对话里说「把 D:\docs 加入知识库」即可),
13
+ * 面板只承载:检索参数、知识库描述、监视目录 + 操作提示。
14
+ */
15
+ const FIELDS = [
16
+ {
17
+ key: "description",
18
+ label: "知识库描述",
19
+ type: "string",
20
+ placeholder: "如:公司产品手册、技术规范、常见问题解答",
21
+ hint: "agent 检索前会阅读这段描述,知道库里有什么"
22
+ },
23
+ {
24
+ key: "watchDir",
25
+ label: "自动索引目录",
26
+ type: "string",
27
+ placeholder: "D:\\docs",
28
+ hint: "放入此目录的文档自动监视索引(可选)"
29
+ },
30
+ {
31
+ key: "topK",
32
+ label: "检索返回条数",
33
+ type: "number",
34
+ hint: "默认 5"
35
+ },
36
+ {
37
+ key: "minScore",
38
+ label: "相似度阈值",
39
+ type: "number",
40
+ hint: "0~1,默认 0.3,过低噪音多"
41
+ }
42
+ ];
43
+ function KbSettingsTab(props) {
44
+ const face = props.face;
45
+ const [ns, setNs] = (0, react.useState)(null);
46
+ const [view, setView] = (0, react.useState)(null);
47
+ const [error, setError] = (0, react.useState)(null);
48
+ const [draft, setDraft] = (0, react.useState)(null);
49
+ const [saving, setSaving] = (0, react.useState)(false);
50
+ const [message, setMessage] = (0, react.useState)(null);
51
+ const [failed, setFailed] = (0, react.useState)(false);
52
+ const reload = (0, react.useCallback)(async () => {
53
+ setError(null);
54
+ if (face === void 0 || typeof face.describe !== "function") {
55
+ setError("组件未收到设置读写面");
56
+ return;
57
+ }
58
+ const timeout = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("settings.describe 8 秒未响应")), 8e3));
59
+ let result;
60
+ try {
61
+ result = await Promise.race([face.describe(), timeout]);
62
+ } catch (e) {
63
+ setError(`读取设置失败:${String(e.message)}`);
64
+ return;
65
+ }
66
+ if ("error" in result) {
67
+ setError(result.error);
68
+ return;
69
+ }
70
+ const rows = result.namespaces ?? [];
71
+ const hit = rows.find((row) => row.ns === "rag-kb");
72
+ if (hit === void 0) {
73
+ setError(`设置视图中未找到 rag-kb 命名空间(共 ${String(rows.length)} 个)`);
74
+ return;
75
+ }
76
+ setNs(hit.ns);
77
+ setView(hit);
78
+ setDraft(null);
79
+ }, [face]);
80
+ (0, react.useEffect)(() => {
81
+ reload();
82
+ }, [reload]);
83
+ const current = view?.value ?? {};
84
+ const userLayer = view?.user ?? {};
85
+ const shown = draft ?? current;
86
+ const edit = (key, value) => {
87
+ setMessage(null);
88
+ setFailed(false);
89
+ setDraft((prev) => ({
90
+ ...prev ?? { ...current },
91
+ [key]: value
92
+ }));
93
+ };
94
+ const save = async () => {
95
+ if (draft === null || ns === null || view === void 0) return;
96
+ setSaving(true);
97
+ try {
98
+ const ops = [];
99
+ for (const f of FIELDS) {
100
+ const after = draft[f.key];
101
+ if (after === void 0 || after === "") continue;
102
+ const value = f.type === "number" ? Number(after) : after;
103
+ if (Number.isNaN(value)) continue;
104
+ if (JSON.stringify(value) === JSON.stringify(current[f.key])) continue;
105
+ ops.push({
106
+ op: "set",
107
+ path: [f.key],
108
+ value
109
+ });
110
+ }
111
+ if (ops.length > 0) {
112
+ const result = await face.mutate(ns, ops, view.revision);
113
+ if (!result.ok) throw new Error(result.error ?? "写入失败");
114
+ }
115
+ setDraft(null);
116
+ setFailed(false);
117
+ setMessage(ops.length > 0 ? "已保存,插件已按新配置自动重载" : "无更改");
118
+ await reload();
119
+ } catch (e) {
120
+ setFailed(true);
121
+ setMessage(`保存失败:${String(e.message)}`);
122
+ } finally {
123
+ setSaving(false);
124
+ }
125
+ };
126
+ const inputStyle = {
127
+ width: "100%",
128
+ padding: "6px 10px",
129
+ font: "inherit",
130
+ boxSizing: "border-box",
131
+ border: "1px solid var(--dsw-alias-border-l2)",
132
+ borderRadius: "6px",
133
+ background: "var(--dsw-alias-bg-layer-2)",
134
+ color: "var(--dsw-alias-label-primary)"
135
+ };
136
+ if (error !== null) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
137
+ style: {
138
+ ...cardStyle,
139
+ color: "var(--dsw-alias-label-tertiary)"
140
+ },
141
+ children: [
142
+ "知识库配置加载失败:",
143
+ error,
144
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
145
+ type: "button",
146
+ onClick: () => {
147
+ reload();
148
+ },
149
+ style: {
150
+ ...btnStyle,
151
+ marginLeft: "10px"
152
+ },
153
+ children: "重试"
154
+ })
155
+ ]
156
+ });
157
+ if (view === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
158
+ style: {
159
+ ...cardStyle,
160
+ color: "var(--dsw-alias-label-tertiary)"
161
+ },
162
+ children: "知识库配置加载中…"
163
+ });
164
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
165
+ style: cardStyle,
166
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
167
+ style: { fontSize: "14px" },
168
+ children: "知识库"
169
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
170
+ style: {
171
+ display: "grid",
172
+ gridTemplateColumns: "1fr",
173
+ gap: "10px",
174
+ marginTop: "12px"
175
+ },
176
+ children: [FIELDS.map((f) => {
177
+ const overridden = Object.hasOwn(userLayer, f.key);
178
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
179
+ style: {
180
+ display: "flex",
181
+ flexDirection: "column",
182
+ gap: "4px",
183
+ fontSize: "13px"
184
+ },
185
+ children: [
186
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
187
+ style: { color: "var(--dsw-alias-label-secondary)" },
188
+ children: [f.label, overridden && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
189
+ style: {
190
+ marginLeft: "6px",
191
+ color: "var(--dsw-alias-brand-primary)"
192
+ },
193
+ children: "·已覆盖"
194
+ })]
195
+ }),
196
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
197
+ value: draft !== null && draft[f.key] !== void 0 ? String(draft[f.key]) : String(shown[f.key] ?? ""),
198
+ placeholder: f.placeholder ?? "",
199
+ onChange: (e) => {
200
+ edit(f.key, e.target.value);
201
+ },
202
+ style: inputStyle
203
+ }),
204
+ f.hint !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
205
+ style: {
206
+ fontSize: "11px",
207
+ color: "var(--dsw-alias-label-tertiary)"
208
+ },
209
+ children: f.hint
210
+ })
211
+ ]
212
+ }, f.key);
213
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
214
+ style: {
215
+ display: "flex",
216
+ alignItems: "center",
217
+ gap: "10px",
218
+ marginTop: "4px"
219
+ },
220
+ children: [
221
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
222
+ type: "button",
223
+ disabled: saving || draft === null,
224
+ onClick: () => {
225
+ save();
226
+ },
227
+ style: {
228
+ padding: "6px 16px",
229
+ borderRadius: "6px",
230
+ font: "inherit",
231
+ border: "1px solid var(--dsw-alias-brand-primary)",
232
+ background: "var(--dsw-alias-brand-primary)",
233
+ color: "var(--dsw-alias-label-primary-foreground)",
234
+ cursor: draft === null ? "default" : "pointer",
235
+ opacity: draft === null ? .6 : 1
236
+ },
237
+ children: saving ? "保存中…" : "保存"
238
+ }),
239
+ draft !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
240
+ type: "button",
241
+ disabled: saving,
242
+ onClick: () => {
243
+ setDraft(null);
244
+ setMessage(null);
245
+ setFailed(false);
246
+ },
247
+ style: {
248
+ padding: "6px 16px",
249
+ border: "1px solid var(--dsw-alias-border-l2)",
250
+ borderRadius: "6px",
251
+ background: "transparent",
252
+ font: "inherit",
253
+ color: "var(--dsw-alias-label-secondary)",
254
+ cursor: "pointer"
255
+ },
256
+ children: "放弃更改"
257
+ }),
258
+ message !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
259
+ style: {
260
+ fontSize: "12px",
261
+ color: failed ? "var(--dsw-alias-label-error)" : "var(--dsw-alias-label-secondary)"
262
+ },
263
+ children: message
264
+ })
265
+ ]
266
+ })]
267
+ })]
268
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
269
+ style: {
270
+ ...cardStyle,
271
+ marginTop: "12px",
272
+ color: "var(--dsw-alias-label-secondary)"
273
+ },
274
+ children: [
275
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
276
+ style: { fontSize: "13px" },
277
+ children: "💡 知识库管理:直接在对话里说"
278
+ }),
279
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
280
+ style: {
281
+ marginTop: "8px",
282
+ lineHeight: 1.8,
283
+ fontSize: "13px"
284
+ },
285
+ children: [
286
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
287
+ "💬 「把 ",
288
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
289
+ style: {
290
+ background: "var(--dsw-alias-bg-layer-2)",
291
+ padding: "1px 4px",
292
+ borderRadius: "3px"
293
+ },
294
+ children: "D:\\docs"
295
+ }),
296
+ " 目录加入知识库」"
297
+ ] }),
298
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: "💬 「知识库里有哪些文档?」" }),
299
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: "💬 「删掉知识库里的旧版手册」" }),
300
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: "💬 「根据知识库回答:报销流程是什么?」" })
301
+ ]
302
+ }),
303
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
304
+ style: {
305
+ marginTop: "8px",
306
+ fontSize: "11px",
307
+ color: "var(--dsw-alias-label-tertiary)"
308
+ },
309
+ children: "支持 pdf / docx / md / txt / csv / json;文件内容变化自动重新索引。 首次使用会自动下载中文向量化模型(约 100MB,下载后完全离线)。"
310
+ })
311
+ ]
312
+ })] });
313
+ }
314
+ const cardStyle = {
315
+ border: "1px solid var(--dsw-alias-border-l2)",
316
+ borderRadius: "10px",
317
+ padding: "14px 16px",
318
+ background: "var(--dsw-alias-bg-layer-3)",
319
+ font: "13px/1.5 system-ui, \"Segoe UI\", \"Microsoft YaHei\", sans-serif",
320
+ color: "var(--dsw-alias-label-primary)"
321
+ };
322
+ const btnStyle = {
323
+ padding: "6px 12px",
324
+ border: "1px solid var(--dsw-alias-border-l2)",
325
+ borderRadius: "6px",
326
+ background: "transparent",
327
+ color: "inherit",
328
+ font: "inherit",
329
+ cursor: "pointer",
330
+ whiteSpace: "nowrap"
331
+ };
332
+ //#endregion
333
+ //#region index.tsx
334
+ const name = "dsh-rag-kb";
335
+ const inject = [
336
+ "slots",
337
+ "locale",
338
+ "remote",
339
+ "remote.settings"
340
+ ];
341
+ const NS = "dsh-rag-kb";
342
+ const zh = { tab: "知识库" };
343
+ const en = { tab: "Knowledge" };
344
+ function apply(ctx) {
345
+ ctx.effect(() => {
346
+ try {
347
+ ctx.locale.register(NS, {
348
+ zh,
349
+ en
350
+ });
351
+ } catch {}
352
+ const t = (key) => zh[key] ?? String(key);
353
+ const face = {
354
+ describe: async () => {
355
+ const response = await ctx.remote.settings.describe();
356
+ return response.ok && response.value !== void 0 ? { namespaces: response.value.namespaces } : { error: response.error?.message ?? "settings.describe failed" };
357
+ },
358
+ mutate: async (ns, ops, revision) => {
359
+ const response = await ctx.remote.settings.mutate(ns, ops, revision);
360
+ return response.ok ? { ok: true } : {
361
+ ok: false,
362
+ error: response.error?.message ?? "settings.mutate failed"
363
+ };
364
+ }
365
+ };
366
+ const offTab = ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({
367
+ name: "settings.plugins.tab",
368
+ id: "rag-kb",
369
+ order: 50,
370
+ label: () => t("tab"),
371
+ locale: NS,
372
+ inject: () => ({ face })
373
+ }, KbSettingsTab));
374
+ return () => {
375
+ offTab();
376
+ };
377
+ }, "dsh-rag-kb: settings tab");
378
+ }
379
+ //#endregion
380
+ exports.apply = apply;
381
+ exports.inject = inject;
382
+ exports.name = name;
383
+ return module.exports;
384
+ }
385
+ });
386
+
387
+ //# sourceMappingURL=client.js.map
package/lib/plugin.mjs ADDED
@@ -0,0 +1,657 @@
1
+ import { createRequire } from "node:module";
2
+ import { existsSync, mkdirSync, statSync, watch } from "node:fs";
3
+ import { basename, extname, join, resolve } from "node:path";
4
+ import z from "@deepseek-ai/schemastery";
5
+ import { createHash } from "node:crypto";
6
+ import { readFile } from "node:fs/promises";
7
+ //#region \0rolldown/runtime.js
8
+ var __defProp = Object.defineProperty;
9
+ var __exportAll = (all, no_symbols) => {
10
+ let target = {};
11
+ for (var name in all) __defProp(target, name, {
12
+ get: all[name],
13
+ enumerable: true
14
+ });
15
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
16
+ return target;
17
+ };
18
+ var __require = /* #__PURE__ */ (() => createRequire(import.meta.url))();
19
+ //#endregion
20
+ //#region src/config.ts
21
+ /**
22
+ * RAG 知识库插件配置:schemastery schema,全字段 volatile(0.2.0 设置体系要求)。
23
+ * 由 build 脚本编译打包为 lib/plugin.mjs。
24
+ */
25
+ /** 用户可见配置(设置面板「知识库」标签页) */
26
+ const PublicConfig = z.object({
27
+ watchDir: z.string().default("").description("本地文档目录(放入即自动索引)"),
28
+ topK: z.number().default(5).description("检索返回条数(默认 5)"),
29
+ minScore: z.number().default(.3).description("相似度阈值(0~1,默认 0.3)"),
30
+ description: z.string().default("").description("知识库描述(agent 检索前会阅读)")
31
+ });
32
+ /** 完整配置(含高级项) */
33
+ const Config = z.object({
34
+ watchDir: z.string().default("").description("本地文档目录"),
35
+ topK: z.number().default(5).description("检索返回条数"),
36
+ minScore: z.number().default(.3).description("相似度阈值"),
37
+ description: z.string().default("").description("知识库描述"),
38
+ embeddingModel: z.string().default("Xenova/bge-small-zh-v1.5").description("embedding 模型"),
39
+ batchSize: z.number().default(32).description("批量推理大小")
40
+ });
41
+ /** 全字段 volatile 注入(npm 版 schemastery 无 .volatile(),0.2.0 设置面板只收录 volatile 字段) */
42
+ for (const schema of [PublicConfig, Config]) for (const child of Object.values(schema.dict ?? {})) child.meta.volatile = true;
43
+ /** 解析后的配置快照 */
44
+ function resolveKbConfig(c = {}) {
45
+ return {
46
+ watchDir: c.watchDir ?? "",
47
+ topK: Math.max(1, Math.min(50, c.topK ?? 5)),
48
+ minScore: Math.max(0, Math.min(1, c.minScore ?? .3)),
49
+ description: c.description ?? "",
50
+ embeddingModel: c.embeddingModel ?? "Xenova/bge-small-zh-v1.5",
51
+ batchSize: Math.max(1, Math.min(128, c.batchSize ?? 32))
52
+ };
53
+ }
54
+ //#endregion
55
+ //#region src/store.ts
56
+ /**
57
+ * 向量索引库:node:sqlite(DSH 宿主先例模式)。
58
+ * 表结构:documents(文档元数据)+ chunks(切块 + 向量 blob)。
59
+ * 检索:向量全量载入内存 → 余弦相似 top-K(万级 chunk 毫秒级)。
60
+ */
61
+ /** 库归属标识(PRAGMA application_id,抄宿主 session-query-sqlite 惯例) */
62
+ const APP_ID = 1380009794;
63
+ const SCHEMA_VERSION = 1;
64
+ var KbStore = class {
65
+ db;
66
+ vectors = [];
67
+ vectorRows = [];
68
+ loaded = false;
69
+ constructor(dbPath) {
70
+ const { DatabaseSync } = __require("node:sqlite");
71
+ mkdirSync(join(dbPath, ".."), { recursive: true });
72
+ this.db = new DatabaseSync(dbPath);
73
+ this.db.exec("PRAGMA journal_mode=WAL;");
74
+ const appId = this.db.prepare("PRAGMA application_id").get().application_id;
75
+ if (appId !== 0 && appId !== APP_ID) throw new Error(`rag-kb: ${dbPath} belongs to another application`);
76
+ this.db.exec(`PRAGMA application_id = ${APP_ID}; PRAGMA user_version = ${SCHEMA_VERSION};`);
77
+ this.db.exec(`
78
+ CREATE TABLE IF NOT EXISTS documents(
79
+ doc_id TEXT PRIMARY KEY,
80
+ name TEXT NOT NULL,
81
+ source TEXT NOT NULL DEFAULT 'upload',
82
+ bytes INTEGER NOT NULL DEFAULT 0,
83
+ chunk_count INTEGER NOT NULL DEFAULT 0,
84
+ status TEXT NOT NULL DEFAULT 'pending',
85
+ error TEXT NOT NULL DEFAULT '',
86
+ created_at TEXT NOT NULL DEFAULT '',
87
+ updated_at TEXT NOT NULL DEFAULT ''
88
+ );
89
+ CREATE TABLE IF NOT EXISTS chunks(
90
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
91
+ doc_id TEXT NOT NULL,
92
+ seq INTEGER NOT NULL,
93
+ text TEXT NOT NULL,
94
+ embedding BLOB NOT NULL,
95
+ FOREIGN KEY(doc_id) REFERENCES documents(doc_id) ON DELETE CASCADE
96
+ );
97
+ CREATE INDEX IF NOT EXISTS idx_chunks_doc ON chunks(doc_id);
98
+ `);
99
+ }
100
+ /** 文档内容 hash 作 doc_id(同名不同内容=不同文档) */
101
+ static docId(content) {
102
+ return createHash("sha256").update(content).digest("hex").slice(0, 16);
103
+ }
104
+ hasDoc(docId) {
105
+ return this.db.prepare("SELECT 1 FROM documents WHERE doc_id=?").get(docId) !== void 0;
106
+ }
107
+ upsertDoc(docId, name, source, bytes, status, error = "") {
108
+ const now = (/* @__PURE__ */ new Date()).toISOString();
109
+ this.db.prepare(`
110
+ INSERT INTO documents(doc_id, name, source, bytes, chunk_count, status, error, created_at, updated_at)
111
+ VALUES(?,?,?,?,0,?,?,?,?)
112
+ ON CONFLICT(doc_id) DO UPDATE SET name=excluded.name, bytes=excluded.bytes, status=excluded.status, error=excluded.error, updated_at=excluded.updated_at
113
+ `).run(docId, name, source, bytes, status, error, now, now);
114
+ this.loaded = false;
115
+ }
116
+ setDocStatus(docId, status, chunkCount, error = "") {
117
+ this.db.prepare("UPDATE documents SET status=?, chunk_count=?, error=?, updated_at=? WHERE doc_id=?").run(status, chunkCount, error, (/* @__PURE__ */ new Date()).toISOString(), docId);
118
+ this.loaded = false;
119
+ }
120
+ deleteDoc(docId) {
121
+ this.db.prepare("DELETE FROM chunks WHERE doc_id=?").run(docId);
122
+ this.db.prepare("DELETE FROM documents WHERE doc_id=?").run(docId);
123
+ this.loaded = false;
124
+ }
125
+ listDocs() {
126
+ return this.db.prepare("SELECT * FROM documents ORDER BY updated_at DESC").all();
127
+ }
128
+ getDoc(docId) {
129
+ return this.db.prepare("SELECT * FROM documents WHERE doc_id=?").get(docId);
130
+ }
131
+ /** 批量写入切块+向量(一个事务,失败回滚) */
132
+ insertChunks(docId, chunks) {
133
+ this.db.exec("BEGIN");
134
+ try {
135
+ this.db.prepare("DELETE FROM chunks WHERE doc_id=?").run(docId);
136
+ const stmt = this.db.prepare("INSERT INTO chunks(doc_id, seq, text, embedding) VALUES(?,?,?,?)");
137
+ for (const c of chunks) stmt.run(docId, c.seq, c.text, Buffer.from(c.embedding.buffer, c.embedding.byteOffset, c.embedding.byteLength));
138
+ this.db.exec("COMMIT");
139
+ } catch (e) {
140
+ this.db.exec("ROLLBACK");
141
+ throw e;
142
+ }
143
+ this.loaded = false;
144
+ }
145
+ /** 惰性加载全部向量到内存(首次或写后失效) */
146
+ ensureLoaded() {
147
+ if (this.loaded) return;
148
+ this.vectors = [];
149
+ this.vectorRows = [];
150
+ const rows = this.db.prepare("SELECT c.doc_id, c.seq, c.embedding FROM chunks c JOIN documents d ON d.doc_id=c.doc_id WHERE d.status='ready'").all();
151
+ for (const row of rows) {
152
+ this.vectors.push(new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4));
153
+ this.vectorRows.push({
154
+ docId: row.doc_id,
155
+ seq: row.seq
156
+ });
157
+ }
158
+ this.loaded = true;
159
+ }
160
+ /** 余弦相似检索(向量已归一化,等价点积) */
161
+ search(queryVec, topK, minScore) {
162
+ this.ensureLoaded();
163
+ const scored = [];
164
+ for (let i = 0; i < this.vectors.length; i++) {
165
+ const v = this.vectors[i];
166
+ let dot = 0;
167
+ for (let j = 0; j < queryVec.length; j++) dot += queryVec[j] * v[j];
168
+ if (dot >= minScore) scored.push({
169
+ docId: this.vectorRows[i].docId,
170
+ seq: this.vectorRows[i].seq,
171
+ score: dot
172
+ });
173
+ }
174
+ scored.sort((a, b) => b.score - a.score);
175
+ return scored.slice(0, topK);
176
+ }
177
+ /** 按 doc_id + seq 取 chunk 原文 */
178
+ chunkText(docId, seq) {
179
+ return this.db.prepare("SELECT text FROM chunks WHERE doc_id=? AND seq=?").get(docId, seq)?.text ?? "";
180
+ }
181
+ close() {
182
+ this.db.close();
183
+ }
184
+ };
185
+ //#endregion
186
+ //#region src/indexer.ts
187
+ /**
188
+ * 文档管线:解析(pdf/docx/md/txt)→ 切块(标题感知 + 固定长度 fallback)→ 批量向量化。
189
+ * Embedding: transformers.js(GPU 加速优先,ONNX Runtime 自动检测 CUDA/WebGPU/CPU)。
190
+ */
191
+ var indexer_exports = /* @__PURE__ */ __exportAll({
192
+ chunkText: () => chunkText,
193
+ extractText: () => extractText,
194
+ getEmbedder: () => getEmbedder,
195
+ indexFile: () => indexFile
196
+ });
197
+ /** 中文友好的切块参数 */
198
+ const CHUNK_TOKENS = 400;
199
+ const OVERLAP_RATIO = .1;
200
+ async function extractText(filePath) {
201
+ const ext = extname(filePath).toLowerCase();
202
+ const buf = await readFile(filePath);
203
+ if (ext === ".pdf") {
204
+ const { extractText: pdfExtract, getDocumentProxy } = await import("unpdf");
205
+ const { text, totalPages } = await pdfExtract(await getDocumentProxy(new Uint8Array(buf)), { mergePages: true });
206
+ return {
207
+ text,
208
+ pages: totalPages
209
+ };
210
+ }
211
+ if (ext === ".docx") {
212
+ const { value: html } = await (await import("mammoth")).extractRawText({ buffer: buf });
213
+ return { text: html };
214
+ }
215
+ if (ext === ".md" || ext === ".txt" || ext === ".log" || ext === ".csv" || ext === ".json") return { text: buf.toString("utf8") };
216
+ throw new Error(`不支持的文件类型 ${ext}(支持 pdf/docx/md/txt/csv/json)`);
217
+ }
218
+ /** 标题感知切块:按标题/空行分段,过长段再固定长度切 */
219
+ function chunkText(text) {
220
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
221
+ if (normalized === "") return [];
222
+ const paragraphs = normalized.split(/\n(?=#{1,4} )|\n\n+/).map((p) => p.trim()).filter((p) => p !== "");
223
+ const chunks = [];
224
+ let current = "";
225
+ const maxChars = CHUNK_TOKENS * 1.5;
226
+ const overlap = Math.floor(maxChars * OVERLAP_RATIO);
227
+ for (const para of paragraphs) {
228
+ if (current !== "" && (current + "\n" + para).length > maxChars) {
229
+ chunks.push(current);
230
+ current = current.length > overlap ? current.slice(-60) : "";
231
+ }
232
+ if (para.length > maxChars * 2) {
233
+ if (current !== "") {
234
+ chunks.push(current);
235
+ current = "";
236
+ }
237
+ for (let i = 0; i < para.length; i += 540) chunks.push(para.slice(i, i + maxChars));
238
+ continue;
239
+ }
240
+ current = current === "" ? para : current + "\n" + para;
241
+ }
242
+ if (current !== "") chunks.push(current);
243
+ return chunks.filter((c) => c.trim().length >= 10);
244
+ }
245
+ let pipelinePromise;
246
+ async function loadPipeline(modelName) {
247
+ const { pipeline, env } = await import("@huggingface/transformers");
248
+ const { dshHomePath } = await import("@deepseek-ai/dsh-home-paths");
249
+ env.cacheDir = dshHomePath("rag-kb", "hf-cache");
250
+ return pipeline("feature-extraction", modelName);
251
+ }
252
+ async function getEmbedder(modelName) {
253
+ pipelinePromise ??= loadPipeline(modelName);
254
+ const embedder = await pipelinePromise;
255
+ return {
256
+ /** 单条向量化(检索查询用) */
257
+ async embed(text) {
258
+ const out = await embedder(text, {
259
+ pooling: "cls",
260
+ normalize: true
261
+ });
262
+ return new Float32Array(out.data);
263
+ },
264
+ /** 批量向量化(索引用,batch 推理吃满多核/GPU) */
265
+ async embedBatch(texts, batchSize) {
266
+ const results = [];
267
+ for (let i = 0; i < texts.length; i += batchSize) {
268
+ const batch = texts.slice(i, i + batchSize);
269
+ const out = await embedder(batch, {
270
+ pooling: "cls",
271
+ normalize: true
272
+ });
273
+ const dim = out.data.length / batch.length;
274
+ for (let j = 0; j < batch.length; j++) results.push(new Float32Array(out.data.slice(j * dim, (j + 1) * dim)));
275
+ }
276
+ return results;
277
+ }
278
+ };
279
+ }
280
+ /** 完整索引管线:文件路径 → 切块+向量数组 */
281
+ async function indexFile(filePath, embedder, batchSize, onProgress) {
282
+ const { text } = await extractText(filePath);
283
+ const pieces = chunkText(text);
284
+ if (pieces.length === 0) return [];
285
+ const embeddings = await embedder.embedBatch(pieces, batchSize);
286
+ if (onProgress) onProgress(pieces.length, pieces.length);
287
+ return pieces.map((piece, i) => ({
288
+ seq: i,
289
+ text: piece,
290
+ embedding: embeddings[i]
291
+ }));
292
+ }
293
+ //#endregion
294
+ //#region src/plugin.ts
295
+ /**
296
+ * DSH RAG 知识库插件宿主半边入口。
297
+ * - 模型工具:knowledge_search / knowledge_status / knowledge_manage(会话语义管理)
298
+ * - systemPrompt 注入:agent 自动知道知识库
299
+ * - SSE 通道:索引进度推送(浏览器面板用)
300
+ * - 目录监视:watchDir 下的文档自动索引(V2 能力,V1 已生效)
301
+ */
302
+ const name = "rag-kb";
303
+ const inject = [
304
+ "tools",
305
+ "systemPrompt",
306
+ "settings"
307
+ ];
308
+ let store;
309
+ let cfg;
310
+ const progressListeners = /* @__PURE__ */ new Set();
311
+ function broadcastProgress(msg) {
312
+ for (const write of progressListeners) try {
313
+ write(`event: progress\ndata: ${JSON.stringify(msg)}\n\n`);
314
+ } catch {}
315
+ }
316
+ async function ensureStore() {
317
+ if (store !== void 0) return store;
318
+ const { dshHomePath } = await import("@deepseek-ai/dsh-home-paths");
319
+ store = new KbStore(dshHomePath("rag-kb", "index.db"));
320
+ return store;
321
+ }
322
+ /** 索引一个文件路径(upload 或 watch 共用) */
323
+ async function indexOne(ctx, filePath, source) {
324
+ const s = await ensureStore();
325
+ const name = basename(filePath);
326
+ const bytes = statSync(filePath).size;
327
+ const content = await (await import("node:fs/promises")).readFile(filePath);
328
+ const docId = KbStore.docId(content);
329
+ if (s.hasDoc(docId) && s.getDoc(docId)?.status === "ready") return {
330
+ ok: true,
331
+ message: `${name} 已索引(内容未变化,跳过)`
332
+ };
333
+ s.upsertDoc(docId, name, source, bytes, "indexing");
334
+ broadcastProgress(`正在索引 ${name}`);
335
+ try {
336
+ const chunks = await indexFile(filePath, await getEmbedder(cfg?.embeddingModel ?? "Xenova/bge-small-zh-v1.5"), cfg?.batchSize ?? 32);
337
+ s.insertChunks(docId, chunks.map((c) => ({
338
+ seq: c.seq,
339
+ text: c.text,
340
+ embedding: c.embedding
341
+ })));
342
+ s.setDocStatus(docId, "ready", chunks.length);
343
+ broadcastProgress(`${name} 索引完成(${chunks.length} 块)`);
344
+ return {
345
+ ok: true,
346
+ message: `${name} 索引完成,共 ${chunks.length} 个知识块`
347
+ };
348
+ } catch (e) {
349
+ const msg = String(e.message ?? e);
350
+ s.setDocStatus(docId, "failed", 0, msg);
351
+ broadcastProgress(`${name} 索引失败:${msg}`);
352
+ return {
353
+ ok: false,
354
+ message: `${name} 索引失败:${msg}`
355
+ };
356
+ }
357
+ }
358
+ /** 语义检索 */
359
+ async function search(query, topK) {
360
+ const s = await ensureStore();
361
+ const queryVec = await (await getEmbedder(cfg?.embeddingModel ?? "Xenova/bge-small-zh-v1.5")).embed(query);
362
+ return s.search(queryVec, topK ?? cfg?.topK ?? 5, cfg?.minScore ?? .3).map((h) => ({
363
+ doc: s.getDoc(h.docId)?.name ?? h.docId,
364
+ seq: h.seq,
365
+ score: Number(h.score.toFixed(3)),
366
+ text: s.chunkText(h.docId, h.seq).slice(0, 800)
367
+ }));
368
+ }
369
+ function apply(ctx, config = {}) {
370
+ const unwrap = (v) => v !== null && typeof v === "object" && "get" in v ? v.get() : v;
371
+ const raw = {};
372
+ for (const [k, v] of Object.entries(config)) raw[k] = unwrap(v);
373
+ cfg = resolveKbConfig(raw);
374
+ ctx.inject(["webServer"], (hostCtx) => {
375
+ hostCtx.webServer.register({
376
+ kind: "exact",
377
+ path: "/rag-kb/progress",
378
+ handler: (req, res) => {
379
+ res.writeHead(200, {
380
+ "content-type": "text/event-stream",
381
+ "cache-control": "no-store",
382
+ connection: "keep-alive"
383
+ });
384
+ res.write("retry: 3000\n\n");
385
+ const write = (chunk) => res.write(chunk);
386
+ progressListeners.add(write);
387
+ const heartbeat = setInterval(() => {
388
+ try {
389
+ res.write(": ping\n\n");
390
+ } catch {}
391
+ }, 25e3);
392
+ req.on("close", () => {
393
+ clearInterval(heartbeat);
394
+ progressListeners.delete(write);
395
+ });
396
+ }
397
+ });
398
+ });
399
+ if (cfg.watchDir !== "" && existsSync(cfg.watchDir)) {
400
+ const dir = resolve(cfg.watchDir);
401
+ try {
402
+ const watcher = watch(dir, { persistent: false }, (_ev, filename) => {
403
+ if (filename === null || filename === void 0) return;
404
+ const fname = String(filename);
405
+ if (!/\.(pdf|docx|md|txt|csv|json|log)$/i.test(fname)) return;
406
+ const f = join(dir, fname);
407
+ setTimeout(() => {
408
+ if (existsSync(f)) indexOne(ctx, f, "watch");
409
+ }, 2e3);
410
+ });
411
+ ctx.effect(() => {
412
+ watcher.close();
413
+ }, "rag-kb: dir watcher");
414
+ } catch (e) {
415
+ console.warn(`[rag-kb] 目录监视失败 ${dir}: ${String(e.message)}`);
416
+ }
417
+ }
418
+ const compileParams = (spec) => {
419
+ const properties = {};
420
+ const required = [];
421
+ for (const [key, def] of Object.entries(spec)) {
422
+ const { required: req, ...rest } = def;
423
+ properties[key] = rest;
424
+ if (req === true) required.push(key);
425
+ }
426
+ return {
427
+ type: "object",
428
+ properties,
429
+ ...required.length > 0 ? { required } : {}
430
+ };
431
+ };
432
+ const disposers = [
433
+ ctx.tools.register({
434
+ name: "knowledge_search",
435
+ description: "在本地知识库中语义检索。返回最相关的知识块(带来源文档名、相似度、原文片段)。回答与知识库内容相关的问题前应先检索。query 支持自然语言。",
436
+ parameters: compileParams({
437
+ query: {
438
+ type: "string",
439
+ required: true,
440
+ description: "检索问题(自然语言,中英文均可)"
441
+ },
442
+ top_k: {
443
+ type: "number",
444
+ description: "返回条数(默认 5)"
445
+ }
446
+ }),
447
+ output: {
448
+ schema: {},
449
+ render: (_a, v) => [{
450
+ type: "text",
451
+ text: JSON.stringify(v)
452
+ }]
453
+ },
454
+ async execute(args) {
455
+ const a = args;
456
+ const hits = await search(a.query, a.top_k);
457
+ return {
458
+ ok: true,
459
+ count: hits.length,
460
+ results: hits
461
+ };
462
+ }
463
+ }),
464
+ ctx.tools.register({
465
+ name: "knowledge_status",
466
+ description: "查看知识库状态:已索引文档列表(名称/大小/块数/状态)、配置参数、知识库描述。",
467
+ parameters: compileParams({}),
468
+ output: {
469
+ schema: {},
470
+ render: (_a, v) => [{
471
+ type: "text",
472
+ text: JSON.stringify(v)
473
+ }]
474
+ },
475
+ async execute() {
476
+ const s = await ensureStore();
477
+ return {
478
+ ok: true,
479
+ description: cfg?.description ?? "",
480
+ topK: cfg?.topK,
481
+ minScore: cfg?.minScore,
482
+ documents: s.listDocs().map((d) => ({
483
+ name: d.name,
484
+ source: d.source,
485
+ bytes: d.bytes,
486
+ chunks: d.chunk_count,
487
+ status: d.status,
488
+ ...d.error !== "" ? { error: d.error } : {}
489
+ }))
490
+ };
491
+ }
492
+ }),
493
+ ctx.tools.register({
494
+ name: "knowledge_manage",
495
+ description: "知识库管理操作(用户在对话中用自然语言请求时调用)。支持:save(把对话中的内容直接保存入库,给 title + text)、add(索引本地文件路径或目录)、remove(按文档名删除)、reindex(重建全部索引)、describe(设置知识库描述)。用户说「存到知识库」「把这个记下来」时用 save。",
496
+ parameters: compileParams({
497
+ action: {
498
+ type: "string",
499
+ required: true,
500
+ description: "add | remove | reindex | describe"
501
+ },
502
+ path: {
503
+ type: "string",
504
+ description: "add:本地文件或目录路径"
505
+ },
506
+ title: {
507
+ type: "string",
508
+ description: "save:文档标题(会成为知识库里的文档名)"
509
+ },
510
+ text: {
511
+ type: "string",
512
+ description: "save:要保存的文本内容(直接从对话中提取,无需先写文件)"
513
+ },
514
+ name: {
515
+ type: "string",
516
+ description: "remove:要删除的文档名"
517
+ },
518
+ description: {
519
+ type: "string",
520
+ description: "describe:知识库描述文本"
521
+ }
522
+ }),
523
+ output: {
524
+ schema: {},
525
+ render: (_a, v) => [{
526
+ type: "text",
527
+ text: JSON.stringify(v)
528
+ }]
529
+ },
530
+ async execute(args) {
531
+ const a = args;
532
+ const s = await ensureStore();
533
+ if (a.action === "save" && a.text !== void 0 && a.text !== "") {
534
+ const title = a.title !== void 0 && a.title !== "" ? a.title : `对话保存 ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 16)}`;
535
+ const content = `# ${title}\n\n${a.text}`;
536
+ const docId = KbStore.docId(content);
537
+ if (s.hasDoc(docId) && s.getDoc(docId)?.status === "ready") return {
538
+ ok: true,
539
+ message: `${title} 已在知识库中(内容相同,跳过)`
540
+ };
541
+ s.upsertDoc(docId, title, "save", content.length, "indexing");
542
+ broadcastProgress(`正在索引对话内容「${title}」`);
543
+ try {
544
+ const embedder = await getEmbedder(cfg?.embeddingModel ?? "Xenova/bge-small-zh-v1.5");
545
+ const { chunkText } = await Promise.resolve().then(() => indexer_exports);
546
+ const pieces = chunkText(content);
547
+ if (pieces.length === 0) return {
548
+ ok: false,
549
+ error: "内容太短或为空,无法切块"
550
+ };
551
+ const embeddings = await embedder.embedBatch(pieces, cfg?.batchSize ?? 32);
552
+ s.insertChunks(docId, pieces.map((piece, i) => ({
553
+ seq: i,
554
+ text: piece,
555
+ embedding: embeddings[i]
556
+ })));
557
+ s.setDocStatus(docId, "ready", pieces.length);
558
+ broadcastProgress(`「${title}」已入库(${pieces.length} 块)`);
559
+ return {
560
+ ok: true,
561
+ message: `已保存「${title}」到知识库,共 ${pieces.length} 个知识块`
562
+ };
563
+ } catch (e) {
564
+ const msg = String(e.message ?? e);
565
+ s.setDocStatus(docId, "failed", 0, msg);
566
+ return {
567
+ ok: false,
568
+ error: `索引失败:${msg}`
569
+ };
570
+ }
571
+ }
572
+ if (a.action === "add" && a.path) {
573
+ const p = resolve(a.path);
574
+ if (!existsSync(p)) return {
575
+ ok: false,
576
+ error: `路径不存在:${p}`
577
+ };
578
+ if (statSync(p).isFile()) {
579
+ const r = await indexOne(ctx, p, "upload");
580
+ return {
581
+ ok: r.ok,
582
+ message: r.message
583
+ };
584
+ }
585
+ const { readdir } = await import("node:fs/promises");
586
+ const files = (await readdir(p)).filter((f) => /\.(pdf|docx|md|txt|csv|json|log)$/i.test(f)).map((f) => join(p, f));
587
+ const results = [];
588
+ for (const f of files) {
589
+ const r = await indexOne(ctx, f, "upload");
590
+ results.push(r.message);
591
+ }
592
+ return {
593
+ ok: true,
594
+ message: `批量索引 ${files.length} 个文件`,
595
+ detail: results
596
+ };
597
+ }
598
+ if (a.action === "remove" && a.name) {
599
+ const docs = s.listDocs().filter((d) => d.name.includes(a.name));
600
+ if (docs.length === 0) return {
601
+ ok: false,
602
+ error: `未找到包含「${a.name}」的文档`
603
+ };
604
+ for (const d of docs) s.deleteDoc(d.doc_id);
605
+ return {
606
+ ok: true,
607
+ message: `已删除 ${docs.length} 个文档:${docs.map((d) => d.name).join(", ")}`
608
+ };
609
+ }
610
+ if (a.action === "reindex") {
611
+ const docs = s.listDocs();
612
+ for (const d of docs) s.setDocStatus(d.doc_id, "pending", 0);
613
+ return {
614
+ ok: true,
615
+ message: `已重置 ${docs.length} 个文档为待索引。请用户通过设置面板或重新 add 触发重建(文件源路径不再保留,需重新添加)。`
616
+ };
617
+ }
618
+ if (a.action === "describe" && a.description !== void 0) return {
619
+ ok: true,
620
+ message: `知识库描述已记录(当前会话生效)。持久化请通过设置面板「知识库描述」字段。`,
621
+ description: a.description
622
+ };
623
+ return {
624
+ ok: false,
625
+ error: `未知操作 ${a.action}(支持 save/add/remove/reindex/describe)`
626
+ };
627
+ }
628
+ })
629
+ ];
630
+ ctx.effect(() => () => {
631
+ for (const d of disposers) d();
632
+ }, "rag-kb: tools");
633
+ const promptText = () => {
634
+ const desc = cfg?.description ?? "";
635
+ const docs = store?.listDocs().filter((d) => d.status === "ready").length ?? 0;
636
+ return [
637
+ "You have a local knowledge base accessible via these tools:",
638
+ "- knowledge_search: semantic search the KB (use BEFORE answering questions about its content)",
639
+ "- knowledge_status: list documents and config",
640
+ "- knowledge_manage: add/save/remove/reindex documents",
641
+ desc !== "" ? `KB description: ${desc}` : "",
642
+ `Currently ${docs} document(s) indexed.`,
643
+ "When the user says \"save this to the knowledge base\" (保存到知识库/记下来), extract the valuable content from the conversation and call knowledge_manage with action=\"save\", providing a concise title and the text.",
644
+ "For file/directory indexing use action=\"add\" with a path."
645
+ ].filter(Boolean).join("\n");
646
+ };
647
+ ctx.systemPrompt.section({
648
+ name: "tool:rag-kb",
649
+ order: 2350,
650
+ text: promptText,
651
+ interpolate: false
652
+ });
653
+ }
654
+ //#endregion
655
+ export { Config, KbStore, apply, chunkText, extractText, getEmbedder, indexFile, inject, name, resolveKbConfig };
656
+
657
+ //# sourceMappingURL=plugin.mjs.map
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@puwenhui/dsh-rag-kb",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "DSH RAG 知识库插件:文档导入→切块→本地向量化→语义检索,knowledge_search 工具 + 会话语义管理 + 设置面板",
5
+ "publishConfig": {
6
+ "registry": "https://registry.npmjs.org/",
7
+ "access": "public"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Puwenhui-Me/dsh-rag-kb.git"
12
+ },
13
+ "homepage": "https://github.com/Puwenhui-Me/dsh-rag-kb#readme",
14
+ "type": "module",
15
+ "main": "lib/plugin.mjs",
16
+ "exports": {
17
+ ".": "./lib/plugin.mjs",
18
+ "./client": "./lib/client.js",
19
+ "./cordis.patch.yml": "./cordis.patch.yml",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": [
23
+ "lib/plugin.mjs",
24
+ "lib/client.js",
25
+ "cordis.patch.yml",
26
+ "README.md",
27
+ "USAGE.zh.md"
28
+ ],
29
+ "dsh": {
30
+ "bundle": {
31
+ "patch": "./cordis.patch.yml"
32
+ },
33
+ "client": {
34
+ "platform": "web",
35
+ "inject": [
36
+ "@deepseek-ai/dsh-client-connection",
37
+ "@deepseek-ai/dsh-client-locale",
38
+ "@deepseek-ai/dsh-client-ui-theme"
39
+ ]
40
+ }
41
+ },
42
+ "dependencies": {
43
+ "@deepseek-ai/schemastery": "^3.18.2",
44
+ "@huggingface/transformers": "^3.7.2",
45
+ "mammoth": "^1.11.0",
46
+ "unpdf": "^0.12.1"
47
+ },
48
+ "peerDependencies": {
49
+ "@deepseek-ai/cordis": "^4.0.1"
50
+ },
51
+ "devDependencies": {
52
+ "@deepseek-ai/cordis": "^4.0.4",
53
+ "tsdown": "^0.23.0",
54
+ "typescript": "^7.0.2"
55
+ },
56
+ "license": "MIT"
57
+ }