@jungtz/wiki-router 1.1.0 → 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 +71 -1
- package/dist/index.cjs +270 -49
- package/dist/index.d.ts +34 -0
- package/dist/index.mjs +268 -50
- package/package.json +1 -1
- package/src/types.d.ts +34 -0
package/README.md
CHANGED
|
@@ -177,17 +177,87 @@ store: {
|
|
|
177
177
|
### 內建 adapter
|
|
178
178
|
|
|
179
179
|
```js
|
|
180
|
-
import { fsSource, fsStore } from '@jungtz/wiki-router'
|
|
180
|
+
import { fsSource, fsStore, sqliteStore, ensureWikiTables } from '@jungtz/wiki-router'
|
|
181
181
|
|
|
182
|
+
// 檔案系統
|
|
182
183
|
createWikiRouter({
|
|
183
184
|
router,
|
|
184
185
|
source: fsSource('./knowledge'),
|
|
185
186
|
store: fsStore('./wiki-output'),
|
|
186
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
|
+
})
|
|
187
196
|
```
|
|
188
197
|
|
|
189
198
|
`knowledgeDir` / `outputDir` 簡寫即在內部分別包成 `fsSource` / `fsStore`。
|
|
190
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
|
+
|
|
191
261
|
## 子模組
|
|
192
262
|
|
|
193
263
|
### 解析器
|
package/dist/index.cjs
CHANGED
|
@@ -180,13 +180,8 @@ function fsStore(dir) {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* 使用方式 (檔案系統):
|
|
186
|
-
* const wiki = createWikiRouter({ router, knowledgeDir, outputDir })
|
|
187
|
-
*
|
|
188
|
-
* 使用方式 (自訂 adapter,例如 API + DB):
|
|
189
|
-
* const wiki = createWikiRouter({ router, source, store })
|
|
183
|
+
* createWikiRouter — 單一 wiki 實例工廠
|
|
184
|
+
* 已從 index.js 拆出,避免 tenant.js → index.js 的循環依賴。
|
|
190
185
|
*/
|
|
191
186
|
|
|
192
187
|
|
|
@@ -227,10 +222,6 @@ function createWikiRouter(config) {
|
|
|
227
222
|
|
|
228
223
|
let modelsFetched = false;
|
|
229
224
|
|
|
230
|
-
/**
|
|
231
|
-
* 預先獲取本地模型清單(仿照 models.js 的 getModelsList 邏輯)
|
|
232
|
-
* 確保 switchModel 能識別動態發現的 local 模型
|
|
233
|
-
*/
|
|
234
225
|
async function ensureModels() {
|
|
235
226
|
if (modelsFetched) return
|
|
236
227
|
try {
|
|
@@ -243,7 +234,7 @@ function createWikiRouter(config) {
|
|
|
243
234
|
if (pModels.length > 0) {
|
|
244
235
|
router.config.providers[pName].models = pModels.map(m => ({
|
|
245
236
|
model: m.model,
|
|
246
|
-
description: m.description
|
|
237
|
+
description: m.description,
|
|
247
238
|
}));
|
|
248
239
|
}
|
|
249
240
|
});
|
|
@@ -253,13 +244,6 @@ function createWikiRouter(config) {
|
|
|
253
244
|
}
|
|
254
245
|
}
|
|
255
246
|
|
|
256
|
-
/**
|
|
257
|
-
* 向 LLM 發送對話並取得完整回應
|
|
258
|
-
* @param {{ role: string, content: string }[]} messages
|
|
259
|
-
* @param {string} [overrideModelId]
|
|
260
|
-
* @param {number} [overrideTimeout]
|
|
261
|
-
* @returns {Promise<string|null>}
|
|
262
|
-
*/
|
|
263
247
|
async function chat(messages, overrideModelId, overrideTimeout) {
|
|
264
248
|
const wikiModelId = overrideModelId || modelId || process.env.WIKI_MODEL || 'ollama-local/gemma4:31b';
|
|
265
249
|
const effectiveTimeout = overrideTimeout || timeout;
|
|
@@ -274,7 +258,6 @@ function createWikiRouter(config) {
|
|
|
274
258
|
modelName = parts.slice(1).join('/');
|
|
275
259
|
}
|
|
276
260
|
|
|
277
|
-
// 使用 AI Router 進行對話
|
|
278
261
|
if (router && provider) {
|
|
279
262
|
await ensureModels();
|
|
280
263
|
await router.switchModel(provider, modelName);
|
|
@@ -299,12 +282,6 @@ function createWikiRouter(config) {
|
|
|
299
282
|
}
|
|
300
283
|
}
|
|
301
284
|
|
|
302
|
-
/**
|
|
303
|
-
* 比對兩個 fingerprint 字典是否完全一致
|
|
304
|
-
* @param {Record<string,string>|null|undefined} a
|
|
305
|
-
* @param {Record<string,string>|null|undefined} b
|
|
306
|
-
* @returns {boolean}
|
|
307
|
-
*/
|
|
308
285
|
function fingerprintsEqual(a, b) {
|
|
309
286
|
if (!a || !b) return false
|
|
310
287
|
const ak = Object.keys(a);
|
|
@@ -313,12 +290,6 @@ function createWikiRouter(config) {
|
|
|
313
290
|
return ak.every(k => a[k] === b[k])
|
|
314
291
|
}
|
|
315
292
|
|
|
316
|
-
/**
|
|
317
|
-
* 建構或更新 LLM Wiki 知識庫
|
|
318
|
-
* @param {{ force?: boolean }} [options]
|
|
319
|
-
* force=true 時跳過 fingerprint 比對,無條件重建
|
|
320
|
-
* @returns {Promise<boolean>}
|
|
321
|
-
*/
|
|
322
293
|
async function build(options = {}) {
|
|
323
294
|
const { force = false } = options;
|
|
324
295
|
try {
|
|
@@ -329,7 +300,6 @@ function createWikiRouter(config) {
|
|
|
329
300
|
return false
|
|
330
301
|
}
|
|
331
302
|
|
|
332
|
-
// Fingerprint cache: 若來源未變動且既有 wiki 存在,直接跳過 LLM
|
|
333
303
|
let currentFp = null;
|
|
334
304
|
const sourceSupportsFp = typeof activeSource.getFingerprint === 'function';
|
|
335
305
|
const storeSupportsManifest =
|
|
@@ -390,7 +360,6 @@ function createWikiRouter(config) {
|
|
|
390
360
|
|
|
391
361
|
if (totalFiles === 0) return false
|
|
392
362
|
|
|
393
|
-
// build 成功 → 寫入 manifest 供下次比對
|
|
394
363
|
if (currentFp && storeSupportsManifest) {
|
|
395
364
|
await activeStore.writeManifest(currentFp);
|
|
396
365
|
}
|
|
@@ -402,11 +371,6 @@ function createWikiRouter(config) {
|
|
|
402
371
|
}
|
|
403
372
|
}
|
|
404
373
|
|
|
405
|
-
/**
|
|
406
|
-
* 根據使用者問題取得相關的 Wiki 上下文
|
|
407
|
-
* @param {string} prompt - 使用者問題
|
|
408
|
-
* @returns {Promise<string>}
|
|
409
|
-
*/
|
|
410
374
|
async function getContext(prompt) {
|
|
411
375
|
try {
|
|
412
376
|
let wikiFiles = await activeStore.list();
|
|
@@ -427,17 +391,10 @@ function createWikiRouter(config) {
|
|
|
427
391
|
.replace('{{indexContent}}', indexContent);
|
|
428
392
|
|
|
429
393
|
const routeModel = routerModelId || process.env.WIKI_ROUTER_MODEL || modelId || process.env.WIKI_MODEL;
|
|
430
|
-
let output = await chat(
|
|
431
|
-
[{ role: 'user', content: selectionPrompt }],
|
|
432
|
-
routeModel
|
|
433
|
-
);
|
|
434
|
-
// Cloud LLM (e.g. Gemini) 偶爾會回空 stream,retry 一次
|
|
394
|
+
let output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
|
|
435
395
|
if (!output || !output.trim()) {
|
|
436
396
|
console.warn('[Wiki] Router LLM returned empty, retrying once...');
|
|
437
|
-
output = await chat(
|
|
438
|
-
[{ role: 'user', content: selectionPrompt }],
|
|
439
|
-
routeModel
|
|
440
|
-
);
|
|
397
|
+
output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
|
|
441
398
|
}
|
|
442
399
|
if (!output) {
|
|
443
400
|
console.warn('[Wiki] Router LLM returned empty after retry.');
|
|
@@ -466,7 +423,6 @@ function createWikiRouter(config) {
|
|
|
466
423
|
console.log(`[Wiki] LLM correctly routed to ${loadedCount} file(s): ${selectedFiles.join(', ')}`);
|
|
467
424
|
return relevantContext
|
|
468
425
|
}
|
|
469
|
-
|
|
470
426
|
} catch (err) {
|
|
471
427
|
console.error('[Wiki Context Error]', err);
|
|
472
428
|
}
|
|
@@ -476,10 +432,275 @@ function createWikiRouter(config) {
|
|
|
476
432
|
return { build, getContext }
|
|
477
433
|
}
|
|
478
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
|
+
|
|
479
697
|
exports.MERGE_PROMPT = MERGE_PROMPT;
|
|
480
698
|
exports.ROUTER_PROMPT = ROUTER_PROMPT;
|
|
481
699
|
exports.SPLIT_PROMPT = SPLIT_PROMPT;
|
|
700
|
+
exports.createTenantManager = createTenantManager;
|
|
482
701
|
exports.createWikiRouter = createWikiRouter;
|
|
702
|
+
exports.ensureWikiTables = ensureWikiTables;
|
|
483
703
|
exports.fsSource = fsSource;
|
|
484
704
|
exports.fsStore = fsStore;
|
|
485
705
|
exports.parseWikiOutput = parseWikiOutput;
|
|
706
|
+
exports.sqliteStore = sqliteStore;
|
package/dist/index.d.ts
CHANGED
|
@@ -56,4 +56,38 @@
|
|
|
56
56
|
* @property {string} content - 檔案內容
|
|
57
57
|
*/
|
|
58
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
|
+
|
|
59
93
|
export {}
|
package/dist/index.mjs
CHANGED
|
@@ -178,13 +178,8 @@ function fsStore(dir) {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* 使用方式 (檔案系統):
|
|
184
|
-
* const wiki = createWikiRouter({ router, knowledgeDir, outputDir })
|
|
185
|
-
*
|
|
186
|
-
* 使用方式 (自訂 adapter,例如 API + DB):
|
|
187
|
-
* const wiki = createWikiRouter({ router, source, store })
|
|
181
|
+
* createWikiRouter — 單一 wiki 實例工廠
|
|
182
|
+
* 已從 index.js 拆出,避免 tenant.js → index.js 的循環依賴。
|
|
188
183
|
*/
|
|
189
184
|
|
|
190
185
|
|
|
@@ -225,10 +220,6 @@ function createWikiRouter(config) {
|
|
|
225
220
|
|
|
226
221
|
let modelsFetched = false;
|
|
227
222
|
|
|
228
|
-
/**
|
|
229
|
-
* 預先獲取本地模型清單(仿照 models.js 的 getModelsList 邏輯)
|
|
230
|
-
* 確保 switchModel 能識別動態發現的 local 模型
|
|
231
|
-
*/
|
|
232
223
|
async function ensureModels() {
|
|
233
224
|
if (modelsFetched) return
|
|
234
225
|
try {
|
|
@@ -241,7 +232,7 @@ function createWikiRouter(config) {
|
|
|
241
232
|
if (pModels.length > 0) {
|
|
242
233
|
router.config.providers[pName].models = pModels.map(m => ({
|
|
243
234
|
model: m.model,
|
|
244
|
-
description: m.description
|
|
235
|
+
description: m.description,
|
|
245
236
|
}));
|
|
246
237
|
}
|
|
247
238
|
});
|
|
@@ -251,13 +242,6 @@ function createWikiRouter(config) {
|
|
|
251
242
|
}
|
|
252
243
|
}
|
|
253
244
|
|
|
254
|
-
/**
|
|
255
|
-
* 向 LLM 發送對話並取得完整回應
|
|
256
|
-
* @param {{ role: string, content: string }[]} messages
|
|
257
|
-
* @param {string} [overrideModelId]
|
|
258
|
-
* @param {number} [overrideTimeout]
|
|
259
|
-
* @returns {Promise<string|null>}
|
|
260
|
-
*/
|
|
261
245
|
async function chat(messages, overrideModelId, overrideTimeout) {
|
|
262
246
|
const wikiModelId = overrideModelId || modelId || process.env.WIKI_MODEL || 'ollama-local/gemma4:31b';
|
|
263
247
|
const effectiveTimeout = overrideTimeout || timeout;
|
|
@@ -272,7 +256,6 @@ function createWikiRouter(config) {
|
|
|
272
256
|
modelName = parts.slice(1).join('/');
|
|
273
257
|
}
|
|
274
258
|
|
|
275
|
-
// 使用 AI Router 進行對話
|
|
276
259
|
if (router && provider) {
|
|
277
260
|
await ensureModels();
|
|
278
261
|
await router.switchModel(provider, modelName);
|
|
@@ -297,12 +280,6 @@ function createWikiRouter(config) {
|
|
|
297
280
|
}
|
|
298
281
|
}
|
|
299
282
|
|
|
300
|
-
/**
|
|
301
|
-
* 比對兩個 fingerprint 字典是否完全一致
|
|
302
|
-
* @param {Record<string,string>|null|undefined} a
|
|
303
|
-
* @param {Record<string,string>|null|undefined} b
|
|
304
|
-
* @returns {boolean}
|
|
305
|
-
*/
|
|
306
283
|
function fingerprintsEqual(a, b) {
|
|
307
284
|
if (!a || !b) return false
|
|
308
285
|
const ak = Object.keys(a);
|
|
@@ -311,12 +288,6 @@ function createWikiRouter(config) {
|
|
|
311
288
|
return ak.every(k => a[k] === b[k])
|
|
312
289
|
}
|
|
313
290
|
|
|
314
|
-
/**
|
|
315
|
-
* 建構或更新 LLM Wiki 知識庫
|
|
316
|
-
* @param {{ force?: boolean }} [options]
|
|
317
|
-
* force=true 時跳過 fingerprint 比對,無條件重建
|
|
318
|
-
* @returns {Promise<boolean>}
|
|
319
|
-
*/
|
|
320
291
|
async function build(options = {}) {
|
|
321
292
|
const { force = false } = options;
|
|
322
293
|
try {
|
|
@@ -327,7 +298,6 @@ function createWikiRouter(config) {
|
|
|
327
298
|
return false
|
|
328
299
|
}
|
|
329
300
|
|
|
330
|
-
// Fingerprint cache: 若來源未變動且既有 wiki 存在,直接跳過 LLM
|
|
331
301
|
let currentFp = null;
|
|
332
302
|
const sourceSupportsFp = typeof activeSource.getFingerprint === 'function';
|
|
333
303
|
const storeSupportsManifest =
|
|
@@ -388,7 +358,6 @@ function createWikiRouter(config) {
|
|
|
388
358
|
|
|
389
359
|
if (totalFiles === 0) return false
|
|
390
360
|
|
|
391
|
-
// build 成功 → 寫入 manifest 供下次比對
|
|
392
361
|
if (currentFp && storeSupportsManifest) {
|
|
393
362
|
await activeStore.writeManifest(currentFp);
|
|
394
363
|
}
|
|
@@ -400,11 +369,6 @@ function createWikiRouter(config) {
|
|
|
400
369
|
}
|
|
401
370
|
}
|
|
402
371
|
|
|
403
|
-
/**
|
|
404
|
-
* 根據使用者問題取得相關的 Wiki 上下文
|
|
405
|
-
* @param {string} prompt - 使用者問題
|
|
406
|
-
* @returns {Promise<string>}
|
|
407
|
-
*/
|
|
408
372
|
async function getContext(prompt) {
|
|
409
373
|
try {
|
|
410
374
|
let wikiFiles = await activeStore.list();
|
|
@@ -425,17 +389,10 @@ function createWikiRouter(config) {
|
|
|
425
389
|
.replace('{{indexContent}}', indexContent);
|
|
426
390
|
|
|
427
391
|
const routeModel = routerModelId || process.env.WIKI_ROUTER_MODEL || modelId || process.env.WIKI_MODEL;
|
|
428
|
-
let output = await chat(
|
|
429
|
-
[{ role: 'user', content: selectionPrompt }],
|
|
430
|
-
routeModel
|
|
431
|
-
);
|
|
432
|
-
// Cloud LLM (e.g. Gemini) 偶爾會回空 stream,retry 一次
|
|
392
|
+
let output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
|
|
433
393
|
if (!output || !output.trim()) {
|
|
434
394
|
console.warn('[Wiki] Router LLM returned empty, retrying once...');
|
|
435
|
-
output = await chat(
|
|
436
|
-
[{ role: 'user', content: selectionPrompt }],
|
|
437
|
-
routeModel
|
|
438
|
-
);
|
|
395
|
+
output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
|
|
439
396
|
}
|
|
440
397
|
if (!output) {
|
|
441
398
|
console.warn('[Wiki] Router LLM returned empty after retry.');
|
|
@@ -464,7 +421,6 @@ function createWikiRouter(config) {
|
|
|
464
421
|
console.log(`[Wiki] LLM correctly routed to ${loadedCount} file(s): ${selectedFiles.join(', ')}`);
|
|
465
422
|
return relevantContext
|
|
466
423
|
}
|
|
467
|
-
|
|
468
424
|
} catch (err) {
|
|
469
425
|
console.error('[Wiki Context Error]', err);
|
|
470
426
|
}
|
|
@@ -474,4 +430,266 @@ function createWikiRouter(config) {
|
|
|
474
430
|
return { build, getContext }
|
|
475
431
|
}
|
|
476
432
|
|
|
477
|
-
|
|
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
|
@@ -56,4 +56,38 @@
|
|
|
56
56
|
* @property {string} content - 檔案內容
|
|
57
57
|
*/
|
|
58
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
|
+
|
|
59
93
|
export {}
|