@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 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,15 @@ 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
- */
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('[Wiki] Source unchanged, skipping build (fingerprint match)');
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) return false
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('[Wiki Generation Error]', err);
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
- * 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,15 @@ 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
- */
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('[Wiki] Source unchanged, skipping build (fingerprint match)');
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) return false
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('[Wiki Generation Error]', err);
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
- export { MERGE_PROMPT, ROUTER_PROMPT, SPLIT_PROMPT, createWikiRouter, fsSource, fsStore, parseWikiOutput };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jungtz/wiki-router",
3
- "version": "1.1.0",
3
+ "version": "1.3.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 {}