@jungtz/wiki-router 1.0.5 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -3
- package/dist/index.cjs +353 -48
- package/dist/index.d.ts +43 -1
- package/dist/index.mjs +351 -49
- package/package.json +1 -1
- package/src/types.d.ts +43 -1
package/README.md
CHANGED
|
@@ -95,12 +95,30 @@ const wikiB = wikiOf(7) // 旅館 B
|
|
|
95
95
|
|
|
96
96
|
✱ `source` 與 `knowledgeDir` 至少擇一;`store` 與 `outputDir` 至少擇一。
|
|
97
97
|
|
|
98
|
-
### `wiki.build()`
|
|
98
|
+
### `wiki.build(options?)`
|
|
99
99
|
|
|
100
100
|
建構或增量更新 Wiki 知識庫。回傳 `Promise<boolean>`。
|
|
101
101
|
|
|
102
|
+
| 選項 | 類型 | 預設 | 說明 |
|
|
103
|
+
|------|------|------|------|
|
|
104
|
+
| `force` | `boolean` | `false` | 為 `true` 時跳過 fingerprint 比對,無條件重新呼叫 LLM 生成 |
|
|
105
|
+
|
|
102
106
|
- 首次(`store.list()` 為空):對 JSON 來源執行 split prompt,拆分成多個 `.md`
|
|
103
107
|
- 後續:使用 merge prompt,將新來源合併到既有檔案
|
|
108
|
+
- **快取**:若 source 提供 `getFingerprint()` 且 store 提供 `readManifest()` / `writeManifest()`,build 會比對來源指紋,未變動時直接跳過 LLM(詳見「來源變更偵測」)
|
|
109
|
+
|
|
110
|
+
### 來源變更偵測 (Fingerprint)
|
|
111
|
+
|
|
112
|
+
當 source 與 store 都實作對應的選用方法時,`build()` 會自動進行內容指紋比對:
|
|
113
|
+
|
|
114
|
+
1. 進入 build → 呼叫 `source.getFingerprint()` 取得當前來源指紋
|
|
115
|
+
2. 呼叫 `store.readManifest()` 取得上次 build 留下的指紋
|
|
116
|
+
3. 兩者相等且 store 已有檔案 → 跳過 LLM,回傳 `true`
|
|
117
|
+
4. 不等 → 走原本流程,build 成功後呼叫 `store.writeManifest()` 寫入新指紋
|
|
118
|
+
|
|
119
|
+
`force: true` 跳過比對直接重建。內建 `fsSource` / `fsStore` 已實作這組方法(manifest 存在 store 目錄下的 `.manifest.json`)。
|
|
120
|
+
|
|
121
|
+
自訂 adapter 沒實作這些方法時,build 仍會正常執行(每次都重跑 LLM),向下相容。
|
|
104
122
|
|
|
105
123
|
### `wiki.getContext(prompt)`
|
|
106
124
|
|
|
@@ -118,6 +136,7 @@ const wikiB = wikiOf(7) // 旅館 B
|
|
|
118
136
|
interface Source {
|
|
119
137
|
list(): Promise<string[]> // 來源 key 清單
|
|
120
138
|
read(key: string): Promise<{ type: 'json' | 'markdown', content: string }>
|
|
139
|
+
getFingerprint?(): Promise<Record<string, string>> // 選用:來源指紋 { key: hash }
|
|
121
140
|
}
|
|
122
141
|
```
|
|
123
142
|
|
|
@@ -128,23 +147,117 @@ interface Store {
|
|
|
128
147
|
list(): Promise<string[]> // 已生成的 wiki 檔名(含 .md)
|
|
129
148
|
read(filename: string): Promise<string | null> // 不存在回 null
|
|
130
149
|
write(filename: string, content: string): Promise<void>
|
|
150
|
+
readManifest?(): Promise<Record<string, string> | null> // 選用:讀上次 build 指紋
|
|
151
|
+
writeManifest?(m: Record<string, string>): Promise<void> // 選用:寫本次 build 指紋
|
|
131
152
|
}
|
|
132
153
|
```
|
|
133
154
|
|
|
155
|
+
`getFingerprint` / `readManifest` / `writeManifest` 是選用介面,用於來源變更偵測快取。**三者必須同時實作才會啟用快取**;缺其一則 build 仍會正常執行(每次都重跑 LLM)。
|
|
156
|
+
|
|
157
|
+
#### 自訂 adapter 範例(API + DB)
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
source: {
|
|
161
|
+
async list() { return ['base.json'] },
|
|
162
|
+
async read() { /* fetch from API */ },
|
|
163
|
+
async getFingerprint() {
|
|
164
|
+
const res = await fetch(`/api/hotels/${hotelId}/etag`)
|
|
165
|
+
return { 'base.json': await res.text() } // 用 ETag 當指紋
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
store: {
|
|
169
|
+
async list() { /* DB query */ },
|
|
170
|
+
async read(filename) { /* ... */ },
|
|
171
|
+
async write(filename, body) { /* ... */ },
|
|
172
|
+
async readManifest() { return await db.wikiManifest.get(hotelId) },
|
|
173
|
+
async writeManifest(manifest) { await db.wikiManifest.upsert(hotelId, manifest) },
|
|
174
|
+
},
|
|
175
|
+
```
|
|
176
|
+
|
|
134
177
|
### 內建 adapter
|
|
135
178
|
|
|
136
179
|
```js
|
|
137
|
-
import { fsSource, fsStore } from '@jungtz/wiki-router'
|
|
180
|
+
import { fsSource, fsStore, sqliteStore, ensureWikiTables } from '@jungtz/wiki-router'
|
|
138
181
|
|
|
182
|
+
// 檔案系統
|
|
139
183
|
createWikiRouter({
|
|
140
184
|
router,
|
|
141
185
|
source: fsSource('./knowledge'),
|
|
142
186
|
store: fsStore('./wiki-output'),
|
|
143
187
|
})
|
|
188
|
+
|
|
189
|
+
// SQLite (better-sqlite3 相容 db 物件)
|
|
190
|
+
ensureWikiTables(db) // 一次性建表(idempotent)
|
|
191
|
+
createWikiRouter({
|
|
192
|
+
router,
|
|
193
|
+
source: fsSource('./knowledge'),
|
|
194
|
+
store: sqliteStore({ db, tenantId: 'hotel-001' }), // 多租戶用同一個 db、不同 tenantId
|
|
195
|
+
})
|
|
144
196
|
```
|
|
145
197
|
|
|
146
198
|
`knowledgeDir` / `outputDir` 簡寫即在內部分別包成 `fsSource` / `fsStore`。
|
|
147
199
|
|
|
200
|
+
#### `sqliteStore({ db, tenantId, ... })`
|
|
201
|
+
|
|
202
|
+
| 參數 | 類型 | 必要 | 說明 |
|
|
203
|
+
|------|------|------|------|
|
|
204
|
+
| `db` | better-sqlite3 instance | ✅ | 須有 `prepare(sql)` 與 `exec(sql)` 方法(不直接相依 better-sqlite3 套件) |
|
|
205
|
+
| `tenantId` | `string` | ✅ | 多租戶識別字串,不同 tenant 完全隔離 |
|
|
206
|
+
| `filesTable` | `string` | | 預設 `wiki_files` |
|
|
207
|
+
| `manifestsTable` | `string` | | 預設 `wiki_manifests` |
|
|
208
|
+
|
|
209
|
+
`sqliteStore` 內部會自動呼叫 `ensureWikiTables(db)`,但若想預先建表也可以單獨匯入使用。
|
|
210
|
+
|
|
211
|
+
## 多租戶管理 — `createTenantManager`
|
|
212
|
+
|
|
213
|
+
把「快取 wiki 實例 / 並行 build dedup / buildAll / 已 build 狀態追蹤 / Index.md fallback」統一封裝。其他專案串多 wiki 時只要設定 config 即可。
|
|
214
|
+
|
|
215
|
+
```js
|
|
216
|
+
import { createTenantManager, fsSource, sqliteStore } from '@jungtz/wiki-router'
|
|
217
|
+
import Database from 'better-sqlite3'
|
|
218
|
+
|
|
219
|
+
const db = new Database('./wiki.db')
|
|
220
|
+
|
|
221
|
+
const manager = createTenantManager({
|
|
222
|
+
router,
|
|
223
|
+
modelId: 'ollama-local/gemma4:31b',
|
|
224
|
+
|
|
225
|
+
// adapter factory:每個 tenantId 各自建立 source / store
|
|
226
|
+
source: tid => fsSource(`./knowledge/${tid}`),
|
|
227
|
+
store: tid => sqliteStore({ db, tenantId: tid }),
|
|
228
|
+
|
|
229
|
+
// 選用:buildAll() 才需要
|
|
230
|
+
listTenants: () => fs.readdirSync('./knowledge'),
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
await manager.buildAll() // 啟動時並行 build 所有租戶
|
|
234
|
+
await manager.build('hotel-001', { force: true }) // 單一租戶強制重建
|
|
235
|
+
const ctx = await manager.getContext('問題', 'hotel-001')
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Config
|
|
239
|
+
|
|
240
|
+
| 參數 | 類型 | 必要 | 說明 |
|
|
241
|
+
|------|------|------|------|
|
|
242
|
+
| `router` | `AIProviderRouter` | ✅ | AI Router 實例 |
|
|
243
|
+
| `source` | `(tenantId) => Source` | ✅ | Source factory |
|
|
244
|
+
| `store` | `(tenantId) => Store` | ✅ | Store factory |
|
|
245
|
+
| `listTenants` | `() => string[] \| Promise<string[]>` | | `buildAll()` 才需要 |
|
|
246
|
+
| `autoIndex` | `boolean` | | 預設 `true`;build 後 store 沒 Index.md 時,從現有 .md 合成一份目錄 |
|
|
247
|
+
| `autoIndexHeader` | `string` | | 預設 `# 知識庫目錄` |
|
|
248
|
+
| `logger` | `{ log, warn, error }` | | 預設 `console` |
|
|
249
|
+
| `modelId` / `routerModelId` / `timeout` / `*Prompt` | | | 同 `createWikiRouter` config,傳給每個 tenant 的 wiki 實例 |
|
|
250
|
+
|
|
251
|
+
### 回傳 API
|
|
252
|
+
|
|
253
|
+
| 方法 | 說明 |
|
|
254
|
+
|------|------|
|
|
255
|
+
| `build(tenantId, { force })` | 為單一租戶 build;同租戶並行呼叫共用同一個 promise |
|
|
256
|
+
| `buildAll()` | 並行 build 所有租戶,使用 `Promise.allSettled` 避免單租戶失敗影響其他 |
|
|
257
|
+
| `getContext(prompt, tenantId)` | 取得該租戶的 wiki 上下文;尚未 build 完成時回空字串(不阻塞) |
|
|
258
|
+
| `isBuilt(tenantId)` | 該租戶是否已成功 build 過 |
|
|
259
|
+
| `listBuilt()` | 已 build 過的租戶清單 |
|
|
260
|
+
|
|
148
261
|
## 子模組
|
|
149
262
|
|
|
150
263
|
### 解析器
|
|
@@ -201,7 +314,21 @@ npm run preview -- --knowledge ./my-knowledge --output ./my-wiki
|
|
|
201
314
|
|
|
202
315
|
支援指令:`:list` 查看維基頁面、`:build` 重建維基、`:files` 顯示檔案、`:quit` 離開。
|
|
203
316
|
|
|
204
|
-
##
|
|
317
|
+
## 發布
|
|
318
|
+
|
|
319
|
+
### 自動發布(推薦)
|
|
320
|
+
|
|
321
|
+
push 到 `master` 分支時,CI 會自動掃描自上個 tag 以來的 commit message,依 gitmoji 決定 bump 類型並 publish 到 npm:
|
|
322
|
+
|
|
323
|
+
| Commit message 包含 | Bump 類型 | 例 |
|
|
324
|
+
|--------------------|----------|----|
|
|
325
|
+
| `:boom:` 💥 / `BREAKING CHANGE` | major | 1.0.5 → 2.0.0 |
|
|
326
|
+
| `:sparkles:` ✨ | minor | 1.0.5 → 1.1.0 |
|
|
327
|
+
| 其他 (`:bug:` / `:hammer:` / ...) | patch | 1.0.5 → 1.0.6 |
|
|
328
|
+
|
|
329
|
+
只要照常用 gitmoji 寫 commit,版號自己對齊語意。
|
|
330
|
+
|
|
331
|
+
### 本地手動發布腳本(備用)
|
|
205
332
|
|
|
206
333
|
```bash
|
|
207
334
|
npm run bump:patch # 版號 patch +1 並自動 git commit + tag
|
package/dist/index.cjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* LLM Wiki 提示詞 — 從 src/prompts/*.md 檔案載入
|
|
@@ -97,6 +98,8 @@ function parseWikiOutput(output) {
|
|
|
97
98
|
*/
|
|
98
99
|
|
|
99
100
|
|
|
101
|
+
const MANIFEST_FILE = '.manifest.json';
|
|
102
|
+
|
|
100
103
|
/**
|
|
101
104
|
* 建立讀取本地檔案的 Source adapter
|
|
102
105
|
* @param {string} dir - knowledge 來源目錄
|
|
@@ -104,19 +107,30 @@ function parseWikiOutput(output) {
|
|
|
104
107
|
*/
|
|
105
108
|
function fsSource(dir) {
|
|
106
109
|
const resolved = path.resolve(dir);
|
|
110
|
+
async function list() {
|
|
111
|
+
if (!fs.existsSync(resolved)) return []
|
|
112
|
+
return fs
|
|
113
|
+
.readdirSync(resolved)
|
|
114
|
+
.filter(f => f.endsWith('.json') || f.endsWith('.md'))
|
|
115
|
+
.sort()
|
|
116
|
+
}
|
|
117
|
+
async function read(key) {
|
|
118
|
+
const filePath = path.join(resolved, key);
|
|
119
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
120
|
+
const type = key.endsWith('.json') ? 'json' : 'markdown';
|
|
121
|
+
return { type, content }
|
|
122
|
+
}
|
|
107
123
|
return {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const type = key.endsWith('.json') ? 'json' : 'markdown';
|
|
119
|
-
return { type, content }
|
|
124
|
+
list,
|
|
125
|
+
read,
|
|
126
|
+
async getFingerprint() {
|
|
127
|
+
const keys = await list();
|
|
128
|
+
const fp = {};
|
|
129
|
+
for (const key of keys) {
|
|
130
|
+
const { content } = await read(key);
|
|
131
|
+
fp[key] = crypto.createHash('sha256').update(content).digest('hex');
|
|
132
|
+
}
|
|
133
|
+
return fp
|
|
120
134
|
},
|
|
121
135
|
}
|
|
122
136
|
}
|
|
@@ -145,17 +159,29 @@ function fsStore(dir) {
|
|
|
145
159
|
ensure();
|
|
146
160
|
fs.writeFileSync(path.join(resolved, filename), content);
|
|
147
161
|
},
|
|
162
|
+
async readManifest() {
|
|
163
|
+
const filePath = path.join(resolved, MANIFEST_FILE);
|
|
164
|
+
if (!fs.existsSync(filePath)) return null
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
|
167
|
+
} catch {
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
async writeManifest(manifest) {
|
|
172
|
+
ensure();
|
|
173
|
+
fs.writeFileSync(
|
|
174
|
+
path.join(resolved, MANIFEST_FILE),
|
|
175
|
+
JSON.stringify(manifest, null, 2),
|
|
176
|
+
'utf-8'
|
|
177
|
+
);
|
|
178
|
+
},
|
|
148
179
|
}
|
|
149
180
|
}
|
|
150
181
|
|
|
151
182
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* 使用方式 (檔案系統):
|
|
155
|
-
* const wiki = createWikiRouter({ router, knowledgeDir, outputDir })
|
|
156
|
-
*
|
|
157
|
-
* 使用方式 (自訂 adapter,例如 API + DB):
|
|
158
|
-
* const wiki = createWikiRouter({ router, source, store })
|
|
183
|
+
* createWikiRouter — 單一 wiki 實例工廠
|
|
184
|
+
* 已從 index.js 拆出,避免 tenant.js → index.js 的循環依賴。
|
|
159
185
|
*/
|
|
160
186
|
|
|
161
187
|
|
|
@@ -196,10 +222,6 @@ function createWikiRouter(config) {
|
|
|
196
222
|
|
|
197
223
|
let modelsFetched = false;
|
|
198
224
|
|
|
199
|
-
/**
|
|
200
|
-
* 預先獲取本地模型清單(仿照 models.js 的 getModelsList 邏輯)
|
|
201
|
-
* 確保 switchModel 能識別動態發現的 local 模型
|
|
202
|
-
*/
|
|
203
225
|
async function ensureModels() {
|
|
204
226
|
if (modelsFetched) return
|
|
205
227
|
try {
|
|
@@ -212,7 +234,7 @@ function createWikiRouter(config) {
|
|
|
212
234
|
if (pModels.length > 0) {
|
|
213
235
|
router.config.providers[pName].models = pModels.map(m => ({
|
|
214
236
|
model: m.model,
|
|
215
|
-
description: m.description
|
|
237
|
+
description: m.description,
|
|
216
238
|
}));
|
|
217
239
|
}
|
|
218
240
|
});
|
|
@@ -222,13 +244,6 @@ function createWikiRouter(config) {
|
|
|
222
244
|
}
|
|
223
245
|
}
|
|
224
246
|
|
|
225
|
-
/**
|
|
226
|
-
* 向 LLM 發送對話並取得完整回應
|
|
227
|
-
* @param {{ role: string, content: string }[]} messages
|
|
228
|
-
* @param {string} [overrideModelId]
|
|
229
|
-
* @param {number} [overrideTimeout]
|
|
230
|
-
* @returns {Promise<string|null>}
|
|
231
|
-
*/
|
|
232
247
|
async function chat(messages, overrideModelId, overrideTimeout) {
|
|
233
248
|
const wikiModelId = overrideModelId || modelId || process.env.WIKI_MODEL || 'ollama-local/gemma4:31b';
|
|
234
249
|
const effectiveTimeout = overrideTimeout || timeout;
|
|
@@ -243,7 +258,6 @@ function createWikiRouter(config) {
|
|
|
243
258
|
modelName = parts.slice(1).join('/');
|
|
244
259
|
}
|
|
245
260
|
|
|
246
|
-
// 使用 AI Router 進行對話
|
|
247
261
|
if (router && provider) {
|
|
248
262
|
await ensureModels();
|
|
249
263
|
await router.switchModel(provider, modelName);
|
|
@@ -268,11 +282,16 @@ function createWikiRouter(config) {
|
|
|
268
282
|
}
|
|
269
283
|
}
|
|
270
284
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
285
|
+
function fingerprintsEqual(a, b) {
|
|
286
|
+
if (!a || !b) return false
|
|
287
|
+
const ak = Object.keys(a);
|
|
288
|
+
const bk = Object.keys(b);
|
|
289
|
+
if (ak.length !== bk.length) return false
|
|
290
|
+
return ak.every(k => a[k] === b[k])
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function build(options = {}) {
|
|
294
|
+
const { force = false } = options;
|
|
276
295
|
try {
|
|
277
296
|
const knowledgeKeys = await activeSource.list();
|
|
278
297
|
|
|
@@ -281,6 +300,24 @@ function createWikiRouter(config) {
|
|
|
281
300
|
return false
|
|
282
301
|
}
|
|
283
302
|
|
|
303
|
+
let currentFp = null;
|
|
304
|
+
const sourceSupportsFp = typeof activeSource.getFingerprint === 'function';
|
|
305
|
+
const storeSupportsManifest =
|
|
306
|
+
typeof activeStore.readManifest === 'function' &&
|
|
307
|
+
typeof activeStore.writeManifest === 'function';
|
|
308
|
+
|
|
309
|
+
if (sourceSupportsFp && storeSupportsManifest) {
|
|
310
|
+
currentFp = await activeSource.getFingerprint();
|
|
311
|
+
if (!force) {
|
|
312
|
+
const storedFp = await activeStore.readManifest();
|
|
313
|
+
const existingFiles = await activeStore.list();
|
|
314
|
+
if (existingFiles.length > 0 && fingerprintsEqual(currentFp, storedFp)) {
|
|
315
|
+
console.log('[Wiki] Source unchanged, skipping build (fingerprint match)');
|
|
316
|
+
return true
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
284
321
|
let totalFiles = 0;
|
|
285
322
|
|
|
286
323
|
for (const key of knowledgeKeys) {
|
|
@@ -322,6 +359,11 @@ function createWikiRouter(config) {
|
|
|
322
359
|
}
|
|
323
360
|
|
|
324
361
|
if (totalFiles === 0) return false
|
|
362
|
+
|
|
363
|
+
if (currentFp && storeSupportsManifest) {
|
|
364
|
+
await activeStore.writeManifest(currentFp);
|
|
365
|
+
}
|
|
366
|
+
|
|
325
367
|
return true
|
|
326
368
|
} catch (err) {
|
|
327
369
|
console.error('[Wiki Generation Error]', err);
|
|
@@ -329,11 +371,6 @@ function createWikiRouter(config) {
|
|
|
329
371
|
}
|
|
330
372
|
}
|
|
331
373
|
|
|
332
|
-
/**
|
|
333
|
-
* 根據使用者問題取得相關的 Wiki 上下文
|
|
334
|
-
* @param {string} prompt - 使用者問題
|
|
335
|
-
* @returns {Promise<string>}
|
|
336
|
-
*/
|
|
337
374
|
async function getContext(prompt) {
|
|
338
375
|
try {
|
|
339
376
|
let wikiFiles = await activeStore.list();
|
|
@@ -354,11 +391,15 @@ function createWikiRouter(config) {
|
|
|
354
391
|
.replace('{{indexContent}}', indexContent);
|
|
355
392
|
|
|
356
393
|
const routeModel = routerModelId || process.env.WIKI_ROUTER_MODEL || modelId || process.env.WIKI_MODEL;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
394
|
+
let output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
|
|
395
|
+
if (!output || !output.trim()) {
|
|
396
|
+
console.warn('[Wiki] Router LLM returned empty, retrying once...');
|
|
397
|
+
output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
|
|
398
|
+
}
|
|
399
|
+
if (!output) {
|
|
400
|
+
console.warn('[Wiki] Router LLM returned empty after retry.');
|
|
401
|
+
return ''
|
|
402
|
+
}
|
|
362
403
|
|
|
363
404
|
const selectedFilesStr = output.trim();
|
|
364
405
|
if (selectedFilesStr === 'NONE' || !selectedFilesStr) {
|
|
@@ -382,7 +423,6 @@ function createWikiRouter(config) {
|
|
|
382
423
|
console.log(`[Wiki] LLM correctly routed to ${loadedCount} file(s): ${selectedFiles.join(', ')}`);
|
|
383
424
|
return relevantContext
|
|
384
425
|
}
|
|
385
|
-
|
|
386
426
|
} catch (err) {
|
|
387
427
|
console.error('[Wiki Context Error]', err);
|
|
388
428
|
}
|
|
@@ -392,10 +432,275 @@ function createWikiRouter(config) {
|
|
|
392
432
|
return { build, getContext }
|
|
393
433
|
}
|
|
394
434
|
|
|
435
|
+
/**
|
|
436
|
+
* SQLite adapter — 將 better-sqlite3(或相容 API)包裝成 Store 介面
|
|
437
|
+
*
|
|
438
|
+
* 用法:
|
|
439
|
+
* import { sqliteStore } from '@jungtz/wiki-router'
|
|
440
|
+
* const store = sqliteStore({ db, tenantId: 'hotel-001' })
|
|
441
|
+
*
|
|
442
|
+
* 不直接相依 better-sqlite3;只要傳入的 db 物件支援 prepare(sql) 與 exec(sql) 即可。
|
|
443
|
+
*/
|
|
444
|
+
|
|
445
|
+
const DEFAULT_FILES_TABLE = 'wiki_files';
|
|
446
|
+
const DEFAULT_MANIFESTS_TABLE = 'wiki_manifests';
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 建立 wiki_files / wiki_manifests 兩個表(CREATE TABLE IF NOT EXISTS,可重複呼叫)
|
|
450
|
+
* @param {*} db - better-sqlite3 相容實例
|
|
451
|
+
* @param {{ filesTable?: string, manifestsTable?: string }} [opts]
|
|
452
|
+
*/
|
|
453
|
+
function ensureWikiTables(db, opts = {}) {
|
|
454
|
+
const filesTable = opts.filesTable || DEFAULT_FILES_TABLE;
|
|
455
|
+
const manifestsTable = opts.manifestsTable || DEFAULT_MANIFESTS_TABLE;
|
|
456
|
+
db.exec(`
|
|
457
|
+
CREATE TABLE IF NOT EXISTS ${filesTable} (
|
|
458
|
+
tenant_id TEXT NOT NULL,
|
|
459
|
+
filename TEXT NOT NULL,
|
|
460
|
+
content TEXT NOT NULL,
|
|
461
|
+
updated_at TEXT NOT NULL,
|
|
462
|
+
PRIMARY KEY (tenant_id, filename)
|
|
463
|
+
);
|
|
464
|
+
CREATE TABLE IF NOT EXISTS ${manifestsTable} (
|
|
465
|
+
tenant_id TEXT PRIMARY KEY,
|
|
466
|
+
manifest TEXT NOT NULL,
|
|
467
|
+
updated_at TEXT NOT NULL
|
|
468
|
+
);
|
|
469
|
+
`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* 建立 SQLite-backed Store adapter
|
|
474
|
+
* @param {{ db: *, tenantId: string, filesTable?: string, manifestsTable?: string }} config
|
|
475
|
+
* @returns {import('../types').Store}
|
|
476
|
+
*/
|
|
477
|
+
function sqliteStore(config) {
|
|
478
|
+
if (!config || !config.db) {
|
|
479
|
+
throw new Error('[wiki-router] sqliteStore: db is required')
|
|
480
|
+
}
|
|
481
|
+
if (!config.tenantId) {
|
|
482
|
+
throw new Error('[wiki-router] sqliteStore: tenantId is required')
|
|
483
|
+
}
|
|
484
|
+
const { db, tenantId } = config;
|
|
485
|
+
const filesTable = config.filesTable || DEFAULT_FILES_TABLE;
|
|
486
|
+
const manifestsTable = config.manifestsTable || DEFAULT_MANIFESTS_TABLE;
|
|
487
|
+
|
|
488
|
+
ensureWikiTables(db, { filesTable, manifestsTable });
|
|
489
|
+
|
|
490
|
+
const stmts = {
|
|
491
|
+
list: db.prepare(`SELECT filename FROM ${filesTable} WHERE tenant_id = ? ORDER BY filename`),
|
|
492
|
+
read: db.prepare(`SELECT content FROM ${filesTable} WHERE tenant_id = ? AND filename = ?`),
|
|
493
|
+
upsert: db.prepare(
|
|
494
|
+
`INSERT INTO ${filesTable} (tenant_id, filename, content, updated_at)
|
|
495
|
+
VALUES (?, ?, ?, ?)
|
|
496
|
+
ON CONFLICT(tenant_id, filename) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`
|
|
497
|
+
),
|
|
498
|
+
readManifest: db.prepare(`SELECT manifest FROM ${manifestsTable} WHERE tenant_id = ?`),
|
|
499
|
+
writeManifest: db.prepare(
|
|
500
|
+
`INSERT INTO ${manifestsTable} (tenant_id, manifest, updated_at)
|
|
501
|
+
VALUES (?, ?, ?)
|
|
502
|
+
ON CONFLICT(tenant_id) DO UPDATE SET manifest = excluded.manifest, updated_at = excluded.updated_at`
|
|
503
|
+
),
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
async list() {
|
|
508
|
+
return stmts.list.all(tenantId).map(r => r.filename)
|
|
509
|
+
},
|
|
510
|
+
async read(filename) {
|
|
511
|
+
const row = stmts.read.get(tenantId, filename);
|
|
512
|
+
return row ? row.content : null
|
|
513
|
+
},
|
|
514
|
+
async write(filename, content) {
|
|
515
|
+
stmts.upsert.run(tenantId, filename, content, new Date().toISOString());
|
|
516
|
+
},
|
|
517
|
+
async readManifest() {
|
|
518
|
+
const row = stmts.readManifest.get(tenantId);
|
|
519
|
+
if (!row) return null
|
|
520
|
+
try {
|
|
521
|
+
return JSON.parse(row.manifest)
|
|
522
|
+
} catch {
|
|
523
|
+
return null
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
async writeManifest(manifest) {
|
|
527
|
+
stmts.writeManifest.run(tenantId, JSON.stringify(manifest), new Date().toISOString());
|
|
528
|
+
},
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Multi-tenant orchestrator — 把多租戶 wiki 的常見管理邏輯封裝成單一 API
|
|
534
|
+
*
|
|
535
|
+
* 用法:
|
|
536
|
+
* import { createTenantManager, fsSource, sqliteStore } from '@jungtz/wiki-router'
|
|
537
|
+
*
|
|
538
|
+
* const manager = createTenantManager({
|
|
539
|
+
* router,
|
|
540
|
+
* source: tid => fsSource(`./knowledge/${tid}`),
|
|
541
|
+
* store: tid => sqliteStore({ db, tenantId: tid }),
|
|
542
|
+
* listTenants: () => fs.readdirSync('./knowledge'), // 選用:buildAll 才需要
|
|
543
|
+
* })
|
|
544
|
+
*
|
|
545
|
+
* await manager.buildAll()
|
|
546
|
+
* const ctx = await manager.getContext('問題', 'tenant-id')
|
|
547
|
+
*/
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
const DEFAULT_AUTO_INDEX_HEADER = '# 知識庫目錄';
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* @param {import('./types').TenantManagerConfig} config
|
|
554
|
+
* @returns {import('./types').TenantManager}
|
|
555
|
+
*/
|
|
556
|
+
function createTenantManager(config) {
|
|
557
|
+
if (!config || !config.router) {
|
|
558
|
+
throw new Error('[wiki-router] createTenantManager: router is required')
|
|
559
|
+
}
|
|
560
|
+
if (typeof config.source !== 'function') {
|
|
561
|
+
throw new Error('[wiki-router] createTenantManager: source must be a factory function (tenantId) => Source')
|
|
562
|
+
}
|
|
563
|
+
if (typeof config.store !== 'function') {
|
|
564
|
+
throw new Error('[wiki-router] createTenantManager: store must be a factory function (tenantId) => Store')
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const {
|
|
568
|
+
router,
|
|
569
|
+
source: sourceFactory,
|
|
570
|
+
store: storeFactory,
|
|
571
|
+
listTenants,
|
|
572
|
+
autoIndex = true,
|
|
573
|
+
autoIndexHeader = DEFAULT_AUTO_INDEX_HEADER,
|
|
574
|
+
logger = console,
|
|
575
|
+
...wikiConfig
|
|
576
|
+
} = config;
|
|
577
|
+
|
|
578
|
+
const wikiCache = new Map(); // tenantId → { wiki, source, store }
|
|
579
|
+
const buildPromises = new Map(); // tenantId → Promise<boolean>
|
|
580
|
+
const builtTenants = new Set(); // 已成功 build 過的租戶
|
|
581
|
+
|
|
582
|
+
function getWiki(tenantId) {
|
|
583
|
+
if (!wikiCache.has(tenantId)) {
|
|
584
|
+
const source = sourceFactory(tenantId);
|
|
585
|
+
const store = storeFactory(tenantId);
|
|
586
|
+
const wiki = createWikiRouter({ router, source, store, ...wikiConfig });
|
|
587
|
+
wikiCache.set(tenantId, { wiki, source, store });
|
|
588
|
+
}
|
|
589
|
+
return wikiCache.get(tenantId)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** 從 store 已有 .md 檔合成一份簡單 Index.md(LLM 沒生成時的 fallback) */
|
|
593
|
+
async function synthesizeIndex(store) {
|
|
594
|
+
const filenames = (await store.list()).filter(f => f.endsWith('.md')).sort();
|
|
595
|
+
const lines = [autoIndexHeader];
|
|
596
|
+
for (const f of filenames) {
|
|
597
|
+
const content = await store.read(f);
|
|
598
|
+
if (!content) continue
|
|
599
|
+
const titleMatch = content.match(/^#\s+(.+)/m);
|
|
600
|
+
const title = titleMatch ? titleMatch[1].trim() : f.replace('.md', '');
|
|
601
|
+
const bodyStart = content.indexOf('\n\n');
|
|
602
|
+
const snippet =
|
|
603
|
+
bodyStart > 0 ? content.slice(bodyStart).trim().replace(/\n/g, ' ').slice(0, 80) : '';
|
|
604
|
+
lines.push(`- [${title}](${f}):${snippet}`);
|
|
605
|
+
}
|
|
606
|
+
await store.write('Index.md', lines.join('\n'));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function build(tenantId, options = {}) {
|
|
610
|
+
const { force = false } = options;
|
|
611
|
+
if (buildPromises.has(tenantId)) return buildPromises.get(tenantId)
|
|
612
|
+
|
|
613
|
+
const promise = (async () => {
|
|
614
|
+
const { wiki, store } = getWiki(tenantId);
|
|
615
|
+
const ok = await wiki.build({ force });
|
|
616
|
+
if (!ok) return false
|
|
617
|
+
|
|
618
|
+
if (autoIndex) {
|
|
619
|
+
const hasIndex = await store.read('Index.md');
|
|
620
|
+
if (!hasIndex) {
|
|
621
|
+
await synthesizeIndex(store);
|
|
622
|
+
logger.log && logger.log(`[Wiki] Auto-generated Index.md for tenant: ${tenantId}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
builtTenants.add(tenantId);
|
|
627
|
+
return true
|
|
628
|
+
})().finally(() => {
|
|
629
|
+
buildPromises.delete(tenantId);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
buildPromises.set(tenantId, promise);
|
|
633
|
+
return promise
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function buildAll() {
|
|
637
|
+
if (typeof listTenants !== 'function') {
|
|
638
|
+
throw new Error('[wiki-router] buildAll: listTenants config is required')
|
|
639
|
+
}
|
|
640
|
+
const tenants = await listTenants();
|
|
641
|
+
if (!tenants || tenants.length === 0) {
|
|
642
|
+
logger.log && logger.log('[Wiki] No tenants to build');
|
|
643
|
+
return []
|
|
644
|
+
}
|
|
645
|
+
logger.log && logger.log(`[Wiki] Pre-building ${tenants.length} tenant(s): ${tenants.join(', ')}`);
|
|
646
|
+
const results = await Promise.allSettled(tenants.map(t => build(t)));
|
|
647
|
+
results.forEach((r, i) => {
|
|
648
|
+
if (r.status === 'rejected') {
|
|
649
|
+
logger.error && logger.error(
|
|
650
|
+
`[Wiki] Build failed for "${tenants[i]}":`,
|
|
651
|
+
r.reason && r.reason.message ? r.reason.message : r.reason
|
|
652
|
+
);
|
|
653
|
+
} else if (r.value === false) {
|
|
654
|
+
logger.warn && logger.warn(`[Wiki] Build returned false for "${tenants[i]}"`);
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
return results
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* 取得指定租戶的 wiki 上下文;尚未 build 完成時回傳空字串(不阻塞主流程)
|
|
662
|
+
*/
|
|
663
|
+
async function getContext(prompt, tenantId) {
|
|
664
|
+
if (!builtTenants.has(tenantId)) {
|
|
665
|
+
if (buildPromises.has(tenantId)) {
|
|
666
|
+
logger.log && logger.log(`[Wiki] Build in progress for "${tenantId}", returning empty context`);
|
|
667
|
+
} else {
|
|
668
|
+
logger.log && logger.log(`[Wiki] No wiki built for "${tenantId}", returning empty context`);
|
|
669
|
+
}
|
|
670
|
+
return ''
|
|
671
|
+
}
|
|
672
|
+
return getWiki(tenantId).wiki.getContext(prompt)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return {
|
|
676
|
+
build,
|
|
677
|
+
buildAll,
|
|
678
|
+
getContext,
|
|
679
|
+
isBuilt: tenantId => builtTenants.has(tenantId),
|
|
680
|
+
listBuilt: () => Array.from(builtTenants),
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* wiki-router - LLM Wiki 知識庫路由引擎
|
|
686
|
+
*
|
|
687
|
+
* 使用方式 (檔案系統):
|
|
688
|
+
* const wiki = createWikiRouter({ router, knowledgeDir, outputDir })
|
|
689
|
+
*
|
|
690
|
+
* 使用方式 (自訂 adapter,例如 API + DB):
|
|
691
|
+
* const wiki = createWikiRouter({ router, source, store })
|
|
692
|
+
*
|
|
693
|
+
* 多租戶 (推薦):
|
|
694
|
+
* const manager = createTenantManager({ router, source, store, listTenants })
|
|
695
|
+
*/
|
|
696
|
+
|
|
395
697
|
exports.MERGE_PROMPT = MERGE_PROMPT;
|
|
396
698
|
exports.ROUTER_PROMPT = ROUTER_PROMPT;
|
|
397
699
|
exports.SPLIT_PROMPT = SPLIT_PROMPT;
|
|
700
|
+
exports.createTenantManager = createTenantManager;
|
|
398
701
|
exports.createWikiRouter = createWikiRouter;
|
|
702
|
+
exports.ensureWikiTables = ensureWikiTables;
|
|
399
703
|
exports.fsSource = fsSource;
|
|
400
704
|
exports.fsStore = fsStore;
|
|
401
705
|
exports.parseWikiOutput = parseWikiOutput;
|
|
706
|
+
exports.sqliteStore = sqliteStore;
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* @typedef {Object} Source
|
|
13
13
|
* @property {() => Promise<string[]>} list - 列出所有 knowledge 來源 key(檔名 / DB id)
|
|
14
14
|
* @property {(key: string) => Promise<SourceEntry>} read - 讀取單一來源內容
|
|
15
|
+
* @property {() => Promise<Record<string, string>>} [getFingerprint] - 選用;回傳來源指紋(如 { key: hash })供 build 判斷是否需要重新生成
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -19,6 +20,8 @@
|
|
|
19
20
|
* @property {() => Promise<string[]>} list - 列出已生成的 wiki 檔名(含副檔名)
|
|
20
21
|
* @property {(filename: string) => Promise<string|null>} read - 讀取 wiki 檔;不存在回 null
|
|
21
22
|
* @property {(filename: string, content: string) => Promise<void>} write - 寫入 wiki 檔
|
|
23
|
+
* @property {() => Promise<Record<string, string>|null>} [readManifest] - 選用;讀取上次 build 的來源指紋;不存在回 null
|
|
24
|
+
* @property {(manifest: Record<string, string>) => Promise<void>} [writeManifest] - 選用;寫入本次 build 的來源指紋
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
27
|
/**
|
|
@@ -36,9 +39,14 @@
|
|
|
36
39
|
* @property {string} [routerPrompt] - 自訂 Router prompt 字串,未提供時使用內建預設
|
|
37
40
|
*/
|
|
38
41
|
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {Object} BuildOptions
|
|
44
|
+
* @property {boolean} [force=false] - 為 true 時跳過 fingerprint 比對,無條件重建
|
|
45
|
+
*/
|
|
46
|
+
|
|
39
47
|
/**
|
|
40
48
|
* @typedef {Object} WikiRouterInstance
|
|
41
|
-
* @property {() => Promise<boolean>} build - 建構或更新 Wiki 知識庫
|
|
49
|
+
* @property {(options?: BuildOptions) => Promise<boolean>} build - 建構或更新 Wiki 知識庫
|
|
42
50
|
* @property {(prompt: string) => Promise<string>} getContext - 根據問題取得相關 Wiki 上下文
|
|
43
51
|
*/
|
|
44
52
|
|
|
@@ -48,4 +56,38 @@
|
|
|
48
56
|
* @property {string} content - 檔案內容
|
|
49
57
|
*/
|
|
50
58
|
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {Object} SqliteStoreConfig
|
|
61
|
+
* @property {*} db - better-sqlite3 相容實例(需有 prepare/exec 方法)
|
|
62
|
+
* @property {string} tenantId - 多租戶識別字串
|
|
63
|
+
* @property {string} [filesTable='wiki_files'] - 自訂 files 表名
|
|
64
|
+
* @property {string} [manifestsTable='wiki_manifests'] - 自訂 manifests 表名
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {Object} TenantManagerConfig
|
|
69
|
+
* @property {import('@jungtz/ai-router').AIProviderRouter} router - AI Router 實例
|
|
70
|
+
* @property {(tenantId: string) => Source} source - Source factory(依 tenantId 建立)
|
|
71
|
+
* @property {(tenantId: string) => Store} store - Store factory(依 tenantId 建立)
|
|
72
|
+
* @property {() => string[] | Promise<string[]>} [listTenants] - 列出所有租戶;buildAll() 才需要
|
|
73
|
+
* @property {boolean} [autoIndex=true] - build 後若 store 沒有 Index.md,自動從現有 .md 合成一份目錄
|
|
74
|
+
* @property {string} [autoIndexHeader='# 知識庫目錄'] - autoIndex 的 header
|
|
75
|
+
* @property {{log?: Function, warn?: Function, error?: Function}} [logger=console] - 自訂 logger
|
|
76
|
+
* @property {string} [modelId] - 同 WikiRouterConfig.modelId
|
|
77
|
+
* @property {string} [routerModelId] - 同 WikiRouterConfig.routerModelId
|
|
78
|
+
* @property {number} [timeout] - 同 WikiRouterConfig.timeout
|
|
79
|
+
* @property {string} [splitPrompt] - 同 WikiRouterConfig.splitPrompt
|
|
80
|
+
* @property {string} [mergePrompt] - 同 WikiRouterConfig.mergePrompt
|
|
81
|
+
* @property {string} [routerPrompt] - 同 WikiRouterConfig.routerPrompt
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @typedef {Object} TenantManager
|
|
86
|
+
* @property {(tenantId: string, options?: BuildOptions) => Promise<boolean>} build - 為單一租戶建構 wiki
|
|
87
|
+
* @property {() => Promise<PromiseSettledResult<boolean>[]>} buildAll - 並行建構所有租戶(需設定 listTenants)
|
|
88
|
+
* @property {(prompt: string, tenantId: string) => Promise<string>} getContext - 取得指定租戶的 wiki 上下文
|
|
89
|
+
* @property {(tenantId: string) => boolean} isBuilt - 該租戶是否已成功 build 過
|
|
90
|
+
* @property {() => string[]} listBuilt - 已成功 build 過的租戶清單
|
|
91
|
+
*/
|
|
92
|
+
|
|
51
93
|
export {}
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* LLM Wiki 提示詞 — 從 src/prompts/*.md 檔案載入
|
|
@@ -95,6 +96,8 @@ function parseWikiOutput(output) {
|
|
|
95
96
|
*/
|
|
96
97
|
|
|
97
98
|
|
|
99
|
+
const MANIFEST_FILE = '.manifest.json';
|
|
100
|
+
|
|
98
101
|
/**
|
|
99
102
|
* 建立讀取本地檔案的 Source adapter
|
|
100
103
|
* @param {string} dir - knowledge 來源目錄
|
|
@@ -102,19 +105,30 @@ function parseWikiOutput(output) {
|
|
|
102
105
|
*/
|
|
103
106
|
function fsSource(dir) {
|
|
104
107
|
const resolved = path.resolve(dir);
|
|
108
|
+
async function list() {
|
|
109
|
+
if (!fs.existsSync(resolved)) return []
|
|
110
|
+
return fs
|
|
111
|
+
.readdirSync(resolved)
|
|
112
|
+
.filter(f => f.endsWith('.json') || f.endsWith('.md'))
|
|
113
|
+
.sort()
|
|
114
|
+
}
|
|
115
|
+
async function read(key) {
|
|
116
|
+
const filePath = path.join(resolved, key);
|
|
117
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
118
|
+
const type = key.endsWith('.json') ? 'json' : 'markdown';
|
|
119
|
+
return { type, content }
|
|
120
|
+
}
|
|
105
121
|
return {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const type = key.endsWith('.json') ? 'json' : 'markdown';
|
|
117
|
-
return { type, content }
|
|
122
|
+
list,
|
|
123
|
+
read,
|
|
124
|
+
async getFingerprint() {
|
|
125
|
+
const keys = await list();
|
|
126
|
+
const fp = {};
|
|
127
|
+
for (const key of keys) {
|
|
128
|
+
const { content } = await read(key);
|
|
129
|
+
fp[key] = crypto.createHash('sha256').update(content).digest('hex');
|
|
130
|
+
}
|
|
131
|
+
return fp
|
|
118
132
|
},
|
|
119
133
|
}
|
|
120
134
|
}
|
|
@@ -143,17 +157,29 @@ function fsStore(dir) {
|
|
|
143
157
|
ensure();
|
|
144
158
|
fs.writeFileSync(path.join(resolved, filename), content);
|
|
145
159
|
},
|
|
160
|
+
async readManifest() {
|
|
161
|
+
const filePath = path.join(resolved, MANIFEST_FILE);
|
|
162
|
+
if (!fs.existsSync(filePath)) return null
|
|
163
|
+
try {
|
|
164
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
|
165
|
+
} catch {
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
async writeManifest(manifest) {
|
|
170
|
+
ensure();
|
|
171
|
+
fs.writeFileSync(
|
|
172
|
+
path.join(resolved, MANIFEST_FILE),
|
|
173
|
+
JSON.stringify(manifest, null, 2),
|
|
174
|
+
'utf-8'
|
|
175
|
+
);
|
|
176
|
+
},
|
|
146
177
|
}
|
|
147
178
|
}
|
|
148
179
|
|
|
149
180
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* 使用方式 (檔案系統):
|
|
153
|
-
* const wiki = createWikiRouter({ router, knowledgeDir, outputDir })
|
|
154
|
-
*
|
|
155
|
-
* 使用方式 (自訂 adapter,例如 API + DB):
|
|
156
|
-
* const wiki = createWikiRouter({ router, source, store })
|
|
181
|
+
* createWikiRouter — 單一 wiki 實例工廠
|
|
182
|
+
* 已從 index.js 拆出,避免 tenant.js → index.js 的循環依賴。
|
|
157
183
|
*/
|
|
158
184
|
|
|
159
185
|
|
|
@@ -194,10 +220,6 @@ function createWikiRouter(config) {
|
|
|
194
220
|
|
|
195
221
|
let modelsFetched = false;
|
|
196
222
|
|
|
197
|
-
/**
|
|
198
|
-
* 預先獲取本地模型清單(仿照 models.js 的 getModelsList 邏輯)
|
|
199
|
-
* 確保 switchModel 能識別動態發現的 local 模型
|
|
200
|
-
*/
|
|
201
223
|
async function ensureModels() {
|
|
202
224
|
if (modelsFetched) return
|
|
203
225
|
try {
|
|
@@ -210,7 +232,7 @@ function createWikiRouter(config) {
|
|
|
210
232
|
if (pModels.length > 0) {
|
|
211
233
|
router.config.providers[pName].models = pModels.map(m => ({
|
|
212
234
|
model: m.model,
|
|
213
|
-
description: m.description
|
|
235
|
+
description: m.description,
|
|
214
236
|
}));
|
|
215
237
|
}
|
|
216
238
|
});
|
|
@@ -220,13 +242,6 @@ function createWikiRouter(config) {
|
|
|
220
242
|
}
|
|
221
243
|
}
|
|
222
244
|
|
|
223
|
-
/**
|
|
224
|
-
* 向 LLM 發送對話並取得完整回應
|
|
225
|
-
* @param {{ role: string, content: string }[]} messages
|
|
226
|
-
* @param {string} [overrideModelId]
|
|
227
|
-
* @param {number} [overrideTimeout]
|
|
228
|
-
* @returns {Promise<string|null>}
|
|
229
|
-
*/
|
|
230
245
|
async function chat(messages, overrideModelId, overrideTimeout) {
|
|
231
246
|
const wikiModelId = overrideModelId || modelId || process.env.WIKI_MODEL || 'ollama-local/gemma4:31b';
|
|
232
247
|
const effectiveTimeout = overrideTimeout || timeout;
|
|
@@ -241,7 +256,6 @@ function createWikiRouter(config) {
|
|
|
241
256
|
modelName = parts.slice(1).join('/');
|
|
242
257
|
}
|
|
243
258
|
|
|
244
|
-
// 使用 AI Router 進行對話
|
|
245
259
|
if (router && provider) {
|
|
246
260
|
await ensureModels();
|
|
247
261
|
await router.switchModel(provider, modelName);
|
|
@@ -266,11 +280,16 @@ function createWikiRouter(config) {
|
|
|
266
280
|
}
|
|
267
281
|
}
|
|
268
282
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
283
|
+
function fingerprintsEqual(a, b) {
|
|
284
|
+
if (!a || !b) return false
|
|
285
|
+
const ak = Object.keys(a);
|
|
286
|
+
const bk = Object.keys(b);
|
|
287
|
+
if (ak.length !== bk.length) return false
|
|
288
|
+
return ak.every(k => a[k] === b[k])
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function build(options = {}) {
|
|
292
|
+
const { force = false } = options;
|
|
274
293
|
try {
|
|
275
294
|
const knowledgeKeys = await activeSource.list();
|
|
276
295
|
|
|
@@ -279,6 +298,24 @@ function createWikiRouter(config) {
|
|
|
279
298
|
return false
|
|
280
299
|
}
|
|
281
300
|
|
|
301
|
+
let currentFp = null;
|
|
302
|
+
const sourceSupportsFp = typeof activeSource.getFingerprint === 'function';
|
|
303
|
+
const storeSupportsManifest =
|
|
304
|
+
typeof activeStore.readManifest === 'function' &&
|
|
305
|
+
typeof activeStore.writeManifest === 'function';
|
|
306
|
+
|
|
307
|
+
if (sourceSupportsFp && storeSupportsManifest) {
|
|
308
|
+
currentFp = await activeSource.getFingerprint();
|
|
309
|
+
if (!force) {
|
|
310
|
+
const storedFp = await activeStore.readManifest();
|
|
311
|
+
const existingFiles = await activeStore.list();
|
|
312
|
+
if (existingFiles.length > 0 && fingerprintsEqual(currentFp, storedFp)) {
|
|
313
|
+
console.log('[Wiki] Source unchanged, skipping build (fingerprint match)');
|
|
314
|
+
return true
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
282
319
|
let totalFiles = 0;
|
|
283
320
|
|
|
284
321
|
for (const key of knowledgeKeys) {
|
|
@@ -320,6 +357,11 @@ function createWikiRouter(config) {
|
|
|
320
357
|
}
|
|
321
358
|
|
|
322
359
|
if (totalFiles === 0) return false
|
|
360
|
+
|
|
361
|
+
if (currentFp && storeSupportsManifest) {
|
|
362
|
+
await activeStore.writeManifest(currentFp);
|
|
363
|
+
}
|
|
364
|
+
|
|
323
365
|
return true
|
|
324
366
|
} catch (err) {
|
|
325
367
|
console.error('[Wiki Generation Error]', err);
|
|
@@ -327,11 +369,6 @@ function createWikiRouter(config) {
|
|
|
327
369
|
}
|
|
328
370
|
}
|
|
329
371
|
|
|
330
|
-
/**
|
|
331
|
-
* 根據使用者問題取得相關的 Wiki 上下文
|
|
332
|
-
* @param {string} prompt - 使用者問題
|
|
333
|
-
* @returns {Promise<string>}
|
|
334
|
-
*/
|
|
335
372
|
async function getContext(prompt) {
|
|
336
373
|
try {
|
|
337
374
|
let wikiFiles = await activeStore.list();
|
|
@@ -352,11 +389,15 @@ function createWikiRouter(config) {
|
|
|
352
389
|
.replace('{{indexContent}}', indexContent);
|
|
353
390
|
|
|
354
391
|
const routeModel = routerModelId || process.env.WIKI_ROUTER_MODEL || modelId || process.env.WIKI_MODEL;
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
392
|
+
let output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
|
|
393
|
+
if (!output || !output.trim()) {
|
|
394
|
+
console.warn('[Wiki] Router LLM returned empty, retrying once...');
|
|
395
|
+
output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
|
|
396
|
+
}
|
|
397
|
+
if (!output) {
|
|
398
|
+
console.warn('[Wiki] Router LLM returned empty after retry.');
|
|
399
|
+
return ''
|
|
400
|
+
}
|
|
360
401
|
|
|
361
402
|
const selectedFilesStr = output.trim();
|
|
362
403
|
if (selectedFilesStr === 'NONE' || !selectedFilesStr) {
|
|
@@ -380,7 +421,6 @@ function createWikiRouter(config) {
|
|
|
380
421
|
console.log(`[Wiki] LLM correctly routed to ${loadedCount} file(s): ${selectedFiles.join(', ')}`);
|
|
381
422
|
return relevantContext
|
|
382
423
|
}
|
|
383
|
-
|
|
384
424
|
} catch (err) {
|
|
385
425
|
console.error('[Wiki Context Error]', err);
|
|
386
426
|
}
|
|
@@ -390,4 +430,266 @@ function createWikiRouter(config) {
|
|
|
390
430
|
return { build, getContext }
|
|
391
431
|
}
|
|
392
432
|
|
|
393
|
-
|
|
433
|
+
/**
|
|
434
|
+
* SQLite adapter — 將 better-sqlite3(或相容 API)包裝成 Store 介面
|
|
435
|
+
*
|
|
436
|
+
* 用法:
|
|
437
|
+
* import { sqliteStore } from '@jungtz/wiki-router'
|
|
438
|
+
* const store = sqliteStore({ db, tenantId: 'hotel-001' })
|
|
439
|
+
*
|
|
440
|
+
* 不直接相依 better-sqlite3;只要傳入的 db 物件支援 prepare(sql) 與 exec(sql) 即可。
|
|
441
|
+
*/
|
|
442
|
+
|
|
443
|
+
const DEFAULT_FILES_TABLE = 'wiki_files';
|
|
444
|
+
const DEFAULT_MANIFESTS_TABLE = 'wiki_manifests';
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* 建立 wiki_files / wiki_manifests 兩個表(CREATE TABLE IF NOT EXISTS,可重複呼叫)
|
|
448
|
+
* @param {*} db - better-sqlite3 相容實例
|
|
449
|
+
* @param {{ filesTable?: string, manifestsTable?: string }} [opts]
|
|
450
|
+
*/
|
|
451
|
+
function ensureWikiTables(db, opts = {}) {
|
|
452
|
+
const filesTable = opts.filesTable || DEFAULT_FILES_TABLE;
|
|
453
|
+
const manifestsTable = opts.manifestsTable || DEFAULT_MANIFESTS_TABLE;
|
|
454
|
+
db.exec(`
|
|
455
|
+
CREATE TABLE IF NOT EXISTS ${filesTable} (
|
|
456
|
+
tenant_id TEXT NOT NULL,
|
|
457
|
+
filename TEXT NOT NULL,
|
|
458
|
+
content TEXT NOT NULL,
|
|
459
|
+
updated_at TEXT NOT NULL,
|
|
460
|
+
PRIMARY KEY (tenant_id, filename)
|
|
461
|
+
);
|
|
462
|
+
CREATE TABLE IF NOT EXISTS ${manifestsTable} (
|
|
463
|
+
tenant_id TEXT PRIMARY KEY,
|
|
464
|
+
manifest TEXT NOT NULL,
|
|
465
|
+
updated_at TEXT NOT NULL
|
|
466
|
+
);
|
|
467
|
+
`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* 建立 SQLite-backed Store adapter
|
|
472
|
+
* @param {{ db: *, tenantId: string, filesTable?: string, manifestsTable?: string }} config
|
|
473
|
+
* @returns {import('../types').Store}
|
|
474
|
+
*/
|
|
475
|
+
function sqliteStore(config) {
|
|
476
|
+
if (!config || !config.db) {
|
|
477
|
+
throw new Error('[wiki-router] sqliteStore: db is required')
|
|
478
|
+
}
|
|
479
|
+
if (!config.tenantId) {
|
|
480
|
+
throw new Error('[wiki-router] sqliteStore: tenantId is required')
|
|
481
|
+
}
|
|
482
|
+
const { db, tenantId } = config;
|
|
483
|
+
const filesTable = config.filesTable || DEFAULT_FILES_TABLE;
|
|
484
|
+
const manifestsTable = config.manifestsTable || DEFAULT_MANIFESTS_TABLE;
|
|
485
|
+
|
|
486
|
+
ensureWikiTables(db, { filesTable, manifestsTable });
|
|
487
|
+
|
|
488
|
+
const stmts = {
|
|
489
|
+
list: db.prepare(`SELECT filename FROM ${filesTable} WHERE tenant_id = ? ORDER BY filename`),
|
|
490
|
+
read: db.prepare(`SELECT content FROM ${filesTable} WHERE tenant_id = ? AND filename = ?`),
|
|
491
|
+
upsert: db.prepare(
|
|
492
|
+
`INSERT INTO ${filesTable} (tenant_id, filename, content, updated_at)
|
|
493
|
+
VALUES (?, ?, ?, ?)
|
|
494
|
+
ON CONFLICT(tenant_id, filename) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`
|
|
495
|
+
),
|
|
496
|
+
readManifest: db.prepare(`SELECT manifest FROM ${manifestsTable} WHERE tenant_id = ?`),
|
|
497
|
+
writeManifest: db.prepare(
|
|
498
|
+
`INSERT INTO ${manifestsTable} (tenant_id, manifest, updated_at)
|
|
499
|
+
VALUES (?, ?, ?)
|
|
500
|
+
ON CONFLICT(tenant_id) DO UPDATE SET manifest = excluded.manifest, updated_at = excluded.updated_at`
|
|
501
|
+
),
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
async list() {
|
|
506
|
+
return stmts.list.all(tenantId).map(r => r.filename)
|
|
507
|
+
},
|
|
508
|
+
async read(filename) {
|
|
509
|
+
const row = stmts.read.get(tenantId, filename);
|
|
510
|
+
return row ? row.content : null
|
|
511
|
+
},
|
|
512
|
+
async write(filename, content) {
|
|
513
|
+
stmts.upsert.run(tenantId, filename, content, new Date().toISOString());
|
|
514
|
+
},
|
|
515
|
+
async readManifest() {
|
|
516
|
+
const row = stmts.readManifest.get(tenantId);
|
|
517
|
+
if (!row) return null
|
|
518
|
+
try {
|
|
519
|
+
return JSON.parse(row.manifest)
|
|
520
|
+
} catch {
|
|
521
|
+
return null
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
async writeManifest(manifest) {
|
|
525
|
+
stmts.writeManifest.run(tenantId, JSON.stringify(manifest), new Date().toISOString());
|
|
526
|
+
},
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Multi-tenant orchestrator — 把多租戶 wiki 的常見管理邏輯封裝成單一 API
|
|
532
|
+
*
|
|
533
|
+
* 用法:
|
|
534
|
+
* import { createTenantManager, fsSource, sqliteStore } from '@jungtz/wiki-router'
|
|
535
|
+
*
|
|
536
|
+
* const manager = createTenantManager({
|
|
537
|
+
* router,
|
|
538
|
+
* source: tid => fsSource(`./knowledge/${tid}`),
|
|
539
|
+
* store: tid => sqliteStore({ db, tenantId: tid }),
|
|
540
|
+
* listTenants: () => fs.readdirSync('./knowledge'), // 選用:buildAll 才需要
|
|
541
|
+
* })
|
|
542
|
+
*
|
|
543
|
+
* await manager.buildAll()
|
|
544
|
+
* const ctx = await manager.getContext('問題', 'tenant-id')
|
|
545
|
+
*/
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
const DEFAULT_AUTO_INDEX_HEADER = '# 知識庫目錄';
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* @param {import('./types').TenantManagerConfig} config
|
|
552
|
+
* @returns {import('./types').TenantManager}
|
|
553
|
+
*/
|
|
554
|
+
function createTenantManager(config) {
|
|
555
|
+
if (!config || !config.router) {
|
|
556
|
+
throw new Error('[wiki-router] createTenantManager: router is required')
|
|
557
|
+
}
|
|
558
|
+
if (typeof config.source !== 'function') {
|
|
559
|
+
throw new Error('[wiki-router] createTenantManager: source must be a factory function (tenantId) => Source')
|
|
560
|
+
}
|
|
561
|
+
if (typeof config.store !== 'function') {
|
|
562
|
+
throw new Error('[wiki-router] createTenantManager: store must be a factory function (tenantId) => Store')
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const {
|
|
566
|
+
router,
|
|
567
|
+
source: sourceFactory,
|
|
568
|
+
store: storeFactory,
|
|
569
|
+
listTenants,
|
|
570
|
+
autoIndex = true,
|
|
571
|
+
autoIndexHeader = DEFAULT_AUTO_INDEX_HEADER,
|
|
572
|
+
logger = console,
|
|
573
|
+
...wikiConfig
|
|
574
|
+
} = config;
|
|
575
|
+
|
|
576
|
+
const wikiCache = new Map(); // tenantId → { wiki, source, store }
|
|
577
|
+
const buildPromises = new Map(); // tenantId → Promise<boolean>
|
|
578
|
+
const builtTenants = new Set(); // 已成功 build 過的租戶
|
|
579
|
+
|
|
580
|
+
function getWiki(tenantId) {
|
|
581
|
+
if (!wikiCache.has(tenantId)) {
|
|
582
|
+
const source = sourceFactory(tenantId);
|
|
583
|
+
const store = storeFactory(tenantId);
|
|
584
|
+
const wiki = createWikiRouter({ router, source, store, ...wikiConfig });
|
|
585
|
+
wikiCache.set(tenantId, { wiki, source, store });
|
|
586
|
+
}
|
|
587
|
+
return wikiCache.get(tenantId)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** 從 store 已有 .md 檔合成一份簡單 Index.md(LLM 沒生成時的 fallback) */
|
|
591
|
+
async function synthesizeIndex(store) {
|
|
592
|
+
const filenames = (await store.list()).filter(f => f.endsWith('.md')).sort();
|
|
593
|
+
const lines = [autoIndexHeader];
|
|
594
|
+
for (const f of filenames) {
|
|
595
|
+
const content = await store.read(f);
|
|
596
|
+
if (!content) continue
|
|
597
|
+
const titleMatch = content.match(/^#\s+(.+)/m);
|
|
598
|
+
const title = titleMatch ? titleMatch[1].trim() : f.replace('.md', '');
|
|
599
|
+
const bodyStart = content.indexOf('\n\n');
|
|
600
|
+
const snippet =
|
|
601
|
+
bodyStart > 0 ? content.slice(bodyStart).trim().replace(/\n/g, ' ').slice(0, 80) : '';
|
|
602
|
+
lines.push(`- [${title}](${f}):${snippet}`);
|
|
603
|
+
}
|
|
604
|
+
await store.write('Index.md', lines.join('\n'));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function build(tenantId, options = {}) {
|
|
608
|
+
const { force = false } = options;
|
|
609
|
+
if (buildPromises.has(tenantId)) return buildPromises.get(tenantId)
|
|
610
|
+
|
|
611
|
+
const promise = (async () => {
|
|
612
|
+
const { wiki, store } = getWiki(tenantId);
|
|
613
|
+
const ok = await wiki.build({ force });
|
|
614
|
+
if (!ok) return false
|
|
615
|
+
|
|
616
|
+
if (autoIndex) {
|
|
617
|
+
const hasIndex = await store.read('Index.md');
|
|
618
|
+
if (!hasIndex) {
|
|
619
|
+
await synthesizeIndex(store);
|
|
620
|
+
logger.log && logger.log(`[Wiki] Auto-generated Index.md for tenant: ${tenantId}`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
builtTenants.add(tenantId);
|
|
625
|
+
return true
|
|
626
|
+
})().finally(() => {
|
|
627
|
+
buildPromises.delete(tenantId);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
buildPromises.set(tenantId, promise);
|
|
631
|
+
return promise
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function buildAll() {
|
|
635
|
+
if (typeof listTenants !== 'function') {
|
|
636
|
+
throw new Error('[wiki-router] buildAll: listTenants config is required')
|
|
637
|
+
}
|
|
638
|
+
const tenants = await listTenants();
|
|
639
|
+
if (!tenants || tenants.length === 0) {
|
|
640
|
+
logger.log && logger.log('[Wiki] No tenants to build');
|
|
641
|
+
return []
|
|
642
|
+
}
|
|
643
|
+
logger.log && logger.log(`[Wiki] Pre-building ${tenants.length} tenant(s): ${tenants.join(', ')}`);
|
|
644
|
+
const results = await Promise.allSettled(tenants.map(t => build(t)));
|
|
645
|
+
results.forEach((r, i) => {
|
|
646
|
+
if (r.status === 'rejected') {
|
|
647
|
+
logger.error && logger.error(
|
|
648
|
+
`[Wiki] Build failed for "${tenants[i]}":`,
|
|
649
|
+
r.reason && r.reason.message ? r.reason.message : r.reason
|
|
650
|
+
);
|
|
651
|
+
} else if (r.value === false) {
|
|
652
|
+
logger.warn && logger.warn(`[Wiki] Build returned false for "${tenants[i]}"`);
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
return results
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* 取得指定租戶的 wiki 上下文;尚未 build 完成時回傳空字串(不阻塞主流程)
|
|
660
|
+
*/
|
|
661
|
+
async function getContext(prompt, tenantId) {
|
|
662
|
+
if (!builtTenants.has(tenantId)) {
|
|
663
|
+
if (buildPromises.has(tenantId)) {
|
|
664
|
+
logger.log && logger.log(`[Wiki] Build in progress for "${tenantId}", returning empty context`);
|
|
665
|
+
} else {
|
|
666
|
+
logger.log && logger.log(`[Wiki] No wiki built for "${tenantId}", returning empty context`);
|
|
667
|
+
}
|
|
668
|
+
return ''
|
|
669
|
+
}
|
|
670
|
+
return getWiki(tenantId).wiki.getContext(prompt)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
build,
|
|
675
|
+
buildAll,
|
|
676
|
+
getContext,
|
|
677
|
+
isBuilt: tenantId => builtTenants.has(tenantId),
|
|
678
|
+
listBuilt: () => Array.from(builtTenants),
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* wiki-router - LLM Wiki 知識庫路由引擎
|
|
684
|
+
*
|
|
685
|
+
* 使用方式 (檔案系統):
|
|
686
|
+
* const wiki = createWikiRouter({ router, knowledgeDir, outputDir })
|
|
687
|
+
*
|
|
688
|
+
* 使用方式 (自訂 adapter,例如 API + DB):
|
|
689
|
+
* const wiki = createWikiRouter({ router, source, store })
|
|
690
|
+
*
|
|
691
|
+
* 多租戶 (推薦):
|
|
692
|
+
* const manager = createTenantManager({ router, source, store, listTenants })
|
|
693
|
+
*/
|
|
694
|
+
|
|
695
|
+
export { MERGE_PROMPT, ROUTER_PROMPT, SPLIT_PROMPT, createTenantManager, createWikiRouter, ensureWikiTables, fsSource, fsStore, parseWikiOutput, sqliteStore };
|
package/package.json
CHANGED
package/src/types.d.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* @typedef {Object} Source
|
|
13
13
|
* @property {() => Promise<string[]>} list - 列出所有 knowledge 來源 key(檔名 / DB id)
|
|
14
14
|
* @property {(key: string) => Promise<SourceEntry>} read - 讀取單一來源內容
|
|
15
|
+
* @property {() => Promise<Record<string, string>>} [getFingerprint] - 選用;回傳來源指紋(如 { key: hash })供 build 判斷是否需要重新生成
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -19,6 +20,8 @@
|
|
|
19
20
|
* @property {() => Promise<string[]>} list - 列出已生成的 wiki 檔名(含副檔名)
|
|
20
21
|
* @property {(filename: string) => Promise<string|null>} read - 讀取 wiki 檔;不存在回 null
|
|
21
22
|
* @property {(filename: string, content: string) => Promise<void>} write - 寫入 wiki 檔
|
|
23
|
+
* @property {() => Promise<Record<string, string>|null>} [readManifest] - 選用;讀取上次 build 的來源指紋;不存在回 null
|
|
24
|
+
* @property {(manifest: Record<string, string>) => Promise<void>} [writeManifest] - 選用;寫入本次 build 的來源指紋
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
27
|
/**
|
|
@@ -36,9 +39,14 @@
|
|
|
36
39
|
* @property {string} [routerPrompt] - 自訂 Router prompt 字串,未提供時使用內建預設
|
|
37
40
|
*/
|
|
38
41
|
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {Object} BuildOptions
|
|
44
|
+
* @property {boolean} [force=false] - 為 true 時跳過 fingerprint 比對,無條件重建
|
|
45
|
+
*/
|
|
46
|
+
|
|
39
47
|
/**
|
|
40
48
|
* @typedef {Object} WikiRouterInstance
|
|
41
|
-
* @property {() => Promise<boolean>} build - 建構或更新 Wiki 知識庫
|
|
49
|
+
* @property {(options?: BuildOptions) => Promise<boolean>} build - 建構或更新 Wiki 知識庫
|
|
42
50
|
* @property {(prompt: string) => Promise<string>} getContext - 根據問題取得相關 Wiki 上下文
|
|
43
51
|
*/
|
|
44
52
|
|
|
@@ -48,4 +56,38 @@
|
|
|
48
56
|
* @property {string} content - 檔案內容
|
|
49
57
|
*/
|
|
50
58
|
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {Object} SqliteStoreConfig
|
|
61
|
+
* @property {*} db - better-sqlite3 相容實例(需有 prepare/exec 方法)
|
|
62
|
+
* @property {string} tenantId - 多租戶識別字串
|
|
63
|
+
* @property {string} [filesTable='wiki_files'] - 自訂 files 表名
|
|
64
|
+
* @property {string} [manifestsTable='wiki_manifests'] - 自訂 manifests 表名
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {Object} TenantManagerConfig
|
|
69
|
+
* @property {import('@jungtz/ai-router').AIProviderRouter} router - AI Router 實例
|
|
70
|
+
* @property {(tenantId: string) => Source} source - Source factory(依 tenantId 建立)
|
|
71
|
+
* @property {(tenantId: string) => Store} store - Store factory(依 tenantId 建立)
|
|
72
|
+
* @property {() => string[] | Promise<string[]>} [listTenants] - 列出所有租戶;buildAll() 才需要
|
|
73
|
+
* @property {boolean} [autoIndex=true] - build 後若 store 沒有 Index.md,自動從現有 .md 合成一份目錄
|
|
74
|
+
* @property {string} [autoIndexHeader='# 知識庫目錄'] - autoIndex 的 header
|
|
75
|
+
* @property {{log?: Function, warn?: Function, error?: Function}} [logger=console] - 自訂 logger
|
|
76
|
+
* @property {string} [modelId] - 同 WikiRouterConfig.modelId
|
|
77
|
+
* @property {string} [routerModelId] - 同 WikiRouterConfig.routerModelId
|
|
78
|
+
* @property {number} [timeout] - 同 WikiRouterConfig.timeout
|
|
79
|
+
* @property {string} [splitPrompt] - 同 WikiRouterConfig.splitPrompt
|
|
80
|
+
* @property {string} [mergePrompt] - 同 WikiRouterConfig.mergePrompt
|
|
81
|
+
* @property {string} [routerPrompt] - 同 WikiRouterConfig.routerPrompt
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @typedef {Object} TenantManager
|
|
86
|
+
* @property {(tenantId: string, options?: BuildOptions) => Promise<boolean>} build - 為單一租戶建構 wiki
|
|
87
|
+
* @property {() => Promise<PromiseSettledResult<boolean>[]>} buildAll - 並行建構所有租戶(需設定 listTenants)
|
|
88
|
+
* @property {(prompt: string, tenantId: string) => Promise<string>} getContext - 取得指定租戶的 wiki 上下文
|
|
89
|
+
* @property {(tenantId: string) => boolean} isBuilt - 該租戶是否已成功 build 過
|
|
90
|
+
* @property {() => string[]} listBuilt - 已成功 build 過的租戶清單
|
|
91
|
+
*/
|
|
92
|
+
|
|
51
93
|
export {}
|