@jungtz/wiki-router 1.1.0 → 1.3.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 +307 -53
- package/dist/index.d.ts +34 -0
- package/dist/index.mjs +305 -54
- 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,15 @@ function createWikiRouter(config) {
|
|
|
299
282
|
}
|
|
300
283
|
}
|
|
301
284
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
285
|
+
function formatElapsed(startMs) {
|
|
286
|
+
const ms = Date.now() - startMs;
|
|
287
|
+
if (ms < 1000) return `${ms}ms`
|
|
288
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`
|
|
289
|
+
const m = Math.floor(ms / 60000);
|
|
290
|
+
const s = ((ms % 60000) / 1000).toFixed(1);
|
|
291
|
+
return `${m}m${s}s`
|
|
292
|
+
}
|
|
293
|
+
|
|
308
294
|
function fingerprintsEqual(a, b) {
|
|
309
295
|
if (!a || !b) return false
|
|
310
296
|
const ak = Object.keys(a);
|
|
@@ -313,14 +299,9 @@ function createWikiRouter(config) {
|
|
|
313
299
|
return ak.every(k => a[k] === b[k])
|
|
314
300
|
}
|
|
315
301
|
|
|
316
|
-
/**
|
|
317
|
-
* 建構或更新 LLM Wiki 知識庫
|
|
318
|
-
* @param {{ force?: boolean }} [options]
|
|
319
|
-
* force=true 時跳過 fingerprint 比對,無條件重建
|
|
320
|
-
* @returns {Promise<boolean>}
|
|
321
|
-
*/
|
|
322
302
|
async function build(options = {}) {
|
|
323
303
|
const { force = false } = options;
|
|
304
|
+
const buildStart = Date.now();
|
|
324
305
|
try {
|
|
325
306
|
const knowledgeKeys = await activeSource.list();
|
|
326
307
|
|
|
@@ -329,7 +310,6 @@ function createWikiRouter(config) {
|
|
|
329
310
|
return false
|
|
330
311
|
}
|
|
331
312
|
|
|
332
|
-
// Fingerprint cache: 若來源未變動且既有 wiki 存在,直接跳過 LLM
|
|
333
313
|
let currentFp = null;
|
|
334
314
|
const sourceSupportsFp = typeof activeSource.getFingerprint === 'function';
|
|
335
315
|
const storeSupportsManifest =
|
|
@@ -342,7 +322,7 @@ function createWikiRouter(config) {
|
|
|
342
322
|
const storedFp = await activeStore.readManifest();
|
|
343
323
|
const existingFiles = await activeStore.list();
|
|
344
324
|
if (existingFiles.length > 0 && fingerprintsEqual(currentFp, storedFp)) {
|
|
345
|
-
console.log(
|
|
325
|
+
console.log(`[Wiki] Source unchanged, skipping build (fingerprint match) — ${formatElapsed(buildStart)}`);
|
|
346
326
|
return true
|
|
347
327
|
}
|
|
348
328
|
}
|
|
@@ -351,6 +331,7 @@ function createWikiRouter(config) {
|
|
|
351
331
|
let totalFiles = 0;
|
|
352
332
|
|
|
353
333
|
for (const key of knowledgeKeys) {
|
|
334
|
+
const keyStart = Date.now();
|
|
354
335
|
const existingFiles = await activeStore.list();
|
|
355
336
|
const isFirstTime = existingFiles.length === 0;
|
|
356
337
|
const { type: fileType, content: fileContent } = await activeSource.read(key);
|
|
@@ -371,7 +352,7 @@ function createWikiRouter(config) {
|
|
|
371
352
|
|
|
372
353
|
const output = await chat([{ role: 'user', content: finalPrompt }]);
|
|
373
354
|
if (!output) {
|
|
374
|
-
console.warn(`[Wiki] No output for: ${key}`);
|
|
355
|
+
console.warn(`[Wiki] No output for: ${key} — ${formatElapsed(keyStart)}`);
|
|
375
356
|
continue
|
|
376
357
|
}
|
|
377
358
|
|
|
@@ -386,27 +367,26 @@ function createWikiRouter(config) {
|
|
|
386
367
|
console.log(`[Wiki] ${isFirstTime ? 'Generated' : 'Updated'}: ${filename}`);
|
|
387
368
|
}
|
|
388
369
|
totalFiles += parsed.length;
|
|
370
|
+
console.log(`[Wiki] Done: ${key} — ${parsed.length} file(s), ${formatElapsed(keyStart)}`);
|
|
389
371
|
}
|
|
390
372
|
|
|
391
|
-
if (totalFiles === 0)
|
|
373
|
+
if (totalFiles === 0) {
|
|
374
|
+
console.warn(`[Wiki] Build produced no files — ${formatElapsed(buildStart)}`);
|
|
375
|
+
return false
|
|
376
|
+
}
|
|
392
377
|
|
|
393
|
-
// build 成功 → 寫入 manifest 供下次比對
|
|
394
378
|
if (currentFp && storeSupportsManifest) {
|
|
395
379
|
await activeStore.writeManifest(currentFp);
|
|
396
380
|
}
|
|
397
381
|
|
|
382
|
+
console.log(`[Wiki] Build complete — ${totalFiles} file(s), ${formatElapsed(buildStart)}`);
|
|
398
383
|
return true
|
|
399
384
|
} catch (err) {
|
|
400
|
-
console.error(
|
|
385
|
+
console.error(`[Wiki Generation Error] (after ${formatElapsed(buildStart)})`, err);
|
|
401
386
|
return false
|
|
402
387
|
}
|
|
403
388
|
}
|
|
404
389
|
|
|
405
|
-
/**
|
|
406
|
-
* 根據使用者問題取得相關的 Wiki 上下文
|
|
407
|
-
* @param {string} prompt - 使用者問題
|
|
408
|
-
* @returns {Promise<string>}
|
|
409
|
-
*/
|
|
410
390
|
async function getContext(prompt) {
|
|
411
391
|
try {
|
|
412
392
|
let wikiFiles = await activeStore.list();
|
|
@@ -427,17 +407,10 @@ function createWikiRouter(config) {
|
|
|
427
407
|
.replace('{{indexContent}}', indexContent);
|
|
428
408
|
|
|
429
409
|
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 一次
|
|
410
|
+
let output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
|
|
435
411
|
if (!output || !output.trim()) {
|
|
436
412
|
console.warn('[Wiki] Router LLM returned empty, retrying once...');
|
|
437
|
-
output = await chat(
|
|
438
|
-
[{ role: 'user', content: selectionPrompt }],
|
|
439
|
-
routeModel
|
|
440
|
-
);
|
|
413
|
+
output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
|
|
441
414
|
}
|
|
442
415
|
if (!output) {
|
|
443
416
|
console.warn('[Wiki] Router LLM returned empty after retry.');
|
|
@@ -466,7 +439,6 @@ function createWikiRouter(config) {
|
|
|
466
439
|
console.log(`[Wiki] LLM correctly routed to ${loadedCount} file(s): ${selectedFiles.join(', ')}`);
|
|
467
440
|
return relevantContext
|
|
468
441
|
}
|
|
469
|
-
|
|
470
442
|
} catch (err) {
|
|
471
443
|
console.error('[Wiki Context Error]', err);
|
|
472
444
|
}
|
|
@@ -476,10 +448,292 @@ function createWikiRouter(config) {
|
|
|
476
448
|
return { build, getContext }
|
|
477
449
|
}
|
|
478
450
|
|
|
451
|
+
/**
|
|
452
|
+
* SQLite adapter — 將 better-sqlite3(或相容 API)包裝成 Store 介面
|
|
453
|
+
*
|
|
454
|
+
* 用法:
|
|
455
|
+
* import { sqliteStore } from '@jungtz/wiki-router'
|
|
456
|
+
* const store = sqliteStore({ db, tenantId: 'hotel-001' })
|
|
457
|
+
*
|
|
458
|
+
* 不直接相依 better-sqlite3;只要傳入的 db 物件支援 prepare(sql) 與 exec(sql) 即可。
|
|
459
|
+
*/
|
|
460
|
+
|
|
461
|
+
const DEFAULT_FILES_TABLE = 'wiki_files';
|
|
462
|
+
const DEFAULT_MANIFESTS_TABLE = 'wiki_manifests';
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 建立 wiki_files / wiki_manifests 兩個表(CREATE TABLE IF NOT EXISTS,可重複呼叫)
|
|
466
|
+
* @param {*} db - better-sqlite3 相容實例
|
|
467
|
+
* @param {{ filesTable?: string, manifestsTable?: string }} [opts]
|
|
468
|
+
*/
|
|
469
|
+
function ensureWikiTables(db, opts = {}) {
|
|
470
|
+
const filesTable = opts.filesTable || DEFAULT_FILES_TABLE;
|
|
471
|
+
const manifestsTable = opts.manifestsTable || DEFAULT_MANIFESTS_TABLE;
|
|
472
|
+
db.exec(`
|
|
473
|
+
CREATE TABLE IF NOT EXISTS ${filesTable} (
|
|
474
|
+
tenant_id TEXT NOT NULL,
|
|
475
|
+
filename TEXT NOT NULL,
|
|
476
|
+
content TEXT NOT NULL,
|
|
477
|
+
updated_at TEXT NOT NULL,
|
|
478
|
+
PRIMARY KEY (tenant_id, filename)
|
|
479
|
+
);
|
|
480
|
+
CREATE TABLE IF NOT EXISTS ${manifestsTable} (
|
|
481
|
+
tenant_id TEXT PRIMARY KEY,
|
|
482
|
+
manifest TEXT NOT NULL,
|
|
483
|
+
updated_at TEXT NOT NULL
|
|
484
|
+
);
|
|
485
|
+
`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* 建立 SQLite-backed Store adapter
|
|
490
|
+
* @param {{ db: *, tenantId: string, filesTable?: string, manifestsTable?: string }} config
|
|
491
|
+
* @returns {import('../types').Store}
|
|
492
|
+
*/
|
|
493
|
+
function sqliteStore(config) {
|
|
494
|
+
if (!config || !config.db) {
|
|
495
|
+
throw new Error('[wiki-router] sqliteStore: db is required')
|
|
496
|
+
}
|
|
497
|
+
if (!config.tenantId) {
|
|
498
|
+
throw new Error('[wiki-router] sqliteStore: tenantId is required')
|
|
499
|
+
}
|
|
500
|
+
const { db, tenantId } = config;
|
|
501
|
+
const filesTable = config.filesTable || DEFAULT_FILES_TABLE;
|
|
502
|
+
const manifestsTable = config.manifestsTable || DEFAULT_MANIFESTS_TABLE;
|
|
503
|
+
|
|
504
|
+
ensureWikiTables(db, { filesTable, manifestsTable });
|
|
505
|
+
|
|
506
|
+
const stmts = {
|
|
507
|
+
list: db.prepare(`SELECT filename FROM ${filesTable} WHERE tenant_id = ? ORDER BY filename`),
|
|
508
|
+
read: db.prepare(`SELECT content FROM ${filesTable} WHERE tenant_id = ? AND filename = ?`),
|
|
509
|
+
upsert: db.prepare(
|
|
510
|
+
`INSERT INTO ${filesTable} (tenant_id, filename, content, updated_at)
|
|
511
|
+
VALUES (?, ?, ?, ?)
|
|
512
|
+
ON CONFLICT(tenant_id, filename) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`
|
|
513
|
+
),
|
|
514
|
+
readManifest: db.prepare(`SELECT manifest FROM ${manifestsTable} WHERE tenant_id = ?`),
|
|
515
|
+
writeManifest: db.prepare(
|
|
516
|
+
`INSERT INTO ${manifestsTable} (tenant_id, manifest, updated_at)
|
|
517
|
+
VALUES (?, ?, ?)
|
|
518
|
+
ON CONFLICT(tenant_id) DO UPDATE SET manifest = excluded.manifest, updated_at = excluded.updated_at`
|
|
519
|
+
),
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
async list() {
|
|
524
|
+
return stmts.list.all(tenantId).map(r => r.filename)
|
|
525
|
+
},
|
|
526
|
+
async read(filename) {
|
|
527
|
+
const row = stmts.read.get(tenantId, filename);
|
|
528
|
+
return row ? row.content : null
|
|
529
|
+
},
|
|
530
|
+
async write(filename, content) {
|
|
531
|
+
stmts.upsert.run(tenantId, filename, content, new Date().toISOString());
|
|
532
|
+
},
|
|
533
|
+
async readManifest() {
|
|
534
|
+
const row = stmts.readManifest.get(tenantId);
|
|
535
|
+
if (!row) return null
|
|
536
|
+
try {
|
|
537
|
+
return JSON.parse(row.manifest)
|
|
538
|
+
} catch {
|
|
539
|
+
return null
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
async writeManifest(manifest) {
|
|
543
|
+
stmts.writeManifest.run(tenantId, JSON.stringify(manifest), new Date().toISOString());
|
|
544
|
+
},
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Multi-tenant orchestrator — 把多租戶 wiki 的常見管理邏輯封裝成單一 API
|
|
550
|
+
*
|
|
551
|
+
* 用法:
|
|
552
|
+
* import { createTenantManager, fsSource, sqliteStore } from '@jungtz/wiki-router'
|
|
553
|
+
*
|
|
554
|
+
* const manager = createTenantManager({
|
|
555
|
+
* router,
|
|
556
|
+
* source: tid => fsSource(`./knowledge/${tid}`),
|
|
557
|
+
* store: tid => sqliteStore({ db, tenantId: tid }),
|
|
558
|
+
* listTenants: () => fs.readdirSync('./knowledge'), // 選用:buildAll 才需要
|
|
559
|
+
* })
|
|
560
|
+
*
|
|
561
|
+
* await manager.buildAll()
|
|
562
|
+
* const ctx = await manager.getContext('問題', 'tenant-id')
|
|
563
|
+
*/
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
const DEFAULT_AUTO_INDEX_HEADER = '# 知識庫目錄';
|
|
567
|
+
|
|
568
|
+
function formatElapsed(startMs) {
|
|
569
|
+
const ms = Date.now() - startMs;
|
|
570
|
+
if (ms < 1000) return `${ms}ms`
|
|
571
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`
|
|
572
|
+
const m = Math.floor(ms / 60000);
|
|
573
|
+
const s = ((ms % 60000) / 1000).toFixed(1);
|
|
574
|
+
return `${m}m${s}s`
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* @param {import('./types').TenantManagerConfig} config
|
|
579
|
+
* @returns {import('./types').TenantManager}
|
|
580
|
+
*/
|
|
581
|
+
function createTenantManager(config) {
|
|
582
|
+
if (!config || !config.router) {
|
|
583
|
+
throw new Error('[wiki-router] createTenantManager: router is required')
|
|
584
|
+
}
|
|
585
|
+
if (typeof config.source !== 'function') {
|
|
586
|
+
throw new Error('[wiki-router] createTenantManager: source must be a factory function (tenantId) => Source')
|
|
587
|
+
}
|
|
588
|
+
if (typeof config.store !== 'function') {
|
|
589
|
+
throw new Error('[wiki-router] createTenantManager: store must be a factory function (tenantId) => Store')
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const {
|
|
593
|
+
router,
|
|
594
|
+
source: sourceFactory,
|
|
595
|
+
store: storeFactory,
|
|
596
|
+
listTenants,
|
|
597
|
+
autoIndex = true,
|
|
598
|
+
autoIndexHeader = DEFAULT_AUTO_INDEX_HEADER,
|
|
599
|
+
logger = console,
|
|
600
|
+
...wikiConfig
|
|
601
|
+
} = config;
|
|
602
|
+
|
|
603
|
+
const wikiCache = new Map(); // tenantId → { wiki, source, store }
|
|
604
|
+
const buildPromises = new Map(); // tenantId → Promise<boolean>
|
|
605
|
+
const builtTenants = new Set(); // 已成功 build 過的租戶
|
|
606
|
+
|
|
607
|
+
function getWiki(tenantId) {
|
|
608
|
+
if (!wikiCache.has(tenantId)) {
|
|
609
|
+
const source = sourceFactory(tenantId);
|
|
610
|
+
const store = storeFactory(tenantId);
|
|
611
|
+
const wiki = createWikiRouter({ router, source, store, ...wikiConfig });
|
|
612
|
+
wikiCache.set(tenantId, { wiki, source, store });
|
|
613
|
+
}
|
|
614
|
+
return wikiCache.get(tenantId)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** 從 store 已有 .md 檔合成一份簡單 Index.md(LLM 沒生成時的 fallback) */
|
|
618
|
+
async function synthesizeIndex(store) {
|
|
619
|
+
const filenames = (await store.list()).filter(f => f.endsWith('.md')).sort();
|
|
620
|
+
const lines = [autoIndexHeader];
|
|
621
|
+
for (const f of filenames) {
|
|
622
|
+
const content = await store.read(f);
|
|
623
|
+
if (!content) continue
|
|
624
|
+
const titleMatch = content.match(/^#\s+(.+)/m);
|
|
625
|
+
const title = titleMatch ? titleMatch[1].trim() : f.replace('.md', '');
|
|
626
|
+
const bodyStart = content.indexOf('\n\n');
|
|
627
|
+
const snippet =
|
|
628
|
+
bodyStart > 0 ? content.slice(bodyStart).trim().replace(/\n/g, ' ').slice(0, 80) : '';
|
|
629
|
+
lines.push(`- [${title}](${f}):${snippet}`);
|
|
630
|
+
}
|
|
631
|
+
await store.write('Index.md', lines.join('\n'));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function build(tenantId, options = {}) {
|
|
635
|
+
const { force = false } = options;
|
|
636
|
+
if (buildPromises.has(tenantId)) return buildPromises.get(tenantId)
|
|
637
|
+
|
|
638
|
+
const tenantStart = Date.now();
|
|
639
|
+
const promise = (async () => {
|
|
640
|
+
const { wiki, store } = getWiki(tenantId);
|
|
641
|
+
const ok = await wiki.build({ force });
|
|
642
|
+
if (!ok) {
|
|
643
|
+
logger.warn && logger.warn(`[Wiki] Tenant "${tenantId}" build failed — ${formatElapsed(tenantStart)}`);
|
|
644
|
+
return false
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (autoIndex) {
|
|
648
|
+
const hasIndex = await store.read('Index.md');
|
|
649
|
+
if (!hasIndex) {
|
|
650
|
+
await synthesizeIndex(store);
|
|
651
|
+
logger.log && logger.log(`[Wiki] Auto-generated Index.md for tenant: ${tenantId}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
builtTenants.add(tenantId);
|
|
656
|
+
logger.log && logger.log(`[Wiki] Tenant "${tenantId}" ready — ${formatElapsed(tenantStart)}`);
|
|
657
|
+
return true
|
|
658
|
+
})().finally(() => {
|
|
659
|
+
buildPromises.delete(tenantId);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
buildPromises.set(tenantId, promise);
|
|
663
|
+
return promise
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function buildAll() {
|
|
667
|
+
if (typeof listTenants !== 'function') {
|
|
668
|
+
throw new Error('[wiki-router] buildAll: listTenants config is required')
|
|
669
|
+
}
|
|
670
|
+
const tenants = await listTenants();
|
|
671
|
+
if (!tenants || tenants.length === 0) {
|
|
672
|
+
logger.log && logger.log('[Wiki] No tenants to build');
|
|
673
|
+
return []
|
|
674
|
+
}
|
|
675
|
+
const allStart = Date.now();
|
|
676
|
+
logger.log && logger.log(`[Wiki] Pre-building ${tenants.length} tenant(s): ${tenants.join(', ')}`);
|
|
677
|
+
const results = await Promise.allSettled(tenants.map(t => build(t)));
|
|
678
|
+
const okCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length;
|
|
679
|
+
logger.log && logger.log(`[Wiki] buildAll done — ${okCount}/${tenants.length} ok, ${formatElapsed(allStart)}`);
|
|
680
|
+
results.forEach((r, i) => {
|
|
681
|
+
if (r.status === 'rejected') {
|
|
682
|
+
logger.error && logger.error(
|
|
683
|
+
`[Wiki] Build failed for "${tenants[i]}":`,
|
|
684
|
+
r.reason && r.reason.message ? r.reason.message : r.reason
|
|
685
|
+
);
|
|
686
|
+
} else if (r.value === false) {
|
|
687
|
+
logger.warn && logger.warn(`[Wiki] Build returned false for "${tenants[i]}"`);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
return results
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* 取得指定租戶的 wiki 上下文;尚未 build 完成時回傳空字串(不阻塞主流程)
|
|
695
|
+
*/
|
|
696
|
+
async function getContext(prompt, tenantId) {
|
|
697
|
+
if (!builtTenants.has(tenantId)) {
|
|
698
|
+
if (buildPromises.has(tenantId)) {
|
|
699
|
+
logger.log && logger.log(`[Wiki] Build in progress for "${tenantId}", returning empty context`);
|
|
700
|
+
} else {
|
|
701
|
+
logger.log && logger.log(`[Wiki] No wiki built for "${tenantId}", returning empty context`);
|
|
702
|
+
}
|
|
703
|
+
return ''
|
|
704
|
+
}
|
|
705
|
+
return getWiki(tenantId).wiki.getContext(prompt)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return {
|
|
709
|
+
build,
|
|
710
|
+
buildAll,
|
|
711
|
+
getContext,
|
|
712
|
+
isBuilt: tenantId => builtTenants.has(tenantId),
|
|
713
|
+
listBuilt: () => Array.from(builtTenants),
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* wiki-router - LLM Wiki 知識庫路由引擎
|
|
719
|
+
*
|
|
720
|
+
* 使用方式 (檔案系統):
|
|
721
|
+
* const wiki = createWikiRouter({ router, knowledgeDir, outputDir })
|
|
722
|
+
*
|
|
723
|
+
* 使用方式 (自訂 adapter,例如 API + DB):
|
|
724
|
+
* const wiki = createWikiRouter({ router, source, store })
|
|
725
|
+
*
|
|
726
|
+
* 多租戶 (推薦):
|
|
727
|
+
* const manager = createTenantManager({ router, source, store, listTenants })
|
|
728
|
+
*/
|
|
729
|
+
|
|
479
730
|
exports.MERGE_PROMPT = MERGE_PROMPT;
|
|
480
731
|
exports.ROUTER_PROMPT = ROUTER_PROMPT;
|
|
481
732
|
exports.SPLIT_PROMPT = SPLIT_PROMPT;
|
|
733
|
+
exports.createTenantManager = createTenantManager;
|
|
482
734
|
exports.createWikiRouter = createWikiRouter;
|
|
735
|
+
exports.ensureWikiTables = ensureWikiTables;
|
|
483
736
|
exports.fsSource = fsSource;
|
|
484
737
|
exports.fsStore = fsStore;
|
|
485
738
|
exports.parseWikiOutput = parseWikiOutput;
|
|
739
|
+
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,15 @@ function createWikiRouter(config) {
|
|
|
297
280
|
}
|
|
298
281
|
}
|
|
299
282
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
283
|
+
function formatElapsed(startMs) {
|
|
284
|
+
const ms = Date.now() - startMs;
|
|
285
|
+
if (ms < 1000) return `${ms}ms`
|
|
286
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`
|
|
287
|
+
const m = Math.floor(ms / 60000);
|
|
288
|
+
const s = ((ms % 60000) / 1000).toFixed(1);
|
|
289
|
+
return `${m}m${s}s`
|
|
290
|
+
}
|
|
291
|
+
|
|
306
292
|
function fingerprintsEqual(a, b) {
|
|
307
293
|
if (!a || !b) return false
|
|
308
294
|
const ak = Object.keys(a);
|
|
@@ -311,14 +297,9 @@ function createWikiRouter(config) {
|
|
|
311
297
|
return ak.every(k => a[k] === b[k])
|
|
312
298
|
}
|
|
313
299
|
|
|
314
|
-
/**
|
|
315
|
-
* 建構或更新 LLM Wiki 知識庫
|
|
316
|
-
* @param {{ force?: boolean }} [options]
|
|
317
|
-
* force=true 時跳過 fingerprint 比對,無條件重建
|
|
318
|
-
* @returns {Promise<boolean>}
|
|
319
|
-
*/
|
|
320
300
|
async function build(options = {}) {
|
|
321
301
|
const { force = false } = options;
|
|
302
|
+
const buildStart = Date.now();
|
|
322
303
|
try {
|
|
323
304
|
const knowledgeKeys = await activeSource.list();
|
|
324
305
|
|
|
@@ -327,7 +308,6 @@ function createWikiRouter(config) {
|
|
|
327
308
|
return false
|
|
328
309
|
}
|
|
329
310
|
|
|
330
|
-
// Fingerprint cache: 若來源未變動且既有 wiki 存在,直接跳過 LLM
|
|
331
311
|
let currentFp = null;
|
|
332
312
|
const sourceSupportsFp = typeof activeSource.getFingerprint === 'function';
|
|
333
313
|
const storeSupportsManifest =
|
|
@@ -340,7 +320,7 @@ function createWikiRouter(config) {
|
|
|
340
320
|
const storedFp = await activeStore.readManifest();
|
|
341
321
|
const existingFiles = await activeStore.list();
|
|
342
322
|
if (existingFiles.length > 0 && fingerprintsEqual(currentFp, storedFp)) {
|
|
343
|
-
console.log(
|
|
323
|
+
console.log(`[Wiki] Source unchanged, skipping build (fingerprint match) — ${formatElapsed(buildStart)}`);
|
|
344
324
|
return true
|
|
345
325
|
}
|
|
346
326
|
}
|
|
@@ -349,6 +329,7 @@ function createWikiRouter(config) {
|
|
|
349
329
|
let totalFiles = 0;
|
|
350
330
|
|
|
351
331
|
for (const key of knowledgeKeys) {
|
|
332
|
+
const keyStart = Date.now();
|
|
352
333
|
const existingFiles = await activeStore.list();
|
|
353
334
|
const isFirstTime = existingFiles.length === 0;
|
|
354
335
|
const { type: fileType, content: fileContent } = await activeSource.read(key);
|
|
@@ -369,7 +350,7 @@ function createWikiRouter(config) {
|
|
|
369
350
|
|
|
370
351
|
const output = await chat([{ role: 'user', content: finalPrompt }]);
|
|
371
352
|
if (!output) {
|
|
372
|
-
console.warn(`[Wiki] No output for: ${key}`);
|
|
353
|
+
console.warn(`[Wiki] No output for: ${key} — ${formatElapsed(keyStart)}`);
|
|
373
354
|
continue
|
|
374
355
|
}
|
|
375
356
|
|
|
@@ -384,27 +365,26 @@ function createWikiRouter(config) {
|
|
|
384
365
|
console.log(`[Wiki] ${isFirstTime ? 'Generated' : 'Updated'}: ${filename}`);
|
|
385
366
|
}
|
|
386
367
|
totalFiles += parsed.length;
|
|
368
|
+
console.log(`[Wiki] Done: ${key} — ${parsed.length} file(s), ${formatElapsed(keyStart)}`);
|
|
387
369
|
}
|
|
388
370
|
|
|
389
|
-
if (totalFiles === 0)
|
|
371
|
+
if (totalFiles === 0) {
|
|
372
|
+
console.warn(`[Wiki] Build produced no files — ${formatElapsed(buildStart)}`);
|
|
373
|
+
return false
|
|
374
|
+
}
|
|
390
375
|
|
|
391
|
-
// build 成功 → 寫入 manifest 供下次比對
|
|
392
376
|
if (currentFp && storeSupportsManifest) {
|
|
393
377
|
await activeStore.writeManifest(currentFp);
|
|
394
378
|
}
|
|
395
379
|
|
|
380
|
+
console.log(`[Wiki] Build complete — ${totalFiles} file(s), ${formatElapsed(buildStart)}`);
|
|
396
381
|
return true
|
|
397
382
|
} catch (err) {
|
|
398
|
-
console.error(
|
|
383
|
+
console.error(`[Wiki Generation Error] (after ${formatElapsed(buildStart)})`, err);
|
|
399
384
|
return false
|
|
400
385
|
}
|
|
401
386
|
}
|
|
402
387
|
|
|
403
|
-
/**
|
|
404
|
-
* 根據使用者問題取得相關的 Wiki 上下文
|
|
405
|
-
* @param {string} prompt - 使用者問題
|
|
406
|
-
* @returns {Promise<string>}
|
|
407
|
-
*/
|
|
408
388
|
async function getContext(prompt) {
|
|
409
389
|
try {
|
|
410
390
|
let wikiFiles = await activeStore.list();
|
|
@@ -425,17 +405,10 @@ function createWikiRouter(config) {
|
|
|
425
405
|
.replace('{{indexContent}}', indexContent);
|
|
426
406
|
|
|
427
407
|
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 一次
|
|
408
|
+
let output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
|
|
433
409
|
if (!output || !output.trim()) {
|
|
434
410
|
console.warn('[Wiki] Router LLM returned empty, retrying once...');
|
|
435
|
-
output = await chat(
|
|
436
|
-
[{ role: 'user', content: selectionPrompt }],
|
|
437
|
-
routeModel
|
|
438
|
-
);
|
|
411
|
+
output = await chat([{ role: 'user', content: selectionPrompt }], routeModel);
|
|
439
412
|
}
|
|
440
413
|
if (!output) {
|
|
441
414
|
console.warn('[Wiki] Router LLM returned empty after retry.');
|
|
@@ -464,7 +437,6 @@ function createWikiRouter(config) {
|
|
|
464
437
|
console.log(`[Wiki] LLM correctly routed to ${loadedCount} file(s): ${selectedFiles.join(', ')}`);
|
|
465
438
|
return relevantContext
|
|
466
439
|
}
|
|
467
|
-
|
|
468
440
|
} catch (err) {
|
|
469
441
|
console.error('[Wiki Context Error]', err);
|
|
470
442
|
}
|
|
@@ -474,4 +446,283 @@ function createWikiRouter(config) {
|
|
|
474
446
|
return { build, getContext }
|
|
475
447
|
}
|
|
476
448
|
|
|
477
|
-
|
|
449
|
+
/**
|
|
450
|
+
* SQLite adapter — 將 better-sqlite3(或相容 API)包裝成 Store 介面
|
|
451
|
+
*
|
|
452
|
+
* 用法:
|
|
453
|
+
* import { sqliteStore } from '@jungtz/wiki-router'
|
|
454
|
+
* const store = sqliteStore({ db, tenantId: 'hotel-001' })
|
|
455
|
+
*
|
|
456
|
+
* 不直接相依 better-sqlite3;只要傳入的 db 物件支援 prepare(sql) 與 exec(sql) 即可。
|
|
457
|
+
*/
|
|
458
|
+
|
|
459
|
+
const DEFAULT_FILES_TABLE = 'wiki_files';
|
|
460
|
+
const DEFAULT_MANIFESTS_TABLE = 'wiki_manifests';
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* 建立 wiki_files / wiki_manifests 兩個表(CREATE TABLE IF NOT EXISTS,可重複呼叫)
|
|
464
|
+
* @param {*} db - better-sqlite3 相容實例
|
|
465
|
+
* @param {{ filesTable?: string, manifestsTable?: string }} [opts]
|
|
466
|
+
*/
|
|
467
|
+
function ensureWikiTables(db, opts = {}) {
|
|
468
|
+
const filesTable = opts.filesTable || DEFAULT_FILES_TABLE;
|
|
469
|
+
const manifestsTable = opts.manifestsTable || DEFAULT_MANIFESTS_TABLE;
|
|
470
|
+
db.exec(`
|
|
471
|
+
CREATE TABLE IF NOT EXISTS ${filesTable} (
|
|
472
|
+
tenant_id TEXT NOT NULL,
|
|
473
|
+
filename TEXT NOT NULL,
|
|
474
|
+
content TEXT NOT NULL,
|
|
475
|
+
updated_at TEXT NOT NULL,
|
|
476
|
+
PRIMARY KEY (tenant_id, filename)
|
|
477
|
+
);
|
|
478
|
+
CREATE TABLE IF NOT EXISTS ${manifestsTable} (
|
|
479
|
+
tenant_id TEXT PRIMARY KEY,
|
|
480
|
+
manifest TEXT NOT NULL,
|
|
481
|
+
updated_at TEXT NOT NULL
|
|
482
|
+
);
|
|
483
|
+
`);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* 建立 SQLite-backed Store adapter
|
|
488
|
+
* @param {{ db: *, tenantId: string, filesTable?: string, manifestsTable?: string }} config
|
|
489
|
+
* @returns {import('../types').Store}
|
|
490
|
+
*/
|
|
491
|
+
function sqliteStore(config) {
|
|
492
|
+
if (!config || !config.db) {
|
|
493
|
+
throw new Error('[wiki-router] sqliteStore: db is required')
|
|
494
|
+
}
|
|
495
|
+
if (!config.tenantId) {
|
|
496
|
+
throw new Error('[wiki-router] sqliteStore: tenantId is required')
|
|
497
|
+
}
|
|
498
|
+
const { db, tenantId } = config;
|
|
499
|
+
const filesTable = config.filesTable || DEFAULT_FILES_TABLE;
|
|
500
|
+
const manifestsTable = config.manifestsTable || DEFAULT_MANIFESTS_TABLE;
|
|
501
|
+
|
|
502
|
+
ensureWikiTables(db, { filesTable, manifestsTable });
|
|
503
|
+
|
|
504
|
+
const stmts = {
|
|
505
|
+
list: db.prepare(`SELECT filename FROM ${filesTable} WHERE tenant_id = ? ORDER BY filename`),
|
|
506
|
+
read: db.prepare(`SELECT content FROM ${filesTable} WHERE tenant_id = ? AND filename = ?`),
|
|
507
|
+
upsert: db.prepare(
|
|
508
|
+
`INSERT INTO ${filesTable} (tenant_id, filename, content, updated_at)
|
|
509
|
+
VALUES (?, ?, ?, ?)
|
|
510
|
+
ON CONFLICT(tenant_id, filename) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`
|
|
511
|
+
),
|
|
512
|
+
readManifest: db.prepare(`SELECT manifest FROM ${manifestsTable} WHERE tenant_id = ?`),
|
|
513
|
+
writeManifest: db.prepare(
|
|
514
|
+
`INSERT INTO ${manifestsTable} (tenant_id, manifest, updated_at)
|
|
515
|
+
VALUES (?, ?, ?)
|
|
516
|
+
ON CONFLICT(tenant_id) DO UPDATE SET manifest = excluded.manifest, updated_at = excluded.updated_at`
|
|
517
|
+
),
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
async list() {
|
|
522
|
+
return stmts.list.all(tenantId).map(r => r.filename)
|
|
523
|
+
},
|
|
524
|
+
async read(filename) {
|
|
525
|
+
const row = stmts.read.get(tenantId, filename);
|
|
526
|
+
return row ? row.content : null
|
|
527
|
+
},
|
|
528
|
+
async write(filename, content) {
|
|
529
|
+
stmts.upsert.run(tenantId, filename, content, new Date().toISOString());
|
|
530
|
+
},
|
|
531
|
+
async readManifest() {
|
|
532
|
+
const row = stmts.readManifest.get(tenantId);
|
|
533
|
+
if (!row) return null
|
|
534
|
+
try {
|
|
535
|
+
return JSON.parse(row.manifest)
|
|
536
|
+
} catch {
|
|
537
|
+
return null
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
async writeManifest(manifest) {
|
|
541
|
+
stmts.writeManifest.run(tenantId, JSON.stringify(manifest), new Date().toISOString());
|
|
542
|
+
},
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Multi-tenant orchestrator — 把多租戶 wiki 的常見管理邏輯封裝成單一 API
|
|
548
|
+
*
|
|
549
|
+
* 用法:
|
|
550
|
+
* import { createTenantManager, fsSource, sqliteStore } from '@jungtz/wiki-router'
|
|
551
|
+
*
|
|
552
|
+
* const manager = createTenantManager({
|
|
553
|
+
* router,
|
|
554
|
+
* source: tid => fsSource(`./knowledge/${tid}`),
|
|
555
|
+
* store: tid => sqliteStore({ db, tenantId: tid }),
|
|
556
|
+
* listTenants: () => fs.readdirSync('./knowledge'), // 選用:buildAll 才需要
|
|
557
|
+
* })
|
|
558
|
+
*
|
|
559
|
+
* await manager.buildAll()
|
|
560
|
+
* const ctx = await manager.getContext('問題', 'tenant-id')
|
|
561
|
+
*/
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
const DEFAULT_AUTO_INDEX_HEADER = '# 知識庫目錄';
|
|
565
|
+
|
|
566
|
+
function formatElapsed(startMs) {
|
|
567
|
+
const ms = Date.now() - startMs;
|
|
568
|
+
if (ms < 1000) return `${ms}ms`
|
|
569
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`
|
|
570
|
+
const m = Math.floor(ms / 60000);
|
|
571
|
+
const s = ((ms % 60000) / 1000).toFixed(1);
|
|
572
|
+
return `${m}m${s}s`
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* @param {import('./types').TenantManagerConfig} config
|
|
577
|
+
* @returns {import('./types').TenantManager}
|
|
578
|
+
*/
|
|
579
|
+
function createTenantManager(config) {
|
|
580
|
+
if (!config || !config.router) {
|
|
581
|
+
throw new Error('[wiki-router] createTenantManager: router is required')
|
|
582
|
+
}
|
|
583
|
+
if (typeof config.source !== 'function') {
|
|
584
|
+
throw new Error('[wiki-router] createTenantManager: source must be a factory function (tenantId) => Source')
|
|
585
|
+
}
|
|
586
|
+
if (typeof config.store !== 'function') {
|
|
587
|
+
throw new Error('[wiki-router] createTenantManager: store must be a factory function (tenantId) => Store')
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const {
|
|
591
|
+
router,
|
|
592
|
+
source: sourceFactory,
|
|
593
|
+
store: storeFactory,
|
|
594
|
+
listTenants,
|
|
595
|
+
autoIndex = true,
|
|
596
|
+
autoIndexHeader = DEFAULT_AUTO_INDEX_HEADER,
|
|
597
|
+
logger = console,
|
|
598
|
+
...wikiConfig
|
|
599
|
+
} = config;
|
|
600
|
+
|
|
601
|
+
const wikiCache = new Map(); // tenantId → { wiki, source, store }
|
|
602
|
+
const buildPromises = new Map(); // tenantId → Promise<boolean>
|
|
603
|
+
const builtTenants = new Set(); // 已成功 build 過的租戶
|
|
604
|
+
|
|
605
|
+
function getWiki(tenantId) {
|
|
606
|
+
if (!wikiCache.has(tenantId)) {
|
|
607
|
+
const source = sourceFactory(tenantId);
|
|
608
|
+
const store = storeFactory(tenantId);
|
|
609
|
+
const wiki = createWikiRouter({ router, source, store, ...wikiConfig });
|
|
610
|
+
wikiCache.set(tenantId, { wiki, source, store });
|
|
611
|
+
}
|
|
612
|
+
return wikiCache.get(tenantId)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** 從 store 已有 .md 檔合成一份簡單 Index.md(LLM 沒生成時的 fallback) */
|
|
616
|
+
async function synthesizeIndex(store) {
|
|
617
|
+
const filenames = (await store.list()).filter(f => f.endsWith('.md')).sort();
|
|
618
|
+
const lines = [autoIndexHeader];
|
|
619
|
+
for (const f of filenames) {
|
|
620
|
+
const content = await store.read(f);
|
|
621
|
+
if (!content) continue
|
|
622
|
+
const titleMatch = content.match(/^#\s+(.+)/m);
|
|
623
|
+
const title = titleMatch ? titleMatch[1].trim() : f.replace('.md', '');
|
|
624
|
+
const bodyStart = content.indexOf('\n\n');
|
|
625
|
+
const snippet =
|
|
626
|
+
bodyStart > 0 ? content.slice(bodyStart).trim().replace(/\n/g, ' ').slice(0, 80) : '';
|
|
627
|
+
lines.push(`- [${title}](${f}):${snippet}`);
|
|
628
|
+
}
|
|
629
|
+
await store.write('Index.md', lines.join('\n'));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function build(tenantId, options = {}) {
|
|
633
|
+
const { force = false } = options;
|
|
634
|
+
if (buildPromises.has(tenantId)) return buildPromises.get(tenantId)
|
|
635
|
+
|
|
636
|
+
const tenantStart = Date.now();
|
|
637
|
+
const promise = (async () => {
|
|
638
|
+
const { wiki, store } = getWiki(tenantId);
|
|
639
|
+
const ok = await wiki.build({ force });
|
|
640
|
+
if (!ok) {
|
|
641
|
+
logger.warn && logger.warn(`[Wiki] Tenant "${tenantId}" build failed — ${formatElapsed(tenantStart)}`);
|
|
642
|
+
return false
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (autoIndex) {
|
|
646
|
+
const hasIndex = await store.read('Index.md');
|
|
647
|
+
if (!hasIndex) {
|
|
648
|
+
await synthesizeIndex(store);
|
|
649
|
+
logger.log && logger.log(`[Wiki] Auto-generated Index.md for tenant: ${tenantId}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
builtTenants.add(tenantId);
|
|
654
|
+
logger.log && logger.log(`[Wiki] Tenant "${tenantId}" ready — ${formatElapsed(tenantStart)}`);
|
|
655
|
+
return true
|
|
656
|
+
})().finally(() => {
|
|
657
|
+
buildPromises.delete(tenantId);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
buildPromises.set(tenantId, promise);
|
|
661
|
+
return promise
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function buildAll() {
|
|
665
|
+
if (typeof listTenants !== 'function') {
|
|
666
|
+
throw new Error('[wiki-router] buildAll: listTenants config is required')
|
|
667
|
+
}
|
|
668
|
+
const tenants = await listTenants();
|
|
669
|
+
if (!tenants || tenants.length === 0) {
|
|
670
|
+
logger.log && logger.log('[Wiki] No tenants to build');
|
|
671
|
+
return []
|
|
672
|
+
}
|
|
673
|
+
const allStart = Date.now();
|
|
674
|
+
logger.log && logger.log(`[Wiki] Pre-building ${tenants.length} tenant(s): ${tenants.join(', ')}`);
|
|
675
|
+
const results = await Promise.allSettled(tenants.map(t => build(t)));
|
|
676
|
+
const okCount = results.filter(r => r.status === 'fulfilled' && r.value === true).length;
|
|
677
|
+
logger.log && logger.log(`[Wiki] buildAll done — ${okCount}/${tenants.length} ok, ${formatElapsed(allStart)}`);
|
|
678
|
+
results.forEach((r, i) => {
|
|
679
|
+
if (r.status === 'rejected') {
|
|
680
|
+
logger.error && logger.error(
|
|
681
|
+
`[Wiki] Build failed for "${tenants[i]}":`,
|
|
682
|
+
r.reason && r.reason.message ? r.reason.message : r.reason
|
|
683
|
+
);
|
|
684
|
+
} else if (r.value === false) {
|
|
685
|
+
logger.warn && logger.warn(`[Wiki] Build returned false for "${tenants[i]}"`);
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
return results
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* 取得指定租戶的 wiki 上下文;尚未 build 完成時回傳空字串(不阻塞主流程)
|
|
693
|
+
*/
|
|
694
|
+
async function getContext(prompt, tenantId) {
|
|
695
|
+
if (!builtTenants.has(tenantId)) {
|
|
696
|
+
if (buildPromises.has(tenantId)) {
|
|
697
|
+
logger.log && logger.log(`[Wiki] Build in progress for "${tenantId}", returning empty context`);
|
|
698
|
+
} else {
|
|
699
|
+
logger.log && logger.log(`[Wiki] No wiki built for "${tenantId}", returning empty context`);
|
|
700
|
+
}
|
|
701
|
+
return ''
|
|
702
|
+
}
|
|
703
|
+
return getWiki(tenantId).wiki.getContext(prompt)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return {
|
|
707
|
+
build,
|
|
708
|
+
buildAll,
|
|
709
|
+
getContext,
|
|
710
|
+
isBuilt: tenantId => builtTenants.has(tenantId),
|
|
711
|
+
listBuilt: () => Array.from(builtTenants),
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* wiki-router - LLM Wiki 知識庫路由引擎
|
|
717
|
+
*
|
|
718
|
+
* 使用方式 (檔案系統):
|
|
719
|
+
* const wiki = createWikiRouter({ router, knowledgeDir, outputDir })
|
|
720
|
+
*
|
|
721
|
+
* 使用方式 (自訂 adapter,例如 API + DB):
|
|
722
|
+
* const wiki = createWikiRouter({ router, source, store })
|
|
723
|
+
*
|
|
724
|
+
* 多租戶 (推薦):
|
|
725
|
+
* const manager = createTenantManager({ router, source, store, listTenants })
|
|
726
|
+
*/
|
|
727
|
+
|
|
728
|
+
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 {}
|