@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 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
- * wiki-router - LLM Wiki 知識庫路由引擎
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
- * wiki-router - LLM Wiki 知識庫路由引擎
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
- export { MERGE_PROMPT, ROUTER_PROMPT, SPLIT_PROMPT, createWikiRouter, fsSource, fsStore, parseWikiOutput };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungtz/wiki-router",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "LLM Wiki 知識庫路由引擎 - 將結構化知識轉為 Markdown 維基,智慧路由查詢",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.mjs",
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 {}