mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 12:07:01 +08:00
test: 暂存代码
This commit is contained in:
@@ -8,7 +8,8 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
index: resolve('src/main/index.ts'),
|
index: resolve('src/main/index.ts'),
|
||||||
voiceRecognitionWorker: resolve('src/main/voice-pipeline/voice-recognition-worker.ts')
|
voiceRecognitionWorker: resolve('src/main/voice-pipeline/voice-recognition-worker.ts'),
|
||||||
|
knowledgeWorker: resolve('src/main/knowledge/knowledge-worker.ts')
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: '[name].js'
|
entryFileNames: '[name].js'
|
||||||
|
|||||||
@@ -34,7 +34,10 @@
|
|||||||
"test:unit": "vitest run --config vitest.unit.config.ts",
|
"test:unit": "vitest run --config vitest.unit.config.ts",
|
||||||
"test:component": "vitest run --config vitest.component.config.ts",
|
"test:component": "vitest run --config vitest.component.config.ts",
|
||||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
|
"benchmark:knowledge": "vitest run --config vitest.knowledge-benchmark.config.ts --reporter=verbose",
|
||||||
|
"benchmark:knowledge:capacity": "cross-env KNOWLEDGE_CAPACITY=1 vitest run --config vitest.knowledge-benchmark.config.ts --reporter=verbose",
|
||||||
"test:e2e:build": "electron-vite build",
|
"test:e2e:build": "electron-vite build",
|
||||||
|
"test:knowledge-worker": "pnpm test:e2e:build && node scripts/test-knowledge-worker.cjs",
|
||||||
"test:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual",
|
"test:e2e": "pnpm test:e2e:build && playwright test --grep-invert @visual",
|
||||||
"test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts",
|
"test:visual": "pnpm test:e2e:build && playwright test tests/e2e/visual.spec.ts",
|
||||||
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
|
"test:smoke": "node --test tests/smoke/native-environment.test.mjs",
|
||||||
@@ -59,6 +62,7 @@
|
|||||||
"@electron-toolkit/preload": "^3.0.2",
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@koromix/koffi-win32-x64": "3.1.0",
|
"@koromix/koffi-win32-x64": "3.1.0",
|
||||||
|
"@radix-ui/react-popover": "^1.1.23",
|
||||||
"@tanstack/react-virtual": "^3.14.6",
|
"@tanstack/react-virtual": "^3.14.6",
|
||||||
"archiver": "^8.0.0",
|
"archiver": "^8.0.0",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
|
|||||||
Generated
+481
-3
@@ -13,6 +13,7 @@ specifiers:
|
|||||||
'@electron-toolkit/utils': ^4.0.0
|
'@electron-toolkit/utils': ^4.0.0
|
||||||
'@koromix/koffi-win32-x64': 3.1.0
|
'@koromix/koffi-win32-x64': 3.1.0
|
||||||
'@playwright/test': ^1.62.1
|
'@playwright/test': ^1.62.1
|
||||||
|
'@radix-ui/react-popover': ^1.1.23
|
||||||
'@rollup/rollup-darwin-arm64': ^4.62.2
|
'@rollup/rollup-darwin-arm64': ^4.62.2
|
||||||
'@tanstack/react-virtual': ^3.14.6
|
'@tanstack/react-virtual': ^3.14.6
|
||||||
'@testing-library/dom': ^10.4.1
|
'@testing-library/dom': ^10.4.1
|
||||||
@@ -58,6 +59,7 @@ dependencies:
|
|||||||
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
|
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
|
||||||
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
|
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
|
||||||
'@koromix/koffi-win32-x64': 3.1.0
|
'@koromix/koffi-win32-x64': 3.1.0
|
||||||
|
'@radix-ui/react-popover': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
'@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq
|
'@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq
|
||||||
archiver: 8.0.0
|
archiver: 8.0.0
|
||||||
cross-env: 10.1.0
|
cross-env: 10.1.0
|
||||||
@@ -888,6 +890,34 @@ packages:
|
|||||||
optional: true
|
optional: true
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@floating-ui/core/1.8.0:
|
||||||
|
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/utils': 0.2.12
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@floating-ui/dom/1.8.0:
|
||||||
|
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/core': 1.8.0
|
||||||
|
'@floating-ui/utils': 0.2.12
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@floating-ui/react-dom/2.1.9_bokjwhiew3ov3ffvbmafuwoalq:
|
||||||
|
resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.8.0'
|
||||||
|
react-dom: '>=16.8.0'
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.8.0
|
||||||
|
react: 19.2.1
|
||||||
|
react-dom: 19.2.1_react@19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@floating-ui/utils/0.2.12:
|
||||||
|
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@gar/promisify/1.1.3:
|
/@gar/promisify/1.1.3:
|
||||||
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
|
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -1247,6 +1277,355 @@ packages:
|
|||||||
playwright: 1.62.1
|
playwright: 1.62.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@radix-ui/primitive/1.1.7:
|
||||||
|
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-arrow/1.1.15_eijghdl4n2x4hz6j4cg7ctgbuu:
|
||||||
|
resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-primitive': 2.1.10_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3_@types+react@19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
react-dom: 19.2.1_react@19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-compose-refs/1.1.5_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-context/1.2.2_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-dismissable-layer/1.1.19_eijghdl4n2x4hz6j4cg7ctgbuu:
|
||||||
|
resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.7
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.5_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/react-primitive': 2.1.10_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.4_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/react-use-effect-event': 0.0.5_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3_@types+react@19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
react-dom: 19.2.1_react@19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-focus-guards/1.1.6_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-focus-scope/1.1.16_eijghdl4n2x4hz6j4cg7ctgbuu:
|
||||||
|
resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.5_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/react-primitive': 2.1.10_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.4_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3_@types+react@19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
react-dom: 19.2.1_react@19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-id/1.1.4_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.4_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-popover/1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu:
|
||||||
|
resolution: {integrity: sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.7
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.5_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/react-context': 1.2.2_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.19_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
|
'@radix-ui/react-focus-guards': 1.1.6_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/react-focus-scope': 1.1.16_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
|
'@radix-ui/react-id': 1.1.4_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/react-popper': 1.3.7_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
|
'@radix-ui/react-portal': 1.1.17_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
|
'@radix-ui/react-presence': 1.1.10_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
|
'@radix-ui/react-primitive': 2.1.10_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
|
'@radix-ui/react-slot': 1.3.3_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.6_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3_@types+react@19.2.7
|
||||||
|
aria-hidden: 1.2.6
|
||||||
|
react: 19.2.1
|
||||||
|
react-dom: 19.2.1_react@19.2.1
|
||||||
|
react-remove-scroll: 2.7.2_2lt27s3vun674ldqoia6newrwa
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-popper/1.3.7_eijghdl4n2x4hz6j4cg7ctgbuu:
|
||||||
|
resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/react-dom': 2.1.9_bokjwhiew3ov3ffvbmafuwoalq
|
||||||
|
'@radix-ui/react-arrow': 1.1.15_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.5_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/react-context': 1.2.2_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/react-primitive': 2.1.10_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.4_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.4_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/react-use-rect': 1.1.4_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/react-use-size': 1.1.4_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/rect': 1.1.3
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3_@types+react@19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
react-dom: 19.2.1_react@19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-portal/1.1.17_eijghdl4n2x4hz6j4cg7ctgbuu:
|
||||||
|
resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-primitive': 2.1.10_eijghdl4n2x4hz6j4cg7ctgbuu
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.4_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3_@types+react@19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
react-dom: 19.2.1_react@19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-presence/1.1.10_eijghdl4n2x4hz6j4cg7ctgbuu:
|
||||||
|
resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.4_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3_@types+react@19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
react-dom: 19.2.1_react@19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-primitive/2.1.10_eijghdl4n2x4hz6j4cg7ctgbuu:
|
||||||
|
resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-slot': 1.3.3_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
'@types/react-dom': 19.2.3_@types+react@19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
react-dom: 19.2.1_react@19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-slot/1.3.3_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.5_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-use-callback-ref/1.1.4_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-use-controllable-state/1.2.6_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.7
|
||||||
|
'@radix-ui/react-use-effect-event': 0.0.5_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.4_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-use-effect-event/0.0.5_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.4_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-use-layout-effect/1.1.4_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-use-rect/1.1.4_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/rect': 1.1.3
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/react-use-size/1.1.4_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.4_2lt27s3vun674ldqoia6newrwa
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@radix-ui/rect/1.1.3:
|
||||||
|
resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@rolldown/pluginutils/1.0.0-beta.53:
|
/@rolldown/pluginutils/1.0.0-beta.53:
|
||||||
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
|
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
|
||||||
dev: true
|
dev: true
|
||||||
@@ -1655,13 +2034,11 @@ packages:
|
|||||||
'@types/react': ^19.2.0
|
'@types/react': ^19.2.0
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/react/19.2.7:
|
/@types/react/19.2.7:
|
||||||
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/readdir-glob/1.1.5:
|
/@types/readdir-glob/1.1.5:
|
||||||
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
|
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
|
||||||
@@ -2095,6 +2472,13 @@ packages:
|
|||||||
/argparse/2.0.1:
|
/argparse/2.0.1:
|
||||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
|
|
||||||
|
/aria-hidden/1.2.6:
|
||||||
|
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/aria-query/5.3.0:
|
/aria-query/5.3.0:
|
||||||
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
|
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2723,7 +3107,6 @@ packages:
|
|||||||
|
|
||||||
/csstype/3.2.3:
|
/csstype/3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/data-urls/7.0.0:
|
/data-urls/7.0.0:
|
||||||
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||||
@@ -2832,6 +3215,10 @@ packages:
|
|||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/detect-node-es/1.1.0:
|
||||||
|
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/dir-compare/4.2.0:
|
/dir-compare/4.2.0:
|
||||||
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
|
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3661,6 +4048,11 @@ packages:
|
|||||||
math-intrinsics: 1.1.0
|
math-intrinsics: 1.1.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/get-nonce/1.0.1:
|
||||||
|
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/get-proto/1.0.1:
|
/get-proto/1.0.1:
|
||||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -5118,6 +5510,57 @@ packages:
|
|||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/react-remove-scroll-bar/2.3.8_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
react-style-singleton: 2.2.3_2lt27s3vun674ldqoia6newrwa
|
||||||
|
tslib: 2.8.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/react-remove-scroll/2.7.2_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
react-remove-scroll-bar: 2.3.8_2lt27s3vun674ldqoia6newrwa
|
||||||
|
react-style-singleton: 2.2.3_2lt27s3vun674ldqoia6newrwa
|
||||||
|
tslib: 2.8.1
|
||||||
|
use-callback-ref: 1.3.3_2lt27s3vun674ldqoia6newrwa
|
||||||
|
use-sidecar: 1.1.3_2lt27s3vun674ldqoia6newrwa
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/react-style-singleton/2.2.3_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
get-nonce: 1.0.1
|
||||||
|
react: 19.2.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/react/19.2.1:
|
/react/19.2.1:
|
||||||
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
|
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -5912,6 +6355,10 @@ packages:
|
|||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/tslib/2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/type-check/0.4.0:
|
/type-check/0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -6053,6 +6500,37 @@ packages:
|
|||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/use-callback-ref/1.3.3_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
react: 19.2.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/use-sidecar/1.1.3_2lt27s3vun674ldqoia6newrwa:
|
||||||
|
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
'@types/react': 19.2.7
|
||||||
|
detect-node-es: 1.1.0
|
||||||
|
react: 19.2.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
/utf8-byte-length/1.0.5:
|
/utf8-byte-length/1.0.5:
|
||||||
resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
|
resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
const { fork } = require('node:child_process')
|
||||||
|
const { existsSync, mkdtempSync } = require('node:fs')
|
||||||
|
const { rm } = require('node:fs/promises')
|
||||||
|
const { tmpdir } = require('node:os')
|
||||||
|
const { join } = require('node:path')
|
||||||
|
const { randomUUID, createHash } = require('node:crypto')
|
||||||
|
|
||||||
|
const workerPath = join(__dirname, '..', 'out', 'main', 'knowledgeWorker.js')
|
||||||
|
if (!existsSync(workerPath)) throw new Error(`Knowledge worker build is missing: ${workerPath}`)
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'wxe-knowledge-worker-'))
|
||||||
|
const child = fork(workerPath, [], {
|
||||||
|
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||||
|
serialization: 'advanced',
|
||||||
|
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||||
|
})
|
||||||
|
const pending = new Map()
|
||||||
|
|
||||||
|
function request(type, payload) {
|
||||||
|
const requestId = randomUUID()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pending.set(requestId, { resolve, reject })
|
||||||
|
child.send({ version: 1, type, requestId, payload }, (error) => {
|
||||||
|
if (error) reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
child.on('message', (message) => {
|
||||||
|
if (!message || message.type === 'progress') return
|
||||||
|
const current = pending.get(message.requestId)
|
||||||
|
if (!current) return
|
||||||
|
pending.delete(message.requestId)
|
||||||
|
if (message.type === 'error') current.reject(new Error(message.error))
|
||||||
|
else current.resolve(message.payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
function fts(profileId) {
|
||||||
|
return {
|
||||||
|
profileId,
|
||||||
|
tokenizer: 'trigram',
|
||||||
|
contentMode: 'external',
|
||||||
|
detail: 'full',
|
||||||
|
columnsize: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function conversation(accountId, id) {
|
||||||
|
return {
|
||||||
|
conversationId: `conversation-${id}`,
|
||||||
|
completeSnapshot: true,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
conversationId: `conversation-${id}`,
|
||||||
|
messageId: `message-${id}`,
|
||||||
|
createTime: 1,
|
||||||
|
senderId: 'fixture-member',
|
||||||
|
senderName: '脱敏成员',
|
||||||
|
kind: 'text',
|
||||||
|
text: `脱敏索引内容 ${id}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountPath(accountId) {
|
||||||
|
const key = createHash('sha256')
|
||||||
|
.update(`knowledge-account-v1:${accountId}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 32)
|
||||||
|
return join(root, key, 'knowledge.sqlite')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const chunker = {
|
||||||
|
version: 'conversation-v1',
|
||||||
|
maxGapMs: 600000,
|
||||||
|
maxMessages: 12,
|
||||||
|
maxCharacters: 1200,
|
||||||
|
overlapMessages: 3
|
||||||
|
}
|
||||||
|
const accountA = 'worker-fixture-a'
|
||||||
|
const accountB = 'worker-fixture-b'
|
||||||
|
const first = await request('index', {
|
||||||
|
accountId: accountA,
|
||||||
|
databaseRoot: root,
|
||||||
|
conversations: [conversation(accountA, 'a')],
|
||||||
|
chunker,
|
||||||
|
fts: fts('worker-a')
|
||||||
|
})
|
||||||
|
await request('index', {
|
||||||
|
accountId: accountB,
|
||||||
|
databaseRoot: root,
|
||||||
|
conversations: [conversation(accountB, 'b')],
|
||||||
|
chunker,
|
||||||
|
fts: fts('worker-b')
|
||||||
|
})
|
||||||
|
if (
|
||||||
|
!first ||
|
||||||
|
first.cancelled ||
|
||||||
|
!existsSync(accountPath(accountA)) ||
|
||||||
|
!existsSync(accountPath(accountB))
|
||||||
|
) {
|
||||||
|
throw new Error('Knowledge worker did not create isolated derived databases')
|
||||||
|
}
|
||||||
|
const search = await request('search', {
|
||||||
|
accountId: accountA,
|
||||||
|
databaseRoot: root,
|
||||||
|
fts: fts('worker-a'),
|
||||||
|
text: '查询脱敏索引内容 a',
|
||||||
|
terms: ['脱敏索引内容', 'a'],
|
||||||
|
limit: 10
|
||||||
|
})
|
||||||
|
const evidence = search?.evidence?.[0]
|
||||||
|
if (
|
||||||
|
search?.state !== 'ready' ||
|
||||||
|
!evidence ||
|
||||||
|
evidence.messageId !== 'message-a' ||
|
||||||
|
evidence.conversationId !== 'conversation-a' ||
|
||||||
|
evidence.sender !== '脱敏成员' ||
|
||||||
|
typeof evidence.timestamp !== 'number'
|
||||||
|
) {
|
||||||
|
throw new Error('Knowledge worker search did not return message-level evidence')
|
||||||
|
}
|
||||||
|
await request('remove', { accountId: accountA, databaseRoot: root })
|
||||||
|
if (existsSync(accountPath(accountA)) || !existsSync(accountPath(accountB))) {
|
||||||
|
throw new Error('Knowledge worker removal crossed an account boundary')
|
||||||
|
}
|
||||||
|
const unavailable = await request('search', {
|
||||||
|
accountId: accountA,
|
||||||
|
databaseRoot: root,
|
||||||
|
fts: fts('worker-a'),
|
||||||
|
text: '查询脱敏索引内容 a',
|
||||||
|
terms: ['脱敏索引内容'],
|
||||||
|
limit: 10
|
||||||
|
})
|
||||||
|
if (unavailable?.state !== 'unavailable' || unavailable.evidence?.length) {
|
||||||
|
throw new Error('Knowledge worker did not report unavailable index after removal')
|
||||||
|
}
|
||||||
|
await request('close', {})
|
||||||
|
console.log('Knowledge worker integration check passed')
|
||||||
|
} finally {
|
||||||
|
child.kill()
|
||||||
|
await rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
+46
-3
@@ -104,6 +104,10 @@ import type { ExportRequest } from '../shared/export'
|
|||||||
import { discoverAccounts } from './services/account-discovery'
|
import { discoverAccounts } from './services/account-discovery'
|
||||||
import { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
import { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case'
|
||||||
import type { VoiceMessageReference } from '../shared/voice-recognition'
|
import type { VoiceMessageReference } from '../shared/voice-recognition'
|
||||||
|
import type { AiSearchPipelineRequest } from '../shared/ai-search'
|
||||||
|
import type { KnowledgeSearchIpcRequest, KnowledgeSearchIpcResult } from '../shared/knowledge'
|
||||||
|
import { KnowledgeSearchService } from './knowledge/knowledge-search-service'
|
||||||
|
import { AiSearchPipelineService } from './services/ai-search-pipeline-service'
|
||||||
|
|
||||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||||
@@ -112,6 +116,8 @@ installSafeConsole()
|
|||||||
|
|
||||||
let voiceService: VoiceService | null = null
|
let voiceService: VoiceService | null = null
|
||||||
let voiceRecognition: VoiceRecognitionUseCase | null = null
|
let voiceRecognition: VoiceRecognitionUseCase | null = null
|
||||||
|
let knowledgeSearchService: KnowledgeSearchService | null = null
|
||||||
|
let aiSearchPipelineService: AiSearchPipelineService | null = null
|
||||||
let imageDecryptService: ImageDecryptService | null = null
|
let imageDecryptService: ImageDecryptService | null = null
|
||||||
let stickerService: StickerService | null = null
|
let stickerService: StickerService | null = null
|
||||||
let videoAssetService: VideoAssetService | null = null
|
let videoAssetService: VideoAssetService | null = null
|
||||||
@@ -430,6 +436,16 @@ app.whenReady().then(async () => {
|
|||||||
databasePath: join(app.getPath('userData'), 'cache', 'voice-transcripts.sqlite'),
|
databasePath: join(app.getPath('userData'), 'cache', 'voice-transcripts.sqlite'),
|
||||||
workerPath: join(__dirname, 'voiceRecognitionWorker.js')
|
workerPath: join(__dirname, 'voiceRecognitionWorker.js')
|
||||||
})
|
})
|
||||||
|
knowledgeSearchService = new KnowledgeSearchService(
|
||||||
|
app.getPath('userData'),
|
||||||
|
join(__dirname, 'knowledgeWorker.js')
|
||||||
|
)
|
||||||
|
aiSearchPipelineService = new AiSearchPipelineService(knowledgeSearchService, aiProviderService)
|
||||||
|
knowledgeSearchService.onStatusChange((status) => {
|
||||||
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
|
if (!window.isDestroyed()) window.webContents.send('knowledge:status', status)
|
||||||
|
}
|
||||||
|
})
|
||||||
voiceRecognition.modelManager.setProgressListener((status) => {
|
voiceRecognition.modelManager.setProgressListener((status) => {
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status)
|
if (!window.isDestroyed()) window.webContents.send('voice:modelProgress', status)
|
||||||
@@ -501,10 +517,13 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('app-update:install', () => appUpdateService.install())
|
ipcMain.handle('app-update:install', () => appUpdateService.install())
|
||||||
ipcMain.handle('cache:getSummary', () => getCacheSummary())
|
ipcMain.handle('cache:getSummary', () => getCacheSummary())
|
||||||
ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => {
|
ipcMain.handle('cache:clear', async (_, scope: CacheClearScope) => {
|
||||||
const allowedScopes: CacheClearScope[] = ['bootstrap', 'electron', 'all']
|
const allowedScopes: CacheClearScope[] = ['bootstrap', 'electron', 'knowledge', 'all']
|
||||||
if (!allowedScopes.includes(scope)) return getCacheSummary()
|
if (!allowedScopes.includes(scope)) return getCacheSummary()
|
||||||
imageDecryptService = null
|
imageDecryptService = null
|
||||||
return clearCache(scope)
|
return clearCache(scope, {
|
||||||
|
beforeClearKnowledge: () =>
|
||||||
|
knowledgeSearchService?.prepareForCacheClear() || Promise.resolve()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:init', async (_, key: string, accountRoot?: string) => {
|
ipcMain.handle('db:init', async (_, key: string, accountRoot?: string) => {
|
||||||
@@ -891,6 +910,29 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
||||||
|
ipcMain.handle(
|
||||||
|
'knowledge:search',
|
||||||
|
(_, request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> => {
|
||||||
|
if (!knowledgeSearchService) {
|
||||||
|
throw new Error('本地知识库服务尚未初始化')
|
||||||
|
}
|
||||||
|
return knowledgeSearchService.search(request)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ipcMain.handle('knowledge:getStatus', () => {
|
||||||
|
if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化')
|
||||||
|
return knowledgeSearchService.getStatus()
|
||||||
|
})
|
||||||
|
ipcMain.handle('knowledge:startIndex', () => {
|
||||||
|
if (!knowledgeSearchService) throw new Error('本地知识库服务尚未初始化')
|
||||||
|
return knowledgeSearchService.startCurrentAccountIndex()
|
||||||
|
})
|
||||||
|
ipcMain.handle('ai-search:run', (event, request: AiSearchPipelineRequest) => {
|
||||||
|
if (!aiSearchPipelineService) throw new Error('本地搜索服务尚未初始化')
|
||||||
|
return aiSearchPipelineService.run(request, (progress) => {
|
||||||
|
if (!event.sender.isDestroyed()) event.sender.send('ai-search:progress', progress)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'ai:chat',
|
'ai:chat',
|
||||||
@@ -1444,7 +1486,8 @@ app.on('before-quit', (event) => {
|
|||||||
const [, nativeCallsDrained] = await Promise.all([
|
const [, nativeCallsDrained] = await Promise.all([
|
||||||
apiServer.stop().catch(() => undefined),
|
apiServer.stop().catch(() => undefined),
|
||||||
chat.closeChatDbForQuit().catch(() => false),
|
chat.closeChatDbForQuit().catch(() => false),
|
||||||
voiceRecognition?.dispose().catch(() => undefined)
|
voiceRecognition?.dispose().catch(() => undefined),
|
||||||
|
knowledgeSearchService?.dispose().catch(() => undefined)
|
||||||
])
|
])
|
||||||
if (!nativeCallsDrained) {
|
if (!nativeCallsDrained) {
|
||||||
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
|
console.warn('[Shutdown] WCDB async calls did not fully drain before quit')
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { createHash } from 'crypto'
|
||||||
|
import type {
|
||||||
|
KnowledgeChunk,
|
||||||
|
KnowledgeChunkerConfig,
|
||||||
|
KnowledgeNormalizedMessage
|
||||||
|
} from '../../shared/knowledge'
|
||||||
|
import { isIndexableKnowledgeMessage } from './normalizer'
|
||||||
|
|
||||||
|
function digest(value: string): string {
|
||||||
|
return createHash('sha256').update(value).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChunkText(messages: KnowledgeNormalizedMessage[]): string {
|
||||||
|
return messages
|
||||||
|
.map((message) => {
|
||||||
|
const sender = message.senderName || message.senderId || '未知成员'
|
||||||
|
return `[${new Date(message.createTime).toISOString()}] ${sender}: ${message.searchableText}`
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChunk(
|
||||||
|
messages: KnowledgeNormalizedMessage[],
|
||||||
|
config: KnowledgeChunkerConfig
|
||||||
|
): KnowledgeChunk {
|
||||||
|
const first = messages[0]
|
||||||
|
const last = messages[messages.length - 1]
|
||||||
|
const text = formatChunkText(messages)
|
||||||
|
const messageIds = messages.map((message) => message.messageId)
|
||||||
|
const participantIds = Array.from(
|
||||||
|
new Set(messages.map((message) => message.senderId).filter((value): value is string => Boolean(value)))
|
||||||
|
)
|
||||||
|
const messageKinds = Array.from(new Set(messages.map((message) => message.kind)))
|
||||||
|
const identity = `${first.accountId}|${first.conversationId}|${config.version}|${messageIds.join('|')}`
|
||||||
|
return {
|
||||||
|
chunkId: digest(identity),
|
||||||
|
accountId: first.accountId,
|
||||||
|
conversationId: first.conversationId,
|
||||||
|
startTime: first.createTime,
|
||||||
|
endTime: last.createTime,
|
||||||
|
text,
|
||||||
|
messageIds,
|
||||||
|
participantIds,
|
||||||
|
messageKinds,
|
||||||
|
contentHash: digest(`${identity}|${text}`),
|
||||||
|
chunkerVersion: config.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chunks one conversation only; cross-conversation chunks are never allowed. */
|
||||||
|
export function chunkConversation(
|
||||||
|
messages: KnowledgeNormalizedMessage[],
|
||||||
|
config: KnowledgeChunkerConfig
|
||||||
|
): KnowledgeChunk[] {
|
||||||
|
const sorted = messages
|
||||||
|
.filter(isIndexableKnowledgeMessage)
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => left.createTime - right.createTime || left.messageId.localeCompare(right.messageId))
|
||||||
|
if (!sorted.length) return []
|
||||||
|
|
||||||
|
const conversationId = sorted[0].conversationId
|
||||||
|
const accountId = sorted[0].accountId
|
||||||
|
if (sorted.some((message) => message.conversationId !== conversationId || message.accountId !== accountId)) {
|
||||||
|
throw new Error('Conversation chunker received messages from multiple accounts or conversations')
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: KnowledgeChunk[] = []
|
||||||
|
let current: KnowledgeNormalizedMessage[] = []
|
||||||
|
let currentCharacters = 0
|
||||||
|
for (const message of sorted) {
|
||||||
|
const previous = current[current.length - 1]
|
||||||
|
const nextCharacters = currentCharacters + message.searchableText.length
|
||||||
|
const shouldSplit =
|
||||||
|
current.length > 0 &&
|
||||||
|
(message.createTime - previous.createTime > config.maxGapMs ||
|
||||||
|
current.length >= config.maxMessages ||
|
||||||
|
nextCharacters > config.maxCharacters)
|
||||||
|
if (shouldSplit) {
|
||||||
|
chunks.push(buildChunk(current, config))
|
||||||
|
current = []
|
||||||
|
currentCharacters = 0
|
||||||
|
}
|
||||||
|
current.push(message)
|
||||||
|
currentCharacters += message.searchableText.length
|
||||||
|
}
|
||||||
|
if (current.length) chunks.push(buildChunk(current, config))
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
import * as chat from '../services/chat-service'
|
||||||
|
import type {
|
||||||
|
KnowledgeAttachmentMetadata,
|
||||||
|
KnowledgeEvidence,
|
||||||
|
KnowledgeMessageKind,
|
||||||
|
KnowledgeRuntimeStatus,
|
||||||
|
KnowledgeSearchRequest,
|
||||||
|
KnowledgeSearchIpcRequest,
|
||||||
|
KnowledgeSearchIpcResult,
|
||||||
|
KnowledgeSearchResult,
|
||||||
|
KnowledgeSourceMessage
|
||||||
|
} from '../../shared/knowledge'
|
||||||
|
import {
|
||||||
|
DEFAULT_KNOWLEDGE_CHUNKER,
|
||||||
|
DEFAULT_KNOWLEDGE_FTS_CONFIG,
|
||||||
|
emptyKnowledgeSearchTimings
|
||||||
|
} from '../../shared/knowledge'
|
||||||
|
import { KnowledgeService } from './knowledge-service'
|
||||||
|
|
||||||
|
const FALLBACK_LIMIT = 240
|
||||||
|
const MAX_SENDER_NAME_CONVERSATIONS = 8
|
||||||
|
const MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH = 700
|
||||||
|
|
||||||
|
function looksLikeOpaqueSenderId(value: string | undefined): boolean {
|
||||||
|
const normalized = value?.trim() || ''
|
||||||
|
return (
|
||||||
|
normalized.startsWith('wxid_') ||
|
||||||
|
normalized.endsWith('@chatroom') ||
|
||||||
|
/^\d{6,}$/.test(normalized)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMemberDisplayName(member: chat.GroupSnapshot['members'][number]): string {
|
||||||
|
return (
|
||||||
|
[member.groupNickname, member.wechatNickname, member.nickname, member.remark]
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.find((value) => value && !looksLikeOpaqueSenderId(value)) || ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceMessageId(message: chat.FormattedMessage): string {
|
||||||
|
if (message.localId) return `local:${message.localId}`
|
||||||
|
if (message.id) return String(message.id)
|
||||||
|
return `${message.createTime || 0}:${message.serverId || message.content}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceKind(message: chat.FormattedMessage): KnowledgeMessageKind {
|
||||||
|
if (message.voiceTranscript || message.type === '语音') return 'voice'
|
||||||
|
if (message.contentData?.type === 'share' || message.contentData?.type === 'miniProgram') {
|
||||||
|
return message.contentData.type === 'share' && message.contentData.typeVal === '6'
|
||||||
|
? 'file'
|
||||||
|
: 'link'
|
||||||
|
}
|
||||||
|
if (message.contentData?.type === 'system') return 'system'
|
||||||
|
return message.content?.trim() ? 'text' : 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceTextAndAttachment(message: chat.FormattedMessage): {
|
||||||
|
text?: string
|
||||||
|
attachment?: KnowledgeAttachmentMetadata
|
||||||
|
} {
|
||||||
|
const text = message.content?.trim() || ''
|
||||||
|
const content = message.contentData
|
||||||
|
if (!content) {
|
||||||
|
return {
|
||||||
|
text: text || undefined,
|
||||||
|
attachment: message.exportMediaName
|
||||||
|
? {
|
||||||
|
name: message.exportMediaName,
|
||||||
|
kind: message.exportMediaType === 'file' ? 'file' : 'other'
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (content.type === 'share') {
|
||||||
|
const title = content.title?.trim() || ''
|
||||||
|
const description = content.des?.trim() || ''
|
||||||
|
return {
|
||||||
|
text: [text, title, description].filter(Boolean).join('\n') || undefined,
|
||||||
|
attachment:
|
||||||
|
title || content.url
|
||||||
|
? {
|
||||||
|
name: title || content.url,
|
||||||
|
kind: content.typeVal === '6' ? 'file' : 'link',
|
||||||
|
url: content.url
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (content.type === 'miniProgram') {
|
||||||
|
return {
|
||||||
|
text: [text, content.title, content.description].filter(Boolean).join('\n') || undefined,
|
||||||
|
attachment: content.title ? { name: content.title, kind: 'link' } : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (content.type === 'quote') {
|
||||||
|
return {
|
||||||
|
text:
|
||||||
|
[text, content.title, content.content, content.quotedContent].filter(Boolean).join('\n') ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (content.type === 'forwardBundle') {
|
||||||
|
return {
|
||||||
|
text: [text, content.title, content.description, ...content.items.map((item) => item.text)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { text: text || undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSourceMessage(
|
||||||
|
accountId: string,
|
||||||
|
conversationId: string,
|
||||||
|
message: chat.FormattedMessage
|
||||||
|
): KnowledgeSourceMessage | null {
|
||||||
|
if (!message.createTime) return null
|
||||||
|
const extracted = sourceTextAndAttachment(message)
|
||||||
|
const voiceTranscript = message.voiceTranscript?.trim() || undefined
|
||||||
|
if (!extracted.text && !extracted.attachment && !voiceTranscript) return null
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
conversationId,
|
||||||
|
messageId: sourceMessageId(message),
|
||||||
|
// Existing chat messages use Unix seconds; the knowledge contract uses milliseconds.
|
||||||
|
createTime: message.createTime * 1000,
|
||||||
|
senderId: message.senderId || message.from || undefined,
|
||||||
|
senderName: message.isSender ? '我' : message.name || undefined,
|
||||||
|
kind: sourceKind(message),
|
||||||
|
text: extracted.text,
|
||||||
|
attachment: extracted.attachment,
|
||||||
|
voiceTranscript
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeComparable(value: string): string {
|
||||||
|
return value.toLocaleLowerCase().replace(/\s+/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackTermScore(message: chat.FormattedMessage, terms: string[]): number {
|
||||||
|
const source = toSourceMessage('fallback', 'fallback', message)
|
||||||
|
const text = `${source?.text || ''}\n${source?.voiceTranscript || ''}\n${source?.attachment?.name || ''}`
|
||||||
|
const normalized = normalizeComparable(text)
|
||||||
|
return terms.reduce((score, term) => {
|
||||||
|
const normalizedTerm = normalizeComparable(term)
|
||||||
|
return normalizedTerm && normalized.includes(normalizedTerm)
|
||||||
|
? score + normalizedTerm.length
|
||||||
|
: score
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main-process adapter for the read-only chat archive. It never passes source
|
||||||
|
* database handles or keys to the worker; only normalized serializable values.
|
||||||
|
*/
|
||||||
|
export class KnowledgeSearchService {
|
||||||
|
private readonly service: KnowledgeService
|
||||||
|
private readonly indexing = new Map<string, Promise<void>>()
|
||||||
|
private readonly statusByAccount = new Map<string, KnowledgeRuntimeStatus>()
|
||||||
|
private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>()
|
||||||
|
private wcdbReadTail: Promise<void> = Promise.resolve()
|
||||||
|
|
||||||
|
constructor(userDataPath: string, workerPath: string) {
|
||||||
|
this.service = new KnowledgeService(userDataPath, workerPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
startCurrentAccountIndex(): KnowledgeRuntimeStatus {
|
||||||
|
const accountId = this.currentAccountId()
|
||||||
|
if (!accountId) return this.emptyStatus('')
|
||||||
|
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
||||||
|
if (this.indexing.has(accountId)) return current
|
||||||
|
const started: KnowledgeRuntimeStatus = {
|
||||||
|
...current,
|
||||||
|
state: current.indexedMessageCount ? 'syncing' : 'building',
|
||||||
|
processedMessages: 0,
|
||||||
|
totalMessages: current.sourceMessageCount,
|
||||||
|
estimatedRemainingMs: null,
|
||||||
|
lastError: undefined
|
||||||
|
}
|
||||||
|
this.publishStatus(started)
|
||||||
|
const task = this.indexAccount(accountId)
|
||||||
|
.catch((error) => {
|
||||||
|
const previous = this.statusByAccount.get(accountId)
|
||||||
|
this.publishStatus({
|
||||||
|
...(previous || this.emptyStatus(accountId)),
|
||||||
|
state: 'error',
|
||||||
|
lastError: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.indexing.delete(accountId)
|
||||||
|
void this.refreshStatus(accountId).catch(() => undefined)
|
||||||
|
})
|
||||||
|
this.indexing.set(accountId, task)
|
||||||
|
void task.catch((error) => {
|
||||||
|
console.warn('[Knowledge] background index failed:', error)
|
||||||
|
})
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> {
|
||||||
|
const accountId = this.currentAccountId()
|
||||||
|
if (!accountId) return this.searchFallback(request, 'unavailable')
|
||||||
|
try {
|
||||||
|
const searchRequest: Omit<KnowledgeSearchRequest, 'databaseRoot'> = {
|
||||||
|
accountId,
|
||||||
|
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
|
||||||
|
text: request.text,
|
||||||
|
terms: request.terms,
|
||||||
|
limit: Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT)),
|
||||||
|
conversationIds: request.conversationIds,
|
||||||
|
senderIds: request.senderIds,
|
||||||
|
startTime: request.startTime === undefined ? undefined : request.startTime * 1000,
|
||||||
|
endTime: request.endTime === undefined ? undefined : request.endTime * 1000
|
||||||
|
}
|
||||||
|
const result = await this.searchKnowledge(searchRequest)
|
||||||
|
// An existing derived database can answer while its next incremental pass is running.
|
||||||
|
// Never turn an interactive global search into another full WCDB scan during that pass.
|
||||||
|
if (result.state === 'ready' || result.evidence.length) {
|
||||||
|
return this.toKnowledgeResult(result)
|
||||||
|
}
|
||||||
|
if (this.indexing.has(accountId)) {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
source: 'knowledge',
|
||||||
|
totalMessages: result.indexedMessageCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.searchFallback(request, 'unavailable')
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Knowledge] search failed, using legacy fallback:', error)
|
||||||
|
return this.searchFallback(request, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
await this.service.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Safely release derived SQLite handles before the cache screen removes them. */
|
||||||
|
async prepareForCacheClear(): Promise<void> {
|
||||||
|
if (this.indexing.size) {
|
||||||
|
throw new Error('本地知识库正在同步,请等待同步完成后再清理')
|
||||||
|
}
|
||||||
|
await this.service.dispose()
|
||||||
|
const accountIds = Array.from(this.statusByAccount.keys())
|
||||||
|
this.statusByAccount.clear()
|
||||||
|
accountIds.forEach((accountId) => this.publishStatus(this.emptyStatus(accountId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatusChange(listener: (status: KnowledgeRuntimeStatus) => void): () => void {
|
||||||
|
this.statusListeners.add(listener)
|
||||||
|
return () => this.statusListeners.delete(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatus(): Promise<KnowledgeRuntimeStatus> {
|
||||||
|
const accountId = this.currentAccountId()
|
||||||
|
if (!accountId) return this.emptyStatus('')
|
||||||
|
return this.refreshStatus(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentAccountId(): string {
|
||||||
|
if (!chat.isReady()) return ''
|
||||||
|
return chat.getSelfAccountInfo()?.wxid || chat.getCurrentAccountRoot()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async indexAccount(accountId: string): Promise<void> {
|
||||||
|
const contacts = await this.listContacts()
|
||||||
|
let processedMessages = 0
|
||||||
|
const startedAt = Date.now()
|
||||||
|
this.publishStatus({
|
||||||
|
...(this.statusByAccount.get(accountId) || this.emptyStatus(accountId)),
|
||||||
|
state: this.statusByAccount.get(accountId)?.indexedMessageCount ? 'syncing' : 'building',
|
||||||
|
processedMessages: 0,
|
||||||
|
totalMessages: null,
|
||||||
|
estimatedRemainingMs: null
|
||||||
|
})
|
||||||
|
for (const [index, contact] of contacts.entries()) {
|
||||||
|
// WCDB rejects overlapping async pagination. Queue every archive read so
|
||||||
|
// background indexing and an interactive fallback search can interleave safely.
|
||||||
|
const messages = await this.listMessages(contact.md5)
|
||||||
|
const sourceMessages = messages
|
||||||
|
.map((message) => toSourceMessage(accountId, contact.md5, message))
|
||||||
|
.filter((message): message is KnowledgeSourceMessage => Boolean(message))
|
||||||
|
await this.service.index(
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
conversationId: contact.md5,
|
||||||
|
completeSnapshot: true,
|
||||||
|
messages: sourceMessages
|
||||||
|
}
|
||||||
|
],
|
||||||
|
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
|
||||||
|
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG,
|
||||||
|
sourceMessageCount:
|
||||||
|
index === contacts.length - 1 ? processedMessages + sourceMessages.length : undefined
|
||||||
|
},
|
||||||
|
(progress) => {
|
||||||
|
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
||||||
|
this.publishStatus({
|
||||||
|
...current,
|
||||||
|
state: current.indexedMessageCount ? 'syncing' : 'building',
|
||||||
|
processedMessages: processedMessages + progress.processedMessages,
|
||||||
|
totalMessages: null,
|
||||||
|
currentConversationId: progress.conversationId,
|
||||||
|
estimatedRemainingMs: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
processedMessages += sourceMessages.length
|
||||||
|
const current = this.statusByAccount.get(accountId) || this.emptyStatus(accountId)
|
||||||
|
this.publishStatus({
|
||||||
|
...current,
|
||||||
|
state: current.indexedMessageCount ? 'syncing' : 'building',
|
||||||
|
processedMessages,
|
||||||
|
totalMessages: null,
|
||||||
|
currentConversationId: contact.md5,
|
||||||
|
estimatedRemainingMs: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await this.refreshStatus(accountId, {
|
||||||
|
processedMessages,
|
||||||
|
totalMessages: processedMessages,
|
||||||
|
startedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async searchFallback(
|
||||||
|
request: KnowledgeSearchIpcRequest,
|
||||||
|
fallbackReason: 'unavailable' | 'indexing' | 'error'
|
||||||
|
): Promise<KnowledgeSearchIpcResult> {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const contacts = await this.listContacts()
|
||||||
|
const allowedConversations = new Set(request.conversationIds || [])
|
||||||
|
const sourceContacts = allowedConversations.size
|
||||||
|
? contacts.filter((contact) => allowedConversations.has(contact.md5))
|
||||||
|
: contacts
|
||||||
|
const senderIds = new Set(request.senderIds || [])
|
||||||
|
const terms = request.terms.filter((term) => term.trim().length >= 2)
|
||||||
|
const matches: Array<{
|
||||||
|
contact: (typeof sourceContacts)[number]
|
||||||
|
message: chat.FormattedMessage
|
||||||
|
score: number
|
||||||
|
}> = []
|
||||||
|
let totalMessages = 0
|
||||||
|
|
||||||
|
for (const contact of sourceContacts) {
|
||||||
|
const messages = await this.listMessages(contact.md5, request.startTime, request.endTime)
|
||||||
|
totalMessages += messages.length
|
||||||
|
for (const message of messages) {
|
||||||
|
matches.push({
|
||||||
|
contact,
|
||||||
|
message,
|
||||||
|
score: fallbackTermScore(message, terms)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const filtered = matches
|
||||||
|
.filter(({ message, score }) => {
|
||||||
|
const senderMatches = !senderIds.size || senderIds.has(message.senderId || message.from)
|
||||||
|
const termMatches = !terms.length || score > 0
|
||||||
|
return senderMatches && termMatches
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.score - left.score ||
|
||||||
|
(right.message.createTime || 0) - (left.message.createTime || 0)
|
||||||
|
)
|
||||||
|
.slice(0, Math.max(1, Math.min(request.limit || FALLBACK_LIMIT, FALLBACK_LIMIT)))
|
||||||
|
const result: KnowledgeSearchIpcResult = {
|
||||||
|
source: 'fallback',
|
||||||
|
fallbackReason,
|
||||||
|
state: fallbackReason === 'indexing' ? 'indexing' : 'unavailable',
|
||||||
|
indexedMessageCount: 0,
|
||||||
|
indexedChunkCount: 0,
|
||||||
|
totalMessages,
|
||||||
|
timings: {
|
||||||
|
...emptyKnowledgeSearchTimings(),
|
||||||
|
messageLoadMs: Date.now() - startedAt,
|
||||||
|
totalMs: Date.now() - startedAt
|
||||||
|
},
|
||||||
|
evidence: filtered.map(({ contact, message, score }) => ({
|
||||||
|
chunkId: `fallback:${contact.md5}:${sourceMessageId(message)}`,
|
||||||
|
conversationId: contact.md5,
|
||||||
|
startTime: (message.createTime || 0) * 1000,
|
||||||
|
endTime: (message.createTime || 0) * 1000,
|
||||||
|
messageId: sourceMessageId(message),
|
||||||
|
senderId: message.senderId || message.from || undefined,
|
||||||
|
sender: message.isSender ? '我' : message.name || '未知成员',
|
||||||
|
timestamp: (message.createTime || 0) * 1000,
|
||||||
|
messageIds: [sourceMessageId(message)],
|
||||||
|
text: sourceTextAndAttachment(message).text || message.content || `[${message.type}]`,
|
||||||
|
score: -score
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
evidence: await this.enrichEvidenceSenders(result.evidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite has a finite bind-parameter limit. Group/one-to-one scope filters
|
||||||
|
* can contain over one thousand conversations, so split only the Worker
|
||||||
|
* query and merge real Evidence instead of dropping the selected scope.
|
||||||
|
*/
|
||||||
|
private async searchKnowledge(
|
||||||
|
request: Omit<KnowledgeSearchRequest, 'databaseRoot'>
|
||||||
|
): Promise<KnowledgeSearchResult> {
|
||||||
|
const conversationIds = Array.from(new Set(request.conversationIds || []))
|
||||||
|
if (conversationIds.length <= MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH) {
|
||||||
|
return this.searchWorker(request)
|
||||||
|
}
|
||||||
|
const partialResults: KnowledgeSearchResult[] = []
|
||||||
|
for (
|
||||||
|
let start = 0;
|
||||||
|
start < conversationIds.length;
|
||||||
|
start += MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH
|
||||||
|
) {
|
||||||
|
partialResults.push(
|
||||||
|
await this.searchWorker({
|
||||||
|
...request,
|
||||||
|
conversationIds: conversationIds.slice(
|
||||||
|
start,
|
||||||
|
start + MAX_CONVERSATION_FILTERS_PER_WORKER_SEARCH
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const evidenceByIdentity = new Map<string, KnowledgeEvidence>()
|
||||||
|
partialResults
|
||||||
|
.flatMap((result) => result.evidence)
|
||||||
|
.forEach((item) => {
|
||||||
|
const identity = `${item.conversationId}:${item.messageId}`
|
||||||
|
const existing = evidenceByIdentity.get(identity)
|
||||||
|
if (!existing || (item.score || 0) < (existing.score || 0)) {
|
||||||
|
evidenceByIdentity.set(identity, item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const mergeStartedAt = Date.now()
|
||||||
|
const mergedEvidence = Array.from(evidenceByIdentity.values())
|
||||||
|
.sort(
|
||||||
|
(left, right) => (left.score || 0) - (right.score || 0) || right.timestamp - left.timestamp
|
||||||
|
)
|
||||||
|
.slice(0, request.limit)
|
||||||
|
const timings = partialResults.reduce(
|
||||||
|
(total, result) => ({
|
||||||
|
workerIpcMs: total.workerIpcMs + (result.timings?.workerIpcMs || 0),
|
||||||
|
workerBootMs: total.workerBootMs + (result.timings?.workerBootMs || 0),
|
||||||
|
dispatchMs: total.dispatchMs + (result.timings?.dispatchMs || 0),
|
||||||
|
workerSqlMs: total.workerSqlMs + (result.timings?.workerSqlMs || 0),
|
||||||
|
responseTransferMs: total.responseTransferMs + (result.timings?.responseTransferMs || 0),
|
||||||
|
responseSerializeMs: total.responseSerializeMs + (result.timings?.responseSerializeMs || 0),
|
||||||
|
ftsMs: total.ftsMs + (result.timings?.ftsMs || 0),
|
||||||
|
messageLoadMs: total.messageLoadMs + (result.timings?.messageLoadMs || 0),
|
||||||
|
chunkExpandMs: total.chunkExpandMs + (result.timings?.chunkExpandMs || 0),
|
||||||
|
rankingMs: total.rankingMs + (result.timings?.rankingMs || 0),
|
||||||
|
totalMs: total.totalMs + (result.timings?.totalMs || 0)
|
||||||
|
}),
|
||||||
|
emptyKnowledgeSearchTimings()
|
||||||
|
)
|
||||||
|
const mergeRankingMs = Date.now() - mergeStartedAt
|
||||||
|
timings.rankingMs += mergeRankingMs
|
||||||
|
timings.totalMs += mergeRankingMs
|
||||||
|
return {
|
||||||
|
state: partialResults.some((result) => result.state === 'ready')
|
||||||
|
? 'ready'
|
||||||
|
: partialResults.some((result) => result.state === 'indexing')
|
||||||
|
? 'indexing'
|
||||||
|
: 'unavailable',
|
||||||
|
indexedMessageCount: Math.max(...partialResults.map((result) => result.indexedMessageCount)),
|
||||||
|
indexedChunkCount: Math.max(...partialResults.map((result) => result.indexedChunkCount)),
|
||||||
|
evidence: mergedEvidence,
|
||||||
|
timings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async searchWorker(
|
||||||
|
request: Omit<KnowledgeSearchRequest, 'databaseRoot'>
|
||||||
|
): Promise<KnowledgeSearchResult> {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const result = await this.service.search(request)
|
||||||
|
const timings = result.timings || emptyKnowledgeSearchTimings()
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
timings: {
|
||||||
|
...timings,
|
||||||
|
workerIpcMs: timings.workerIpcMs || Math.max(0, Date.now() - startedAt - timings.totalMs),
|
||||||
|
workerSqlMs: timings.workerSqlMs || timings.totalMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private listContacts(): ReturnType<typeof chat.listContactsAsync> {
|
||||||
|
return this.enqueueWcdbRead(() => chat.listContactsAsync())
|
||||||
|
}
|
||||||
|
|
||||||
|
private listMessages(
|
||||||
|
conversationId: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
): ReturnType<typeof chat.listMessagesAsync> {
|
||||||
|
return this.enqueueWcdbRead(() => chat.listMessagesAsync(conversationId, startTime, endTime))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toKnowledgeResult(
|
||||||
|
result: KnowledgeSearchResult
|
||||||
|
): Promise<KnowledgeSearchIpcResult> {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
evidence: await this.enrichEvidenceSenders(result.evidence),
|
||||||
|
source: 'knowledge',
|
||||||
|
totalMessages: result.indexedMessageCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enrichEvidenceSenders(evidence: KnowledgeEvidence[]): Promise<KnowledgeEvidence[]> {
|
||||||
|
const candidateConversationIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
evidence
|
||||||
|
.filter((item) => item.senderId && looksLikeOpaqueSenderId(item.sender))
|
||||||
|
.map((item) => item.conversationId)
|
||||||
|
)
|
||||||
|
).slice(0, MAX_SENDER_NAME_CONVERSATIONS)
|
||||||
|
if (!candidateConversationIds.length) return evidence
|
||||||
|
|
||||||
|
const contacts = await this.listContacts()
|
||||||
|
const groupConversationIds = new Set(
|
||||||
|
contacts.filter((contact) => contact.type === 'group').map((contact) => contact.md5)
|
||||||
|
)
|
||||||
|
const memberNamesByConversation = new Map<string, Map<string, string>>()
|
||||||
|
for (const conversationId of candidateConversationIds) {
|
||||||
|
if (!groupConversationIds.has(conversationId)) continue
|
||||||
|
const snapshot = await this.enqueueWcdbRead(() => chat.getGroupSnapshotAsync(conversationId))
|
||||||
|
const memberNames = new Map(
|
||||||
|
(snapshot?.members || [])
|
||||||
|
.map((member) => [member.wxid, groupMemberDisplayName(member)] as const)
|
||||||
|
.filter(([, name]) => Boolean(name))
|
||||||
|
)
|
||||||
|
if (memberNames.size) memberNamesByConversation.set(conversationId, memberNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
return evidence.map((item) => {
|
||||||
|
const sender = memberNamesByConversation.get(item.conversationId)?.get(item.senderId || '')
|
||||||
|
return sender ? { ...item, sender } : item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueueWcdbRead<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const result = this.wcdbReadTail.then(operation, operation)
|
||||||
|
// Keep the queue usable after a read failure while returning that failure to its caller.
|
||||||
|
this.wcdbReadTail = result.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private emptyStatus(accountId: string): KnowledgeRuntimeStatus {
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
state: 'unavailable',
|
||||||
|
indexedMessageCount: 0,
|
||||||
|
indexedChunkCount: 0,
|
||||||
|
sourceMessageCount: null,
|
||||||
|
processedMessages: 0,
|
||||||
|
totalMessages: null,
|
||||||
|
estimatedRemainingMs: null,
|
||||||
|
databaseBytes: 0,
|
||||||
|
walBytes: 0,
|
||||||
|
shmBytes: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshStatus(
|
||||||
|
accountId: string,
|
||||||
|
progress?: Pick<KnowledgeRuntimeStatus, 'processedMessages' | 'totalMessages'> & {
|
||||||
|
startedAt?: number
|
||||||
|
}
|
||||||
|
): Promise<KnowledgeRuntimeStatus> {
|
||||||
|
const remote = await this.service.status({ accountId, fts: DEFAULT_KNOWLEDGE_FTS_CONFIG })
|
||||||
|
const current = this.statusByAccount.get(accountId)
|
||||||
|
const indexing = this.indexing.has(accountId)
|
||||||
|
const processedMessages =
|
||||||
|
progress?.processedMessages ?? current?.processedMessages ?? remote.processedMessages
|
||||||
|
const totalMessages = progress?.totalMessages ?? remote.sourceMessageCount
|
||||||
|
const state = indexing
|
||||||
|
? remote.indexedMessageCount > 0
|
||||||
|
? 'syncing'
|
||||||
|
: 'building'
|
||||||
|
: remote.state
|
||||||
|
const status: KnowledgeRuntimeStatus = {
|
||||||
|
...remote,
|
||||||
|
state,
|
||||||
|
processedMessages,
|
||||||
|
totalMessages,
|
||||||
|
estimatedRemainingMs: null
|
||||||
|
}
|
||||||
|
this.publishStatus(status)
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishStatus(status: KnowledgeRuntimeStatus): void {
|
||||||
|
this.statusByAccount.set(status.accountId, status)
|
||||||
|
for (const listener of this.statusListeners) listener(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { join } from 'path'
|
||||||
|
import type {
|
||||||
|
KnowledgeCapacityPreflight,
|
||||||
|
KnowledgeCapacityPreflightRequest,
|
||||||
|
KnowledgeIndexProgress,
|
||||||
|
KnowledgeIndexRequest,
|
||||||
|
KnowledgeIndexResult,
|
||||||
|
KnowledgeRuntimeStatus,
|
||||||
|
KnowledgeSearchRequest,
|
||||||
|
KnowledgeSearchResult,
|
||||||
|
KnowledgeStatusRequest
|
||||||
|
} from '../../shared/knowledge'
|
||||||
|
import { KnowledgeWorkerHost } from './knowledge-worker-host'
|
||||||
|
|
||||||
|
/** Minimal main-process service; no renderer API is exposed in Task 0~Task 2. */
|
||||||
|
export class KnowledgeService {
|
||||||
|
private readonly worker: KnowledgeWorkerHost
|
||||||
|
|
||||||
|
constructor(userDataPath: string, workerPath: string) {
|
||||||
|
this.worker = new KnowledgeWorkerHost(workerPath)
|
||||||
|
this.databaseRoot = join(userDataPath, 'knowledge')
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly databaseRoot: string
|
||||||
|
|
||||||
|
index(
|
||||||
|
request: Omit<KnowledgeIndexRequest, 'databaseRoot'>,
|
||||||
|
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||||
|
): Promise<KnowledgeIndexResult> {
|
||||||
|
return this.worker.index({ ...request, databaseRoot: this.databaseRoot }, onProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
preflight(
|
||||||
|
request: Omit<KnowledgeCapacityPreflightRequest, 'databaseRoot'>
|
||||||
|
): Promise<KnowledgeCapacityPreflight> {
|
||||||
|
return this.worker.preflight({ ...request, databaseRoot: this.databaseRoot })
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(accountId: string): Promise<{ removed: true }> {
|
||||||
|
return this.worker.remove(accountId, this.databaseRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
search(request: Omit<KnowledgeSearchRequest, 'databaseRoot'>): Promise<KnowledgeSearchResult> {
|
||||||
|
return this.worker.search({ ...request, databaseRoot: this.databaseRoot })
|
||||||
|
}
|
||||||
|
|
||||||
|
status(request: Omit<KnowledgeStatusRequest, 'databaseRoot'>): Promise<KnowledgeRuntimeStatus> {
|
||||||
|
return this.worker.status({ ...request, databaseRoot: this.databaseRoot })
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): Promise<void> {
|
||||||
|
return this.worker.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
|||||||
|
import { fork, type ChildProcess } from 'child_process'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import type {
|
||||||
|
KnowledgeCapacityPreflight,
|
||||||
|
KnowledgeCapacityPreflightRequest,
|
||||||
|
KnowledgeIndexProgress,
|
||||||
|
KnowledgeIndexRequest,
|
||||||
|
KnowledgeIndexResult,
|
||||||
|
KnowledgeRuntimeStatus,
|
||||||
|
KnowledgeSearchRequest,
|
||||||
|
KnowledgeSearchResult,
|
||||||
|
KnowledgeStatusRequest,
|
||||||
|
KnowledgeWorkerRequest,
|
||||||
|
KnowledgeWorkerResponse
|
||||||
|
} from '../../shared/knowledge'
|
||||||
|
|
||||||
|
type WorkerResult =
|
||||||
|
| KnowledgeIndexResult
|
||||||
|
| KnowledgeCapacityPreflight
|
||||||
|
| KnowledgeSearchResult
|
||||||
|
| KnowledgeRuntimeStatus
|
||||||
|
| { removed: true }
|
||||||
|
type PendingRequest = {
|
||||||
|
resolve: (result: WorkerResult) => void
|
||||||
|
reject: (error: Error) => void
|
||||||
|
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||||
|
sentAt: number
|
||||||
|
workerBootStartedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main-process boundary for the derived knowledge database. The child runs
|
||||||
|
* with ELECTRON_RUN_AS_NODE so synchronous node:sqlite calls never block UI.
|
||||||
|
*/
|
||||||
|
export class KnowledgeWorkerHost {
|
||||||
|
private child: ChildProcess | null = null
|
||||||
|
private childStartedAt = 0
|
||||||
|
private readonly pending = new Map<string, PendingRequest>()
|
||||||
|
|
||||||
|
constructor(private readonly workerPath: string) {}
|
||||||
|
|
||||||
|
index(
|
||||||
|
payload: KnowledgeIndexRequest,
|
||||||
|
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||||
|
): Promise<KnowledgeIndexResult> {
|
||||||
|
return this.request('index', payload, onProgress) as Promise<KnowledgeIndexResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
preflight(payload: KnowledgeCapacityPreflightRequest): Promise<KnowledgeCapacityPreflight> {
|
||||||
|
return this.request('preflight', payload) as Promise<KnowledgeCapacityPreflight>
|
||||||
|
}
|
||||||
|
|
||||||
|
search(payload: KnowledgeSearchRequest): Promise<KnowledgeSearchResult> {
|
||||||
|
return this.request('search', payload) as Promise<KnowledgeSearchResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
status(payload: KnowledgeStatusRequest): Promise<KnowledgeRuntimeStatus> {
|
||||||
|
return this.request('status', payload) as Promise<KnowledgeRuntimeStatus>
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(accountId: string, databaseRoot: string): Promise<{ removed: true }> {
|
||||||
|
return this.request('remove', { accountId, databaseRoot }) as Promise<{ removed: true }>
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(targetRequestId: string): Promise<{ removed: true }> {
|
||||||
|
return this.request('cancel', { targetRequestId }) as Promise<{ removed: true }>
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
const child = this.child
|
||||||
|
if (!child) return
|
||||||
|
try {
|
||||||
|
await this.request('close', {})
|
||||||
|
} catch {
|
||||||
|
// The child is about to be stopped; its only job is a derived local index.
|
||||||
|
}
|
||||||
|
if (this.child === child) this.child = null
|
||||||
|
if (!child.killed) child.kill()
|
||||||
|
}
|
||||||
|
|
||||||
|
private request(
|
||||||
|
type: KnowledgeWorkerRequest['type'],
|
||||||
|
payload: KnowledgeWorkerRequest['payload'],
|
||||||
|
onProgress?: (progress: KnowledgeIndexProgress) => void
|
||||||
|
): Promise<WorkerResult> {
|
||||||
|
const hadWorker = Boolean(this.child?.connected)
|
||||||
|
const child = this.ensureChild()
|
||||||
|
const requestId = randomUUID()
|
||||||
|
const sentAt = Date.now()
|
||||||
|
const request: KnowledgeWorkerRequest = { version: 1, type, requestId, sentAt, payload }
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(requestId, {
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
onProgress,
|
||||||
|
sentAt,
|
||||||
|
workerBootStartedAt: hadWorker ? undefined : this.childStartedAt
|
||||||
|
})
|
||||||
|
child.send(request, (error) => {
|
||||||
|
if (error) this.finish(requestId, undefined, error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureChild(): ChildProcess {
|
||||||
|
if (this.child?.connected) return this.child
|
||||||
|
const child = fork(this.workerPath, [], {
|
||||||
|
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||||
|
serialization: 'advanced',
|
||||||
|
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||||
|
})
|
||||||
|
child.on('message', (message: KnowledgeWorkerResponse) => {
|
||||||
|
if (message?.version !== 1) return
|
||||||
|
if (message.type === 'progress') {
|
||||||
|
const pending = this.pending.get(message.requestId)
|
||||||
|
if (pending && message.payload)
|
||||||
|
pending.onProgress?.(message.payload as KnowledgeIndexProgress)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.finish(
|
||||||
|
message.requestId,
|
||||||
|
message.payload as WorkerResult | undefined,
|
||||||
|
message.type === 'error'
|
||||||
|
? new Error(message.error || 'Knowledge worker failed')
|
||||||
|
: undefined,
|
||||||
|
message.transport
|
||||||
|
)
|
||||||
|
})
|
||||||
|
child.once('error', (error) => this.failAll(error))
|
||||||
|
child.once('exit', (code) => {
|
||||||
|
if (this.child === child) this.child = null
|
||||||
|
this.failAll(new Error(`Knowledge worker exited (${code ?? 'unknown'})`))
|
||||||
|
})
|
||||||
|
this.child = child
|
||||||
|
this.childStartedAt = Date.now()
|
||||||
|
return child
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish(
|
||||||
|
requestId: string,
|
||||||
|
result?: WorkerResult,
|
||||||
|
error?: Error,
|
||||||
|
transport?: KnowledgeWorkerResponse['transport']
|
||||||
|
): void {
|
||||||
|
const pending = this.pending.get(requestId)
|
||||||
|
if (!pending) return
|
||||||
|
this.pending.delete(requestId)
|
||||||
|
if (error) pending.reject(error)
|
||||||
|
else if (result) pending.resolve(this.applyTransportTimings(result, pending, transport))
|
||||||
|
else pending.reject(new Error('Knowledge worker returned no result'))
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyTransportTimings(
|
||||||
|
result: WorkerResult,
|
||||||
|
pending: PendingRequest,
|
||||||
|
transport?: KnowledgeWorkerResponse['transport']
|
||||||
|
): WorkerResult {
|
||||||
|
if (!('timings' in result) || !transport) return result
|
||||||
|
const receivedAt = Date.now()
|
||||||
|
const workerBootMs = pending.workerBootStartedAt
|
||||||
|
? Math.max(0, transport.workerReceivedAt - pending.workerBootStartedAt)
|
||||||
|
: 0
|
||||||
|
const dispatchMs = Math.max(0, transport.workerReceivedAt - pending.sentAt)
|
||||||
|
const responseTransferMs = Math.max(0, receivedAt - transport.workerCompletedAt)
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
timings: {
|
||||||
|
...result.timings,
|
||||||
|
workerBootMs,
|
||||||
|
dispatchMs,
|
||||||
|
workerSqlMs: result.timings.totalMs,
|
||||||
|
responseSerializeMs: transport.responseSerializeMs,
|
||||||
|
responseTransferMs,
|
||||||
|
workerIpcMs: workerBootMs + dispatchMs + responseTransferMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private failAll(error: Error): void {
|
||||||
|
for (const requestId of this.pending.keys()) this.finish(requestId, undefined, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import type {
|
||||||
|
KnowledgeCapacityPreflightRequest,
|
||||||
|
KnowledgeIndexRequest,
|
||||||
|
KnowledgeRuntimeStatus,
|
||||||
|
KnowledgeSearchRequest,
|
||||||
|
KnowledgeStatusRequest,
|
||||||
|
KnowledgeWorkerRequest,
|
||||||
|
KnowledgeWorkerResponse
|
||||||
|
} from '../../shared/knowledge'
|
||||||
|
import { emptyKnowledgeSearchTimings } from '../../shared/knowledge'
|
||||||
|
import {
|
||||||
|
KnowledgeStore,
|
||||||
|
estimateKnowledgeCapacityPreflight,
|
||||||
|
getKnowledgeDatabasePath,
|
||||||
|
removeKnowledgeDatabase
|
||||||
|
} from './knowledge-store'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { serialize } from 'v8'
|
||||||
|
|
||||||
|
const stores = new Map<string, KnowledgeStore>()
|
||||||
|
const controllers = new Map<string, AbortController>()
|
||||||
|
|
||||||
|
function send(
|
||||||
|
message: KnowledgeWorkerResponse,
|
||||||
|
transport?: KnowledgeWorkerResponse['transport']
|
||||||
|
): void {
|
||||||
|
if (process.send) process.send({ ...message, transport })
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSearchResult(
|
||||||
|
request: KnowledgeWorkerRequest,
|
||||||
|
payload: KnowledgeWorkerResponse['payload'],
|
||||||
|
workerReceivedAt: number
|
||||||
|
): void {
|
||||||
|
const serializeStartedAt = Date.now()
|
||||||
|
// This measures the actual payload encoding workload before Node IPC performs
|
||||||
|
// its own transfer. It lets diagnostics separate payload cost from SQL time.
|
||||||
|
serialize(payload)
|
||||||
|
const responseSerializeMs = Date.now() - serializeStartedAt
|
||||||
|
send(
|
||||||
|
{ version: 1, type: 'result', requestId: request.requestId, payload },
|
||||||
|
{ workerReceivedAt, workerCompletedAt: Date.now(), responseSerializeMs }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeKey(databaseRoot: string, accountId: string): string {
|
||||||
|
return getKnowledgeDatabasePath(databaseRoot, accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStore(
|
||||||
|
request: Pick<KnowledgeIndexRequest, 'databaseRoot' | 'accountId' | 'fts'>
|
||||||
|
): KnowledgeStore {
|
||||||
|
const key = storeKey(request.databaseRoot, request.accountId)
|
||||||
|
let store = stores.get(key)
|
||||||
|
if (!store) {
|
||||||
|
store = new KnowledgeStore(request.databaseRoot, request.accountId, request.fts)
|
||||||
|
stores.set(key, store)
|
||||||
|
}
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeStore(databaseRoot: string, accountId: string): void {
|
||||||
|
const key = storeKey(databaseRoot, accountId)
|
||||||
|
const store = stores.get(key)
|
||||||
|
if (store) store.close()
|
||||||
|
stores.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIndex(
|
||||||
|
request: KnowledgeWorkerRequest,
|
||||||
|
payload: KnowledgeIndexRequest
|
||||||
|
): Promise<void> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
controllers.set(request.requestId, controller)
|
||||||
|
try {
|
||||||
|
const result = await getStore(payload).index(payload, controller.signal, (progress) => {
|
||||||
|
send({ version: 1, type: 'progress', requestId: request.requestId, payload: progress })
|
||||||
|
})
|
||||||
|
send({ version: 1, type: 'result', requestId: request.requestId, payload: result })
|
||||||
|
} finally {
|
||||||
|
controllers.delete(request.requestId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePreflight(
|
||||||
|
request: KnowledgeWorkerRequest,
|
||||||
|
payload: KnowledgeCapacityPreflightRequest
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await estimateKnowledgeCapacityPreflight(payload)
|
||||||
|
send({ version: 1, type: 'result', requestId: request.requestId, payload: result })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch(
|
||||||
|
request: KnowledgeWorkerRequest,
|
||||||
|
payload: KnowledgeSearchRequest
|
||||||
|
): Promise<void> {
|
||||||
|
const workerReceivedAt = Date.now()
|
||||||
|
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
sendSearchResult(
|
||||||
|
request,
|
||||||
|
{
|
||||||
|
state: 'unavailable',
|
||||||
|
evidence: [],
|
||||||
|
indexedMessageCount: 0,
|
||||||
|
indexedChunkCount: 0,
|
||||||
|
timings: emptyKnowledgeSearchTimings()
|
||||||
|
},
|
||||||
|
workerReceivedAt
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = getStore(payload).searchWithStatus(payload)
|
||||||
|
sendSearchResult(request, result, workerReceivedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatus(
|
||||||
|
request: KnowledgeWorkerRequest,
|
||||||
|
payload: KnowledgeStatusRequest
|
||||||
|
): Promise<void> {
|
||||||
|
const path = getKnowledgeDatabasePath(payload.databaseRoot, payload.accountId)
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
const unavailable: KnowledgeRuntimeStatus = {
|
||||||
|
accountId: payload.accountId,
|
||||||
|
state: 'unavailable',
|
||||||
|
indexedMessageCount: 0,
|
||||||
|
indexedChunkCount: 0,
|
||||||
|
sourceMessageCount: null,
|
||||||
|
processedMessages: 0,
|
||||||
|
totalMessages: null,
|
||||||
|
estimatedRemainingMs: null,
|
||||||
|
databaseBytes: 0,
|
||||||
|
walBytes: 0,
|
||||||
|
shmBytes: 0
|
||||||
|
}
|
||||||
|
send({ version: 1, type: 'result', requestId: request.requestId, payload: unavailable })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
send({
|
||||||
|
version: 1,
|
||||||
|
type: 'result',
|
||||||
|
requestId: request.requestId,
|
||||||
|
payload: getStore(payload).getRuntimeStatus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(request: KnowledgeWorkerRequest): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (request.type === 'cancel') {
|
||||||
|
const payload = request.payload as { targetRequestId: string }
|
||||||
|
controllers.get(payload.targetRequestId)?.abort()
|
||||||
|
send({ version: 1, type: 'result', requestId: request.requestId, payload: { removed: true } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request.type === 'close') {
|
||||||
|
for (const controller of controllers.values()) controller.abort()
|
||||||
|
for (const store of stores.values()) store.close()
|
||||||
|
stores.clear()
|
||||||
|
send({ version: 1, type: 'result', requestId: request.requestId, payload: { removed: true } })
|
||||||
|
process.disconnect?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request.type === 'remove') {
|
||||||
|
const payload = request.payload as { accountId: string; databaseRoot: string }
|
||||||
|
closeStore(payload.databaseRoot, payload.accountId)
|
||||||
|
removeKnowledgeDatabase(payload.databaseRoot, payload.accountId)
|
||||||
|
send({ version: 1, type: 'result', requestId: request.requestId, payload: { removed: true } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request.type === 'preflight') {
|
||||||
|
await handlePreflight(request, request.payload as KnowledgeCapacityPreflightRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request.type === 'search') {
|
||||||
|
await handleSearch(request, request.payload as KnowledgeSearchRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request.type === 'status') {
|
||||||
|
await handleStatus(request, request.payload as KnowledgeStatusRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request.type === 'index') {
|
||||||
|
await handleIndex(request, request.payload as KnowledgeIndexRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported knowledge worker request: ${String(request.type)}`)
|
||||||
|
} catch (error) {
|
||||||
|
send({
|
||||||
|
version: 1,
|
||||||
|
type: 'error',
|
||||||
|
requestId: request.requestId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('message', (message: KnowledgeWorkerRequest) => {
|
||||||
|
if (message?.version !== 1) return
|
||||||
|
void handle(message)
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { createHash } from 'crypto'
|
||||||
|
import type {
|
||||||
|
KnowledgeNormalizedMessage,
|
||||||
|
KnowledgeSourceMessage
|
||||||
|
} from '../../shared/knowledge'
|
||||||
|
|
||||||
|
const compact = (value: string | undefined): string => value?.replace(/\s+/g, ' ').trim() || ''
|
||||||
|
|
||||||
|
function digest(value: string): string {
|
||||||
|
return createHash('sha256').update(value).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a read-only archive record into text safe for local search. Paths,
|
||||||
|
* binary media and raw voice data are deliberately excluded.
|
||||||
|
*/
|
||||||
|
export function normalizeKnowledgeMessage(
|
||||||
|
source: KnowledgeSourceMessage
|
||||||
|
): KnowledgeNormalizedMessage {
|
||||||
|
const sections: string[] = []
|
||||||
|
const messageText = compact(source.text)
|
||||||
|
if (messageText) sections.push(messageText)
|
||||||
|
|
||||||
|
const transcript = compact(source.voiceTranscript)
|
||||||
|
if (transcript) sections.push(`语音转写:${transcript}`)
|
||||||
|
|
||||||
|
const attachmentName = compact(source.attachment?.name)
|
||||||
|
if (attachmentName) {
|
||||||
|
const label = source.attachment?.kind === 'link' ? '链接' : '附件'
|
||||||
|
sections.push(`${label}:${attachmentName}`)
|
||||||
|
}
|
||||||
|
const url = compact(source.attachment?.url)
|
||||||
|
if (url) sections.push(`地址:${url}`)
|
||||||
|
|
||||||
|
const searchableText = sections.join('\n')
|
||||||
|
return {
|
||||||
|
...source,
|
||||||
|
text: messageText || undefined,
|
||||||
|
voiceTranscript: transcript || undefined,
|
||||||
|
searchableText,
|
||||||
|
contentHash: digest(
|
||||||
|
JSON.stringify({
|
||||||
|
messageId: source.messageId,
|
||||||
|
createTime: source.createTime,
|
||||||
|
senderId: source.senderId || '',
|
||||||
|
kind: source.kind,
|
||||||
|
searchableText
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIndexableKnowledgeMessage(message: KnowledgeNormalizedMessage): boolean {
|
||||||
|
return Boolean(message.searchableText.trim())
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { KnowledgeWorkerRequest, KnowledgeWorkerResponse } from '../../shared/knowledge'
|
||||||
|
|
||||||
|
export const KNOWLEDGE_WORKER_PROTOCOL_VERSION = 1 as const
|
||||||
|
|
||||||
|
export type WorkerKnowledgeRequest = KnowledgeWorkerRequest
|
||||||
|
export type WorkerKnowledgeResponse = KnowledgeWorkerResponse
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import type { AiSearchAgentToolName, AiSearchAgentTraceItem } from '../../shared/ai-search'
|
||||||
|
|
||||||
|
export const MAX_AGENT_TOOL_CALLS = 5
|
||||||
|
|
||||||
|
export type AgentAction =
|
||||||
|
| { action: 'tool'; tool: AiSearchAgentToolName; arguments: Record<string, unknown> }
|
||||||
|
| { action: 'finalize'; reason: string }
|
||||||
|
|
||||||
|
export interface AgentToolResult {
|
||||||
|
summary: Record<string, unknown>
|
||||||
|
candidateCount: number
|
||||||
|
/** A host-owned coverage signal, never supplied by the model. */
|
||||||
|
finalizeReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlledSearchAgentOptions {
|
||||||
|
question: string
|
||||||
|
scopeLabel: string
|
||||||
|
rangeLabel: string
|
||||||
|
maxToolCalls?: number
|
||||||
|
decide: (prompt: string) => Promise<string | undefined>
|
||||||
|
execute: (action: Extract<AgentAction, { action: 'tool' }>) => Promise<AgentToolResult>
|
||||||
|
onTrace: (item: Omit<AiSearchAgentTraceItem, 'sequence'>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ControlledSearchAgentResult {
|
||||||
|
status: 'finalized' | 'exhausted' | 'invalid'
|
||||||
|
toolCalls: number
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOL_NAMES = new Set<AiSearchAgentToolName>([
|
||||||
|
'search_conversations',
|
||||||
|
'search_people',
|
||||||
|
'search_messages',
|
||||||
|
'get_conversation_messages',
|
||||||
|
'get_messages_by_time',
|
||||||
|
'get_message_context'
|
||||||
|
])
|
||||||
|
|
||||||
|
const parseAction = (value: string | undefined): AgentAction | null => {
|
||||||
|
if (!value) return null
|
||||||
|
const match = value.match(/\{[\s\S]*\}/)
|
||||||
|
if (!match) return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(match[0]) as Record<string, unknown>
|
||||||
|
if (parsed.action === 'finalize' && typeof parsed.reason === 'string' && parsed.reason.trim()) {
|
||||||
|
return { action: 'finalize', reason: parsed.reason.trim().slice(0, 240) }
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
parsed.action === 'tool' &&
|
||||||
|
typeof parsed.tool === 'string' &&
|
||||||
|
TOOL_NAMES.has(parsed.tool as AiSearchAgentToolName) &&
|
||||||
|
parsed.arguments &&
|
||||||
|
typeof parsed.arguments === 'object' &&
|
||||||
|
!Array.isArray(parsed.arguments)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
action: 'tool',
|
||||||
|
tool: parsed.tool as AiSearchAgentToolName,
|
||||||
|
arguments: parsed.arguments as Record<string, unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid model output is rejected by the caller and triggers legacy fallback.
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentSystemPrompt = (
|
||||||
|
question: string,
|
||||||
|
scopeLabel: string,
|
||||||
|
rangeLabel: string
|
||||||
|
): string => `你是 WechatExplorer 的受控本地聊天搜索代理,只负责决定下一步检索,不回答用户问题。
|
||||||
|
用户问题:${question}
|
||||||
|
允许范围:${scopeLabel};时间范围:${rangeLabel}。
|
||||||
|
|
||||||
|
你只能输出一个 JSON 对象,不能输出 Markdown、解释、代码、SQL、文件路径或任何系统操作。
|
||||||
|
唯一合法格式:
|
||||||
|
{"action":"tool","tool":"search_people|search_conversations|search_messages|get_conversation_messages|get_messages_by_time|get_message_context","arguments":{...}}
|
||||||
|
或:
|
||||||
|
{"action":"finalize","reason":"已有足够证据"}
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 只能使用此前 Tool 返回的 conversationRef/messageRef;不得猜测或创建引用。
|
||||||
|
- 问“我和某人最近聊了什么”时,优先 search_people 或 search_conversations,再 get_conversation_messages;不要把联系人名当消息关键词。
|
||||||
|
- 搜索会话没有结果时,可根据结果自行尝试更短或更自然的名称表达,但最多五次 Tool 调用。
|
||||||
|
- Tool 结果不足时可以改 Tool 或查询策略;结果充分时 finalize。
|
||||||
|
- 不要请求全部聊天记录;遵守 Tool 返回的受限结果。`
|
||||||
|
|
||||||
|
const traceArguments = (
|
||||||
|
argumentsValue: Record<string, unknown>
|
||||||
|
): Record<string, string | number | boolean> => {
|
||||||
|
const result: Record<string, string | number | boolean> = {}
|
||||||
|
if (typeof argumentsValue.query === 'string') result.query = argumentsValue.query.slice(0, 80)
|
||||||
|
if (typeof argumentsValue.limit === 'number') result.limit = argumentsValue.limit
|
||||||
|
if (typeof argumentsValue.startTime === 'number') result.startTime = argumentsValue.startTime
|
||||||
|
if (typeof argumentsValue.endTime === 'number') result.endTime = argumentsValue.endTime
|
||||||
|
if (typeof argumentsValue.conversationRef === 'string') result.target = '已选择会话'
|
||||||
|
if (typeof argumentsValue.messageRef === 'string') result.context = '已选择消息'
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runControlledSearchAgent(
|
||||||
|
options: ControlledSearchAgentOptions
|
||||||
|
): Promise<ControlledSearchAgentResult> {
|
||||||
|
let toolCalls = 0
|
||||||
|
let previousResult = '尚未执行 Tool。'
|
||||||
|
options.onTrace({ event: 'agentStart', label: '开始规划本次本地检索' })
|
||||||
|
|
||||||
|
const maxToolCalls = options.maxToolCalls || MAX_AGENT_TOOL_CALLS
|
||||||
|
while (toolCalls < maxToolCalls) {
|
||||||
|
const decisionStartedAt = Date.now()
|
||||||
|
const decisionInput = `${agentSystemPrompt(options.question, options.scopeLabel, options.rangeLabel)}\n\n上一次 Tool 结果:${previousResult}`
|
||||||
|
const output = await options.decide(decisionInput)
|
||||||
|
const decisionElapsedMs = Date.now() - decisionStartedAt
|
||||||
|
const action = parseAction(output)
|
||||||
|
if (!action) return { status: 'invalid', toolCalls, reason: 'Agent 返回的控制协议无效' }
|
||||||
|
if (action.action === 'finalize') {
|
||||||
|
options.onTrace({
|
||||||
|
event: 'agentDecision',
|
||||||
|
label: 'Agent 判断现有结果足够',
|
||||||
|
decision: action.reason,
|
||||||
|
decisionInput: decisionInput.slice(0, 8_000),
|
||||||
|
elapsedMs: decisionElapsedMs
|
||||||
|
})
|
||||||
|
return { status: 'finalized', toolCalls, reason: action.reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
options.onTrace({
|
||||||
|
event: 'agentDecision',
|
||||||
|
label: 'Agent 选择下一次检索',
|
||||||
|
toolName: action.tool,
|
||||||
|
elapsedMs: decisionElapsedMs,
|
||||||
|
decisionInput: decisionInput.slice(0, 8_000)
|
||||||
|
})
|
||||||
|
toolCalls += 1
|
||||||
|
options.onTrace({
|
||||||
|
event: 'toolCallStart',
|
||||||
|
label: '正在执行本地检索',
|
||||||
|
toolName: action.tool,
|
||||||
|
arguments: traceArguments(action.arguments)
|
||||||
|
})
|
||||||
|
const toolStartedAt = Date.now()
|
||||||
|
try {
|
||||||
|
const result = await options.execute(action)
|
||||||
|
const elapsedMs = Date.now() - toolStartedAt
|
||||||
|
options.onTrace({
|
||||||
|
event: 'toolCallEnd',
|
||||||
|
label: '本地检索完成',
|
||||||
|
toolName: action.tool,
|
||||||
|
resultCount: result.candidateCount,
|
||||||
|
elapsedMs
|
||||||
|
})
|
||||||
|
previousResult = JSON.stringify(result.summary)
|
||||||
|
if (result.finalizeReason) {
|
||||||
|
options.onTrace({
|
||||||
|
event: 'agentDecision',
|
||||||
|
label: '本地资料已覆盖所选时间范围,可直接整理回答',
|
||||||
|
decision: result.finalizeReason,
|
||||||
|
elapsedMs: 0
|
||||||
|
})
|
||||||
|
return { status: 'finalized', toolCalls, reason: result.finalizeReason }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const elapsedMs = Date.now() - toolStartedAt
|
||||||
|
const message = error instanceof Error ? error.message : '本次本地检索不可用'
|
||||||
|
options.onTrace({
|
||||||
|
event: 'toolCallEnd',
|
||||||
|
label: '本地检索未返回结果',
|
||||||
|
toolName: action.tool,
|
||||||
|
resultCount: 0,
|
||||||
|
elapsedMs,
|
||||||
|
decision: message.slice(0, 160)
|
||||||
|
})
|
||||||
|
previousResult = JSON.stringify({ error: message.slice(0, 160), results: [] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.onTrace({
|
||||||
|
event: 'agentDecision',
|
||||||
|
label: '已达到本次检索上限',
|
||||||
|
decision: `最多允许 ${maxToolCalls} 次本地检索`
|
||||||
|
})
|
||||||
|
return { status: 'exhausted', toolCalls, reason: '已达到本次检索上限' }
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import type {
|
||||||
|
AiSearchAggregation,
|
||||||
|
AiSearchFinalEvidence,
|
||||||
|
AiSearchPipelineEvidence
|
||||||
|
} from '../../shared/ai-search'
|
||||||
|
|
||||||
|
export type EvidenceBuildResult = {
|
||||||
|
evidence: AiSearchFinalEvidence[]
|
||||||
|
aggregation: AiSearchAggregation
|
||||||
|
candidateCount: number
|
||||||
|
deduplicatedCount: number
|
||||||
|
candidateRankingMs: number
|
||||||
|
evidenceBuildMs: number
|
||||||
|
aggregationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CitationValidationResult = {
|
||||||
|
answer: string
|
||||||
|
invalidCitationIds: string[]
|
||||||
|
status: 'valid' | 'sanitized'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const evidenceIdentity = (
|
||||||
|
item: Pick<AiSearchPipelineEvidence, 'conversationId' | 'messageId'>
|
||||||
|
): string => `${item.conversationId}\u0000${item.messageId}`
|
||||||
|
|
||||||
|
const compareEvidence = (left: AiSearchPipelineEvidence, right: AiSearchPipelineEvidence): number =>
|
||||||
|
(left.score ?? 0) - (right.score ?? 0) ||
|
||||||
|
right.timestamp - left.timestamp ||
|
||||||
|
evidenceIdentity(left).localeCompare(evidenceIdentity(right))
|
||||||
|
|
||||||
|
const personIdentity = (item: AiSearchFinalEvidence): string =>
|
||||||
|
item.senderId
|
||||||
|
? `sender:${item.senderId}`
|
||||||
|
: `conversation:${item.conversationId}:name:${item.sender}`
|
||||||
|
|
||||||
|
export function buildEvidenceAggregation(evidence: AiSearchFinalEvidence[]): AiSearchAggregation {
|
||||||
|
const people = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
messageCount: number
|
||||||
|
conversationIds: Set<string>
|
||||||
|
lastMessageAt: number
|
||||||
|
evidenceIds: AiSearchFinalEvidence['id'][]
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
const conversations = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: 'user' | 'group'
|
||||||
|
messageCount: number
|
||||||
|
people: Set<string>
|
||||||
|
lastMessageAt: number
|
||||||
|
evidenceIds: AiSearchFinalEvidence['id'][]
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
for (const item of evidence) {
|
||||||
|
const personId = personIdentity(item)
|
||||||
|
const person = people.get(personId) || {
|
||||||
|
id: personId,
|
||||||
|
name: item.sender,
|
||||||
|
messageCount: 0,
|
||||||
|
conversationIds: new Set<string>(),
|
||||||
|
lastMessageAt: item.timestamp,
|
||||||
|
evidenceIds: []
|
||||||
|
}
|
||||||
|
person.messageCount += 1
|
||||||
|
person.conversationIds.add(item.conversationId)
|
||||||
|
person.lastMessageAt = Math.max(person.lastMessageAt, item.timestamp)
|
||||||
|
person.evidenceIds.push(item.id)
|
||||||
|
people.set(personId, person)
|
||||||
|
|
||||||
|
const conversation = conversations.get(item.conversationId) || {
|
||||||
|
id: item.conversationId,
|
||||||
|
name: item.conversationName,
|
||||||
|
type: item.conversationType,
|
||||||
|
messageCount: 0,
|
||||||
|
people: new Set<string>(),
|
||||||
|
lastMessageAt: item.timestamp,
|
||||||
|
evidenceIds: []
|
||||||
|
}
|
||||||
|
conversation.messageCount += 1
|
||||||
|
conversation.people.add(personId)
|
||||||
|
conversation.lastMessageAt = Math.max(conversation.lastMessageAt, item.timestamp)
|
||||||
|
conversation.evidenceIds.push(item.id)
|
||||||
|
conversations.set(item.conversationId, conversation)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messageCount: evidence.length,
|
||||||
|
peopleCount: people.size,
|
||||||
|
conversationCount: conversations.size,
|
||||||
|
people: Array.from(people.values())
|
||||||
|
.map((person) => ({
|
||||||
|
id: person.id,
|
||||||
|
name: person.name,
|
||||||
|
messageCount: person.messageCount,
|
||||||
|
conversationCount: person.conversationIds.size,
|
||||||
|
lastMessageAt: person.lastMessageAt,
|
||||||
|
evidenceIds: person.evidenceIds
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.messageCount - left.messageCount || right.lastMessageAt - left.lastMessageAt
|
||||||
|
),
|
||||||
|
conversations: Array.from(conversations.values())
|
||||||
|
.map((conversation) => ({
|
||||||
|
id: conversation.id,
|
||||||
|
name: conversation.name,
|
||||||
|
type: conversation.type,
|
||||||
|
messageCount: conversation.messageCount,
|
||||||
|
peopleCount: conversation.people.size,
|
||||||
|
lastMessageAt: conversation.lastMessageAt,
|
||||||
|
evidenceIds: conversation.evidenceIds
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.messageCount - left.messageCount || right.lastMessageAt - left.lastMessageAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs all candidate ordering, identity de-duplication, final limiting and
|
||||||
|
* program-owned citation assignment in one place. Nothing downstream receives
|
||||||
|
* the candidate list as an AI context.
|
||||||
|
*/
|
||||||
|
export function buildFinalEvidence(
|
||||||
|
candidates: AiSearchPipelineEvidence[],
|
||||||
|
limit: number,
|
||||||
|
options?: { strategy?: 'ranked' | 'conversation_coverage' }
|
||||||
|
): EvidenceBuildResult {
|
||||||
|
const rankingStartedAt = Date.now()
|
||||||
|
const ranked = [...candidates].sort(compareEvidence)
|
||||||
|
const candidateRankingMs = Date.now() - rankingStartedAt
|
||||||
|
|
||||||
|
const evidenceStartedAt = Date.now()
|
||||||
|
const unique = new Map<string, AiSearchPipelineEvidence>()
|
||||||
|
for (const item of ranked) {
|
||||||
|
const identity = evidenceIdentity(item)
|
||||||
|
if (!unique.has(identity)) unique.set(identity, item)
|
||||||
|
}
|
||||||
|
const uniqueEvidence = Array.from(unique.values())
|
||||||
|
const selected =
|
||||||
|
options?.strategy === 'conversation_coverage'
|
||||||
|
? selectConversationCoverage(uniqueEvidence, limit)
|
||||||
|
: uniqueEvidence.slice(0, Math.max(1, limit))
|
||||||
|
const evidence = selected.map((item, index) => ({ ...item, id: `E${index + 1}` as const }))
|
||||||
|
const evidenceBuildMs = Date.now() - evidenceStartedAt
|
||||||
|
|
||||||
|
const aggregationStartedAt = Date.now()
|
||||||
|
const aggregation = buildEvidenceAggregation(evidence)
|
||||||
|
const aggregationMs = Date.now() - aggregationStartedAt
|
||||||
|
|
||||||
|
return {
|
||||||
|
evidence,
|
||||||
|
aggregation,
|
||||||
|
candidateCount: candidates.length,
|
||||||
|
deduplicatedCount: unique.size,
|
||||||
|
candidateRankingMs,
|
||||||
|
evidenceBuildMs,
|
||||||
|
aggregationMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recent-conversation answer should cover separate local conversation chunks,
|
||||||
|
* not merely pick eight adjacent newest messages from one exchange.
|
||||||
|
*/
|
||||||
|
function selectConversationCoverage(
|
||||||
|
evidence: AiSearchPipelineEvidence[],
|
||||||
|
limit: number
|
||||||
|
): AiSearchPipelineEvidence[] {
|
||||||
|
const max = Math.max(1, limit)
|
||||||
|
const byChunk = new Map<string, AiSearchPipelineEvidence[]>()
|
||||||
|
for (const item of evidence) {
|
||||||
|
const chunk = byChunk.get(item.chunkId) || []
|
||||||
|
chunk.push(item)
|
||||||
|
byChunk.set(item.chunkId, chunk)
|
||||||
|
}
|
||||||
|
const representatives = Array.from(byChunk.values())
|
||||||
|
.map((items) => [...items].sort(compareEvidence)[0])
|
||||||
|
.sort((left, right) => left.timestamp - right.timestamp)
|
||||||
|
if (representatives.length <= max) return representatives
|
||||||
|
const selected: AiSearchPipelineEvidence[] = []
|
||||||
|
for (let index = 0; index < max; index += 1) {
|
||||||
|
const position = Math.round((index * (representatives.length - 1)) / (max - 1 || 1))
|
||||||
|
const item = representatives[position]
|
||||||
|
if (item && !selected.includes(item)) selected.push(item)
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Do not expose citations that cannot resolve to program-owned Final Evidence. */
|
||||||
|
export function sanitizeAnswerCitations(
|
||||||
|
answer: string,
|
||||||
|
evidence: Array<Pick<AiSearchFinalEvidence, 'id'>>
|
||||||
|
): CitationValidationResult {
|
||||||
|
const allowed = new Set(evidence.map((item) => item.id))
|
||||||
|
const invalidCitationIds = new Set<string>()
|
||||||
|
const sanitized = answer.replace(/\[E(\d+)\]/g, (citation, number: string) => {
|
||||||
|
const id = `E${number}`
|
||||||
|
if (allowed.has(id as AiSearchFinalEvidence['id'])) return citation
|
||||||
|
invalidCitationIds.add(id)
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
answer: sanitized,
|
||||||
|
invalidCitationIds: Array.from(invalidCitationIds),
|
||||||
|
status: invalidCitationIds.size ? 'sanitized' : 'valid'
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,11 @@ import type { CacheClearScope, CacheSummary, CacheSummaryItem } from '../../shar
|
|||||||
export type { CacheClearScope } from '../../shared/cache'
|
export type { CacheClearScope } from '../../shared/cache'
|
||||||
|
|
||||||
const BOOTSTRAP_CACHE_DIR = path.join(app.getPath('userData'), 'cache', 'bootstrap')
|
const BOOTSTRAP_CACHE_DIR = path.join(app.getPath('userData'), 'cache', 'bootstrap')
|
||||||
|
const KNOWLEDGE_CACHE_DIR = path.join(app.getPath('userData'), 'knowledge')
|
||||||
|
|
||||||
|
export interface CacheClearOptions {
|
||||||
|
beforeClearKnowledge?: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
function inspectDirectory(directory: string): { sizeBytes: number; fileCount: number } {
|
function inspectDirectory(directory: string): { sizeBytes: number; fileCount: number } {
|
||||||
if (!fs.existsSync(directory)) return { sizeBytes: 0, fileCount: 0 }
|
if (!fs.existsSync(directory)) return { sizeBytes: 0, fileCount: 0 }
|
||||||
@@ -40,6 +45,7 @@ function inspectDirectory(directory: string): { sizeBytes: number; fileCount: nu
|
|||||||
export function getCacheSummary(): CacheSummary {
|
export function getCacheSummary(): CacheSummary {
|
||||||
const bootstrap = inspectDirectory(BOOTSTRAP_CACHE_DIR)
|
const bootstrap = inspectDirectory(BOOTSTRAP_CACHE_DIR)
|
||||||
const electron = inspectDirectory(path.join(app.getPath('userData'), 'Cache'))
|
const electron = inspectDirectory(path.join(app.getPath('userData'), 'Cache'))
|
||||||
|
const knowledge = inspectDirectory(KNOWLEDGE_CACHE_DIR)
|
||||||
const items: CacheSummaryItem[] = [
|
const items: CacheSummaryItem[] = [
|
||||||
{
|
{
|
||||||
id: 'bootstrap',
|
id: 'bootstrap',
|
||||||
@@ -52,6 +58,12 @@ export function getCacheSummary(): CacheSummary {
|
|||||||
label: '应用临时缓存',
|
label: '应用临时缓存',
|
||||||
description: 'Electron 页面资源缓存,清理后会自动重新生成。',
|
description: 'Electron 页面资源缓存,清理后会自动重新生成。',
|
||||||
...electron
|
...electron
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'knowledge',
|
||||||
|
label: '本地知识库索引',
|
||||||
|
description: '为问问微信建立的所有账号本地检索索引。清理后需手动重新建立,不影响微信原始数据。',
|
||||||
|
...knowledge
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
@@ -61,7 +73,10 @@ export function getCacheSummary(): CacheSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearCache(scope: CacheClearScope): Promise<CacheSummary> {
|
export async function clearCache(
|
||||||
|
scope: CacheClearScope,
|
||||||
|
options: CacheClearOptions = {}
|
||||||
|
): Promise<CacheSummary> {
|
||||||
if (scope === 'bootstrap' || scope === 'all') {
|
if (scope === 'bootstrap' || scope === 'all') {
|
||||||
clearBootstrapCache()
|
clearBootstrapCache()
|
||||||
await fs.remove(BOOTSTRAP_CACHE_DIR)
|
await fs.remove(BOOTSTRAP_CACHE_DIR)
|
||||||
@@ -69,5 +84,9 @@ export async function clearCache(scope: CacheClearScope): Promise<CacheSummary>
|
|||||||
if (scope === 'electron' || scope === 'all') {
|
if (scope === 'electron' || scope === 'all') {
|
||||||
await session.defaultSession.clearCache()
|
await session.defaultSession.clearCache()
|
||||||
}
|
}
|
||||||
|
if (scope === 'knowledge' || scope === 'all') {
|
||||||
|
await options.beforeClearKnowledge?.()
|
||||||
|
await fs.remove(KNOWLEDGE_CACHE_DIR)
|
||||||
|
}
|
||||||
return getCacheSummary()
|
return getCacheSummary()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Contact } from '../../shared/types'
|
||||||
|
import {
|
||||||
|
emptyContactResolution,
|
||||||
|
normalizeContactName,
|
||||||
|
type ContactResolutionCandidate,
|
||||||
|
type ContactResolutionMatch,
|
||||||
|
type ContactResolutionResult
|
||||||
|
} from '../../shared/contact-resolution'
|
||||||
|
|
||||||
|
export type ContactResolutionScope = 'any' | 'person' | 'group'
|
||||||
|
|
||||||
|
const displayName = (contact: Contact): string =>
|
||||||
|
contact.m_nsNickName || contact.remark || contact.wechatNickname || contact.m_nsUsrName
|
||||||
|
|
||||||
|
const aliases = (contact: Contact): Array<{ value: string; primary: boolean }> =>
|
||||||
|
[
|
||||||
|
{ value: contact.m_nsNickName, primary: true },
|
||||||
|
{ value: contact.remark || '', primary: false },
|
||||||
|
{ value: contact.wechatNickname || '', primary: false },
|
||||||
|
{ value: contact.m_nsUsrName, primary: false }
|
||||||
|
].filter((item) => Boolean(normalizeContactName(item.value)))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one main-process authority that converts a user/Agent supplied name to
|
||||||
|
* an existing conversation. It only auto-confirms an exact canonical alias.
|
||||||
|
* Fuzzy discovery intentionally returns candidates rather than a guessed ID.
|
||||||
|
*/
|
||||||
|
export function resolveContact(
|
||||||
|
query: string,
|
||||||
|
contacts: Contact[],
|
||||||
|
scope: ContactResolutionScope = 'any'
|
||||||
|
): ContactResolutionResult {
|
||||||
|
const normalizedQuery = normalizeContactName(query)
|
||||||
|
if (!normalizedQuery) return emptyContactResolution()
|
||||||
|
const matches = new Map<string, { contact: Contact; matchedBy: ContactResolutionMatch }>()
|
||||||
|
|
||||||
|
for (const contact of contacts) {
|
||||||
|
if (!contact.md5) continue
|
||||||
|
if (scope === 'person' && contact.type !== 'user') continue
|
||||||
|
if (scope === 'group' && contact.type !== 'group') continue
|
||||||
|
for (const alias of aliases(contact)) {
|
||||||
|
if (normalizeContactName(alias.value) !== normalizedQuery) continue
|
||||||
|
const rawExact =
|
||||||
|
alias.value.trim().normalize('NFKC').toLocaleLowerCase() ===
|
||||||
|
query.trim().normalize('NFKC').toLocaleLowerCase()
|
||||||
|
const matchedBy: ContactResolutionMatch = rawExact
|
||||||
|
? 'exact'
|
||||||
|
: alias.primary
|
||||||
|
? 'normalized'
|
||||||
|
: 'alias'
|
||||||
|
const current = matches.get(contact.md5)
|
||||||
|
if (!current || (current.matchedBy === 'alias' && matchedBy !== 'alias')) {
|
||||||
|
matches.set(contact.md5, { contact, matchedBy })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates: ContactResolutionCandidate[] = Array.from(matches.values())
|
||||||
|
.map(({ contact, matchedBy }) => ({
|
||||||
|
conversationId: contact.md5,
|
||||||
|
displayName: displayName(contact),
|
||||||
|
matchedBy,
|
||||||
|
confidence: 1
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.displayName.localeCompare(right.displayName, 'zh-CN'))
|
||||||
|
if (candidates.length !== 1) {
|
||||||
|
return {
|
||||||
|
...emptyContactResolution(),
|
||||||
|
candidates,
|
||||||
|
ambiguous: candidates.length > 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const candidate = candidates[0]
|
||||||
|
const contact = matches.get(candidate.conversationId)!.contact
|
||||||
|
return {
|
||||||
|
matched: true,
|
||||||
|
personId: contact.m_nsUsrName,
|
||||||
|
conversationId: contact.md5,
|
||||||
|
canonicalName: displayName(contact),
|
||||||
|
displayName: candidate.displayName,
|
||||||
|
matchedBy: candidate.matchedBy,
|
||||||
|
confidence: candidate.confidence,
|
||||||
|
candidates,
|
||||||
|
ambiguous: false
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+17
-1
@@ -52,6 +52,16 @@ import type {
|
|||||||
VoiceModelStatus,
|
VoiceModelStatus,
|
||||||
VoiceRecognitionResult
|
VoiceRecognitionResult
|
||||||
} from '../shared/voice-recognition'
|
} from '../shared/voice-recognition'
|
||||||
|
import type {
|
||||||
|
AiSearchPipelineRequest,
|
||||||
|
AiSearchPipelineResult,
|
||||||
|
AiSearchProgressEvent
|
||||||
|
} from '../shared/ai-search'
|
||||||
|
import type {
|
||||||
|
KnowledgeRuntimeStatus,
|
||||||
|
KnowledgeSearchIpcRequest,
|
||||||
|
KnowledgeSearchIpcResult
|
||||||
|
} from '../shared/knowledge'
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| { type: 'text'; content: string }
|
| { type: 'text'; content: string }
|
||||||
@@ -121,7 +131,7 @@ declare global {
|
|||||||
installAppUpdate: () => Promise<{ success: boolean; error?: string }>
|
installAppUpdate: () => Promise<{ success: boolean; error?: string }>
|
||||||
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
|
onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void
|
||||||
getCacheSummary: () => Promise<CacheSummary>
|
getCacheSummary: () => Promise<CacheSummary>
|
||||||
clearCache: (scope: 'bootstrap' | 'electron' | 'all') => Promise<CacheSummary>
|
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all') => Promise<CacheSummary>
|
||||||
initDb: (
|
initDb: (
|
||||||
key: string,
|
key: string,
|
||||||
accountRoot: string
|
accountRoot: string
|
||||||
@@ -183,6 +193,12 @@ declare global {
|
|||||||
}[]
|
}[]
|
||||||
} | null>
|
} | null>
|
||||||
search: (keyword: string) => Promise<string | null>
|
search: (keyword: string) => Promise<string | null>
|
||||||
|
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
|
||||||
|
runAiSearch: (request: AiSearchPipelineRequest) => Promise<AiSearchPipelineResult>
|
||||||
|
onAiSearchProgress: (callback: (progress: AiSearchProgressEvent) => void) => () => void
|
||||||
|
getKnowledgeStatus: () => Promise<KnowledgeRuntimeStatus>
|
||||||
|
startKnowledgeIndex: () => Promise<KnowledgeRuntimeStatus>
|
||||||
|
onKnowledgeStatus: (callback: (status: KnowledgeRuntimeStatus) => void) => () => void
|
||||||
aiChat: (
|
aiChat: (
|
||||||
messages: { role: string; content: string }[],
|
messages: { role: string; content: string }[],
|
||||||
options?: AIChatRequestOptions
|
options?: AIChatRequestOptions
|
||||||
|
|||||||
+31
-1
@@ -29,6 +29,16 @@ import type {
|
|||||||
VoiceModelStatus,
|
VoiceModelStatus,
|
||||||
VoiceRecognitionResult
|
VoiceRecognitionResult
|
||||||
} from '../shared/voice-recognition'
|
} from '../shared/voice-recognition'
|
||||||
|
import type {
|
||||||
|
AiSearchPipelineRequest,
|
||||||
|
AiSearchPipelineResult,
|
||||||
|
AiSearchProgressEvent
|
||||||
|
} from '../shared/ai-search'
|
||||||
|
import type {
|
||||||
|
KnowledgeRuntimeStatus,
|
||||||
|
KnowledgeSearchIpcRequest,
|
||||||
|
KnowledgeSearchIpcResult
|
||||||
|
} from '../shared/knowledge'
|
||||||
|
|
||||||
// 渲染器的自定义 API
|
// 渲染器的自定义 API
|
||||||
const api = {
|
const api = {
|
||||||
@@ -46,7 +56,7 @@ const api = {
|
|||||||
return () => ipcRenderer.removeListener('app-update:state', listener)
|
return () => ipcRenderer.removeListener('app-update:state', listener)
|
||||||
},
|
},
|
||||||
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
|
getCacheSummary: (): Promise<CacheSummary> => ipcRenderer.invoke('cache:getSummary'),
|
||||||
clearCache: (scope: 'bootstrap' | 'electron' | 'all'): Promise<CacheSummary> =>
|
clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all'): Promise<CacheSummary> =>
|
||||||
ipcRenderer.invoke('cache:clear', scope),
|
ipcRenderer.invoke('cache:clear', scope),
|
||||||
initDb: (key: string, accountRoot: string) => ipcRenderer.invoke('db:init', key, accountRoot),
|
initDb: (key: string, accountRoot: string) => ipcRenderer.invoke('db:init', key, accountRoot),
|
||||||
discoverAccounts: (inputPath: string): Promise<AccountDiscoveryResult> =>
|
discoverAccounts: (inputPath: string): Promise<AccountDiscoveryResult> =>
|
||||||
@@ -67,6 +77,26 @@ const api = {
|
|||||||
) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options),
|
) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options),
|
||||||
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
||||||
search: (keyword: string) => ipcRenderer.invoke('db:search', keyword),
|
search: (keyword: string) => ipcRenderer.invoke('db:search', keyword),
|
||||||
|
searchKnowledge: (request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> =>
|
||||||
|
ipcRenderer.invoke('knowledge:search', request),
|
||||||
|
runAiSearch: (request: AiSearchPipelineRequest): Promise<AiSearchPipelineResult> =>
|
||||||
|
ipcRenderer.invoke('ai-search:run', request),
|
||||||
|
onAiSearchProgress: (callback: (progress: AiSearchProgressEvent) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, progress: AiSearchProgressEvent): void =>
|
||||||
|
callback(progress)
|
||||||
|
ipcRenderer.on('ai-search:progress', listener)
|
||||||
|
return () => ipcRenderer.removeListener('ai-search:progress', listener)
|
||||||
|
},
|
||||||
|
getKnowledgeStatus: (): Promise<KnowledgeRuntimeStatus> =>
|
||||||
|
ipcRenderer.invoke('knowledge:getStatus'),
|
||||||
|
startKnowledgeIndex: (): Promise<KnowledgeRuntimeStatus> =>
|
||||||
|
ipcRenderer.invoke('knowledge:startIndex'),
|
||||||
|
onKnowledgeStatus: (callback: (status: KnowledgeRuntimeStatus) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, status: KnowledgeRuntimeStatus): void =>
|
||||||
|
callback(status)
|
||||||
|
ipcRenderer.on('knowledge:status', listener)
|
||||||
|
return () => ipcRenderer.removeListener('knowledge:status', listener)
|
||||||
|
},
|
||||||
aiChat: (messages: { role: string; content: string }[], options?: AIChatRequestOptions) =>
|
aiChat: (messages: { role: string; content: string }[], options?: AIChatRequestOptions) =>
|
||||||
ipcRenderer.invoke('ai:chat', messages, options),
|
ipcRenderer.invoke('ai:chat', messages, options),
|
||||||
listAIProviders: () => ipcRenderer.invoke('ai:listProviders'),
|
listAIProviders: () => ipcRenderer.invoke('ai:listProviders'),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,16 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
const inlineMarkdown = (value: string, keyPrefix: string): React.ReactNode[] =>
|
type MarkdownOptions = {
|
||||||
value.split(/(\*\*.*?\*\*|`.*?`|\*.*?\*)/g).map((part, index) => {
|
evidenceCount?: number
|
||||||
|
onEvidenceClick?: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineMarkdown = (
|
||||||
|
value: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
options: MarkdownOptions = {}
|
||||||
|
): React.ReactNode[] =>
|
||||||
|
value.split(/(\*\*.*?\*\*|`.*?`|\*.*?\*|\[E\d+\])/g).map((part, index) => {
|
||||||
const key = `${keyPrefix}-${index}`
|
const key = `${keyPrefix}-${index}`
|
||||||
if (part.startsWith('**') && part.endsWith('**')) {
|
if (part.startsWith('**') && part.endsWith('**')) {
|
||||||
return <strong key={key}>{part.slice(2, -2)}</strong>
|
return <strong key={key}>{part.slice(2, -2)}</strong>
|
||||||
@@ -12,24 +21,45 @@ const inlineMarkdown = (value: string, keyPrefix: string): React.ReactNode[] =>
|
|||||||
if (part.startsWith('*') && part.endsWith('*')) {
|
if (part.startsWith('*') && part.endsWith('*')) {
|
||||||
return <em key={key}>{part.slice(1, -1)}</em>
|
return <em key={key}>{part.slice(1, -1)}</em>
|
||||||
}
|
}
|
||||||
|
const evidence = /^\[E(\d+)\]$/.exec(part)
|
||||||
|
if (evidence) {
|
||||||
|
const evidenceIndex = Number(evidence[1]) - 1
|
||||||
|
if (
|
||||||
|
evidenceIndex >= 0 &&
|
||||||
|
evidenceIndex < (options.evidenceCount || 0) &&
|
||||||
|
options.onEvidenceClick
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
className="ai-search-inline-evidence"
|
||||||
|
onClick={() => options.onEvidenceClick?.(evidenceIndex)}
|
||||||
|
title={`查看证据 E${evidenceIndex + 1}`}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
return <React.Fragment key={key}>{part}</React.Fragment>
|
return <React.Fragment key={key}>{part}</React.Fragment>
|
||||||
})
|
})
|
||||||
|
|
||||||
export const renderMarkdown = (value: string): React.ReactNode =>
|
export const renderMarkdown = (value: string, options: MarkdownOptions = {}): React.ReactNode =>
|
||||||
value.split(/\r?\n/).map((line, index) => {
|
value.split(/\r?\n/).map((line, index) => {
|
||||||
const key = `markdown-${index}`
|
const key = `markdown-${index}`
|
||||||
if (!line.trim()) return <div key={key} className="ai-search-markdown-spacer" />
|
if (!line.trim()) return <div key={key} className="ai-search-markdown-spacer" />
|
||||||
const heading = /^(#{1,3})\s+(.+)$/.exec(line)
|
const heading = /^(#{1,3})\s+(.+)$/.exec(line)
|
||||||
if (heading) {
|
if (heading) {
|
||||||
const Heading = `h${heading[1].length}` as 'h1' | 'h2' | 'h3'
|
const Heading = `h${heading[1].length}` as 'h1' | 'h2' | 'h3'
|
||||||
return <Heading key={key}>{inlineMarkdown(heading[2], key)}</Heading>
|
return <Heading key={key}>{inlineMarkdown(heading[2], key, options)}</Heading>
|
||||||
}
|
}
|
||||||
const bullet = /^\s*[-*]\s+(.+)$/.exec(line)
|
const bullet = /^\s*[-*]\s+(.+)$/.exec(line)
|
||||||
if (bullet) {
|
if (bullet) {
|
||||||
return (
|
return (
|
||||||
<div key={key} className="ai-search-markdown-list-item">
|
<div key={key} className="ai-search-markdown-list-item">
|
||||||
<span aria-hidden>•</span>
|
<span aria-hidden>•</span>
|
||||||
<span>{inlineMarkdown(bullet[1], key)}</span>
|
<span>{inlineMarkdown(bullet[1], key, options)}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -38,9 +68,9 @@ export const renderMarkdown = (value: string): React.ReactNode =>
|
|||||||
return (
|
return (
|
||||||
<div key={key} className="ai-search-markdown-list-item">
|
<div key={key} className="ai-search-markdown-list-item">
|
||||||
<span aria-hidden>{line.trim().match(/^\d+/)?.[0]}.</span>
|
<span aria-hidden>{line.trim().match(/^\d+/)?.[0]}.</span>
|
||||||
<span>{inlineMarkdown(numbered[1], key)}</span>
|
<span>{inlineMarkdown(numbered[1], key, options)}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <p key={key}>{inlineMarkdown(line, key)}</p>
|
return <p key={key}>{inlineMarkdown(line, key, options)}</p>
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||||
import type { Contact, Message } from '../../../../shared/types'
|
import type { Contact, Message } from '../../../../shared/types'
|
||||||
|
|
||||||
export type SearchStage = 'idle' | 'loading' | 'result' | 'insufficient'
|
export type SearchStage = 'idle' | 'loading' | 'result' | 'partial' | 'insufficient'
|
||||||
export type SearchScope = 'global' | 'conversation'
|
export type SearchScope = 'global' | 'groups' | 'contacts' | 'conversation'
|
||||||
export type SearchRange = 'today' | '7d' | '30d' | 'all'
|
export type SearchRange = 'today' | '7d' | '30d' | 'all'
|
||||||
export type SearchIntent = 'general' | 'topic' | 'participants' | 'mixed'
|
export type SearchIntent = 'general' | 'topic' | 'participants' | 'mixed'
|
||||||
|
|
||||||
export interface EvidenceItem {
|
export interface EvidenceItem {
|
||||||
|
/** Program-owned Final Evidence ID. Cached legacy records may omit it. */
|
||||||
|
evidenceId?: string
|
||||||
contact: Contact
|
contact: Contact
|
||||||
message: Message
|
message: Message
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export const RANGE_LABELS: Record<SearchRange, string> = {
|
|||||||
all: '全部历史'
|
all: '全部历史'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SEARCH_CACHE_KEY = 'wxe_ai_search_cache_v8'
|
// Final Evidence IDs are now program-owned; never replay answers cached under
|
||||||
|
// the former candidate-context contract.
|
||||||
|
export const SEARCH_CACHE_KEY = 'wxe_ai_search_cache_v9'
|
||||||
export const SEARCH_HISTORY_KEY = 'wxe_ai_search_history_v1'
|
export const SEARCH_HISTORY_KEY = 'wxe_ai_search_history_v1'
|
||||||
export const SEARCH_CACHE_LIMIT = 20
|
export const SEARCH_CACHE_LIMIT = 20
|
||||||
export const currentTimestamp = (): number => Date.now()
|
export const currentTimestamp = (): number => Date.now()
|
||||||
@@ -270,7 +272,8 @@ export const senderName = (message: Message, contact: Contact, names: Record<str
|
|||||||
return contact.type === 'user' ? contact.m_nsNickName || '联系人' : '群成员'
|
return contact.type === 'user' ? contact.m_nsNickName || '联系人' : '群成员'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const compactCacheItem = ({ contact, message }: EvidenceItem): EvidenceItem => ({
|
export const compactCacheItem = ({ evidenceId, contact, message }: EvidenceItem): EvidenceItem => ({
|
||||||
|
evidenceId,
|
||||||
contact: {
|
contact: {
|
||||||
md5: contact.md5,
|
md5: contact.md5,
|
||||||
m_nsUsrName: contact.m_nsUsrName,
|
m_nsUsrName: contact.m_nsUsrName,
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ function formatBytes(value: number): string {
|
|||||||
|
|
||||||
export function CacheCleanupPage({ onNotice }: { onNotice: (message: string) => void }): React.ReactElement {
|
export function CacheCleanupPage({ onNotice }: { onNotice: (message: string) => void }): React.ReactElement {
|
||||||
const [summary, setSummary] = useState<CacheSummary | null>(null)
|
const [summary, setSummary] = useState<CacheSummary | null>(null)
|
||||||
const [busyScope, setBusyScope] = useState<'bootstrap' | 'electron' | 'all' | 'local' | null>(null)
|
const [busyScope, setBusyScope] = useState<
|
||||||
|
'bootstrap' | 'electron' | 'knowledge' | 'all' | 'local' | null
|
||||||
|
>(null)
|
||||||
|
|
||||||
const refresh = useCallback(async (): Promise<void> => {
|
const refresh = useCallback(async (): Promise<void> => {
|
||||||
setSummary(await window.api.getCacheSummary())
|
setSummary(await window.api.getCacheSummary())
|
||||||
@@ -29,14 +31,22 @@ export function CacheCleanupPage({ onNotice }: { onNotice: (message: string) =>
|
|||||||
onNotice('已清理检索和导出本地缓存')
|
onNotice('已清理检索和导出本地缓存')
|
||||||
}
|
}
|
||||||
|
|
||||||
const clear = async (scope: 'bootstrap' | 'electron' | 'all'): Promise<void> => {
|
const clear = async (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all'): Promise<void> => {
|
||||||
setBusyScope(scope)
|
setBusyScope(scope)
|
||||||
try {
|
try {
|
||||||
|
setSummary(await window.api.clearCache(scope))
|
||||||
if (scope === 'all') {
|
if (scope === 'all') {
|
||||||
for (const key of SEARCH_CACHE_KEYS) localStorage.removeItem(key)
|
for (const key of SEARCH_CACHE_KEYS) localStorage.removeItem(key)
|
||||||
}
|
}
|
||||||
setSummary(await window.api.clearCache(scope))
|
onNotice(
|
||||||
onNotice(scope === 'all' ? '已清理全部可恢复缓存和检索记录' : '缓存已清理')
|
scope === 'knowledge'
|
||||||
|
? '已清理所有账号的本地知识库索引,需要时可在问问微信中重新建立'
|
||||||
|
: scope === 'all'
|
||||||
|
? '已清理全部可恢复缓存和检索记录'
|
||||||
|
: '缓存已清理'
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
onNotice(error instanceof Error ? error.message : '清理缓存失败')
|
||||||
} finally {
|
} finally {
|
||||||
setBusyScope(null)
|
setBusyScope(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-search-scope-panel {
|
.ai-search-scope-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
padding: 16px 12px;
|
padding: 16px 12px;
|
||||||
border-right: 1px solid var(--wxex-border);
|
border-right: 1px solid var(--wxex-border);
|
||||||
}
|
}
|
||||||
@@ -152,7 +155,252 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--wxex-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-card.building .ai-search-knowledge-dot,
|
||||||
|
.ai-search-knowledge-card.syncing .ai-search-knowledge-dot {
|
||||||
|
background: var(--wxex-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-card.ready .ai-search-knowledge-dot {
|
||||||
|
background: var(--wxex-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-card.error .ai-search-knowledge-dot {
|
||||||
|
background: var(--wxex-danger, #c15d4d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-filter-section {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-secondary-menu,
|
||||||
|
.ai-search-time-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-secondary-menu button,
|
||||||
|
.ai-search-time-menu button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-secondary-menu button:hover,
|
||||||
|
.ai-search-time-menu button:hover,
|
||||||
|
.ai-search-secondary-menu button.active,
|
||||||
|
.ai-search-time-menu button.active {
|
||||||
|
background: var(--wxex-brand-soft);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-secondary-menu button:disabled {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-secondary-menu button span,
|
||||||
|
.ai-search-time-menu button span {
|
||||||
|
width: 18px;
|
||||||
|
color: currentColor;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-time-section {
|
||||||
|
padding-bottom: 3px;
|
||||||
|
border-bottom: 1px solid var(--wxex-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-card.building,
|
||||||
|
.ai-search-knowledge-card.syncing {
|
||||||
|
border-color: color-mix(in srgb, var(--wxex-brand) 55%, var(--wxex-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-card.ready {
|
||||||
|
border-color: color-mix(in srgb, var(--wxex-success) 55%, var(--wxex-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-card.error {
|
||||||
|
border-color: color-mix(in srgb, var(--wxex-warning) 70%, var(--wxex-border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-card-heading,
|
||||||
|
.ai-search-sync-progress-top,
|
||||||
|
.ai-search-knowledge-details > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-card-heading > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-card-heading span:first-child {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-card-heading strong {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-description,
|
||||||
|
.ai-search-knowledge-error,
|
||||||
|
.ai-search-knowledge-more p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-error {
|
||||||
|
color: var(--wxex-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-sync-progress {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-sync-progress-top {
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-sync-progress-track {
|
||||||
|
height: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--wxex-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-sync-progress-track span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
transition: width 250ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-card.building .ai-search-sync-progress-track span,
|
||||||
|
.ai-search-knowledge-card.syncing .ai-search-sync-progress-track span {
|
||||||
|
animation: ai-search-sync-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-search-sync-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-details {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-details span {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-details strong {
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-action {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
border: 1px solid var(--wxex-brand);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: var(--wxex-brand);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-action:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-more {
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-more summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-search-local-badge,
|
.ai-search-local-badge,
|
||||||
@@ -566,10 +814,156 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-search-loading {
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-top: clamp(34px, 8vh, 88px);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
> h2,
|
||||||
|
> p {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-pipeline {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
margin-top: 24px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-pipeline-step {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--wxex-border);
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
max-width: none;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.done strong {
|
||||||
|
color: var(--wxex-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: color-mix(in srgb, var(--wxex-brand-soft) 58%, transparent);
|
||||||
|
|
||||||
|
strong,
|
||||||
|
p {
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: #fff8ef;
|
||||||
|
|
||||||
|
strong,
|
||||||
|
p {
|
||||||
|
color: var(--wxex-warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-pipeline-mark {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
margin-top: 1px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--wxex-bg-main);
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-pipeline-step.done .ai-search-pipeline-mark {
|
||||||
|
border-color: color-mix(in srgb, var(--wxex-success) 45%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--wxex-success) 11%, transparent);
|
||||||
|
color: var(--wxex-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-pipeline-step.active .ai-search-pipeline-mark {
|
||||||
|
border-color: var(--wxex-brand);
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
animation: ai-search-pipeline-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-pipeline-step.error .ai-search-pipeline-mark {
|
||||||
|
border-color: var(--wxex-warning);
|
||||||
|
background: #fff1df;
|
||||||
|
color: var(--wxex-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-search-pipeline-pulse {
|
||||||
|
50% {
|
||||||
|
transform: scale(1.08);
|
||||||
|
box-shadow: 0 0 0 5px color-mix(in srgb, var(--wxex-brand) 10%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-pipeline-details {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 7px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--wxex-bg-sidebar);
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ai-search-result {
|
.ai-search-result {
|
||||||
width: min(820px, calc(100% - 48px));
|
width: min(820px, calc(100% - 48px));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 28px 0 34px;
|
padding: 28px 0 34px;
|
||||||
|
animation: ai-search-result-in 260ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-search-result-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-search-result-header {
|
.ai-search-result-header {
|
||||||
@@ -617,6 +1011,41 @@
|
|||||||
|
|
||||||
.ai-search-summary-block {
|
.ai-search-summary-block {
|
||||||
margin-top: 22px;
|
margin-top: 22px;
|
||||||
|
animation: ai-search-result-in 340ms 45ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-answer > * {
|
||||||
|
animation: ai-search-answer-line-in 260ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-answer > *:nth-child(1) {
|
||||||
|
animation-delay: 70ms;
|
||||||
|
}
|
||||||
|
.ai-search-answer > *:nth-child(2) {
|
||||||
|
animation-delay: 105ms;
|
||||||
|
}
|
||||||
|
.ai-search-answer > *:nth-child(3) {
|
||||||
|
animation-delay: 140ms;
|
||||||
|
}
|
||||||
|
.ai-search-answer > *:nth-child(4) {
|
||||||
|
animation-delay: 175ms;
|
||||||
|
}
|
||||||
|
.ai-search-answer > *:nth-child(5) {
|
||||||
|
animation-delay: 210ms;
|
||||||
|
}
|
||||||
|
.ai-search-answer > *:nth-child(n + 6) {
|
||||||
|
animation-delay: 245ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-search-answer-line-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-search-section-heading {
|
.ai-search-section-heading {
|
||||||
@@ -647,6 +1076,176 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-search-answer-evidence,
|
||||||
|
.ai-search-trace {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-answer-evidence {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-top: 0;
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-answer-evidence button {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--wxex-ai);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--wxex-ai);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-inline-evidence {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 22px;
|
||||||
|
margin: 0 2px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary-color) 35%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||||
|
color: var(--primary-color);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-inline-evidence:hover {
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-trace {
|
||||||
|
margin-top: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-trace span {
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--wxex-bg-sidebar);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-details {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
summary {
|
||||||
|
width: fit-content;
|
||||||
|
color: var(--wxex-brand);
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary::after {
|
||||||
|
content: ' ▾';
|
||||||
|
}
|
||||||
|
|
||||||
|
&[open] summary::after {
|
||||||
|
content: ' ▴';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-details-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-sm);
|
||||||
|
background: var(--wxex-bg-sidebar);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-partial {
|
||||||
|
.ai-search-details {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
margin-top: 18px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-status {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-status > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-status > div span,
|
||||||
|
.ai-search-knowledge-status p,
|
||||||
|
.ai-search-knowledge-status small {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-status > div span {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-status strong {
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-status.building,
|
||||||
|
.ai-search-knowledge-status.syncing {
|
||||||
|
border-color: var(--wxex-ai);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-status.ready strong {
|
||||||
|
color: var(--wxex-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-knowledge-status.error {
|
||||||
|
border-color: var(--wxex-warning);
|
||||||
|
}
|
||||||
|
|
||||||
.ai-search-insufficient-icon {
|
.ai-search-insufficient-icon {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
@@ -661,6 +1260,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-search-composer {
|
.ai-search-composer {
|
||||||
|
position: relative;
|
||||||
padding: 12px 18px 14px;
|
padding: 12px 18px 14px;
|
||||||
border-top: 1px solid var(--wxex-border);
|
border-top: 1px solid var(--wxex-border);
|
||||||
background: var(--wxex-bg-elevated);
|
background: var(--wxex-bg-elevated);
|
||||||
@@ -688,6 +1288,98 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-search-history-trigger {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 3px 7px !important;
|
||||||
|
border: 1px solid var(--wxex-border) !important;
|
||||||
|
border-radius: 999px !important;
|
||||||
|
background: var(--wxex-bg-main) !important;
|
||||||
|
color: var(--wxex-brand) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-history-popover {
|
||||||
|
z-index: 30;
|
||||||
|
width: min(420px, calc(100% - 36px));
|
||||||
|
max-height: 236px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid var(--wxex-border);
|
||||||
|
border-radius: var(--wxex-radius-md);
|
||||||
|
background: var(--wxex-bg-elevated);
|
||||||
|
box-shadow: 0 10px 28px rgba(31, 52, 45, 0.16);
|
||||||
|
transform-origin: var(--radix-popover-content-transform-origin);
|
||||||
|
animation: ai-search-history-popover-in 150ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-search-history-popover-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(5px) scale(0.985);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-history-popover-heading,
|
||||||
|
.ai-search-history-popover-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-history-popover-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 3px 7px;
|
||||||
|
color: var(--wxex-text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-history-popover-heading button,
|
||||||
|
.ai-search-history-popover-item > button:last-child {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 !important;
|
||||||
|
border: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--wxex-text-muted) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px !important;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-history-popover-item > button:first-child {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 6px !important;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0 !important;
|
||||||
|
border-radius: var(--wxex-radius-sm) !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--wxex-text-secondary) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-history-popover-item > button:hover {
|
||||||
|
background: var(--wxex-brand-soft) !important;
|
||||||
|
color: var(--wxex-brand) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-search-history-empty {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 4px 3px;
|
||||||
|
color: var(--wxex-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.ai-search-composer-row {
|
.ai-search-composer-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -743,24 +1435,6 @@
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-search-evidence-meta {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 34px minmax(0, 1fr);
|
|
||||||
gap: 4px 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
color: var(--wxex-text-muted);
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-search-evidence-meta strong {
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--wxex-text-secondary);
|
|
||||||
font-weight: 600;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-search-evidence-card {
|
.ai-search-evidence-card {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -773,6 +1447,18 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
animation: ai-search-evidence-in 240ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ai-search-evidence-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(7px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-search-evidence-card:hover,
|
.ai-search-evidence-card:hover,
|
||||||
@@ -781,6 +1467,20 @@
|
|||||||
background: #f4fbf8;
|
background: #f4fbf8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ai-search-spinner,
|
||||||
|
.ai-search-result,
|
||||||
|
.ai-search-summary-block,
|
||||||
|
.ai-search-answer > *,
|
||||||
|
.ai-search-evidence-card,
|
||||||
|
.ai-search-history-popover,
|
||||||
|
.ai-search-pipeline-step.active .ai-search-pipeline-mark,
|
||||||
|
.ai-search-sync-progress-track span {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ai-search-evidence-card-top,
|
.ai-search-evidence-card-top,
|
||||||
.ai-search-evidence-conversation,
|
.ai-search-evidence-conversation,
|
||||||
.ai-search-evidence-text,
|
.ai-search-evidence-text,
|
||||||
|
|||||||
@@ -0,0 +1,625 @@
|
|||||||
|
import type { KnowledgeEvidence, KnowledgeSearchIpcResult } from './knowledge'
|
||||||
|
|
||||||
|
export type AiSearchScope = 'global' | 'groups' | 'contacts' | 'conversation'
|
||||||
|
export type AiSearchRange = 'today' | '7d' | '30d' | 'all'
|
||||||
|
/**
|
||||||
|
* Retrieval semantics, not presentation labels. Each intent has a constrained
|
||||||
|
* execution path in the main process; a model must not be able to quietly turn
|
||||||
|
* an identity lookup into a generic message-keyword search.
|
||||||
|
*/
|
||||||
|
export type AiSearchIntent =
|
||||||
|
| 'conversation_recall'
|
||||||
|
| 'conversation_topic_search'
|
||||||
|
| 'global_topic_search'
|
||||||
|
| 'conversation_name_search'
|
||||||
|
| 'general'
|
||||||
|
|
||||||
|
export interface AiSearchTimeRange {
|
||||||
|
/** Unix seconds. Undefined start means the user explicitly allowed all history. */
|
||||||
|
startTime?: number
|
||||||
|
endTime?: number
|
||||||
|
label: string
|
||||||
|
reason: string
|
||||||
|
source: 'ui' | 'query' | 'user_retry'
|
||||||
|
}
|
||||||
|
export type AiSearchProgressStage =
|
||||||
|
| 'query_understanding'
|
||||||
|
| 'agent_start'
|
||||||
|
| 'agent_tool'
|
||||||
|
| 'agent_decision'
|
||||||
|
| 'search_plan_ready'
|
||||||
|
| 'knowledge_searching'
|
||||||
|
| 'evidence_ranking'
|
||||||
|
| 'evidence_ready'
|
||||||
|
| 'aggregation'
|
||||||
|
| 'ai_generating'
|
||||||
|
| 'completed'
|
||||||
|
| 'error'
|
||||||
|
export type AiSearchProgressStatus = 'running' | 'completed' | 'error'
|
||||||
|
|
||||||
|
export interface AiSearchPlan {
|
||||||
|
intent: AiSearchIntent
|
||||||
|
keywords: string[]
|
||||||
|
variants: string[]
|
||||||
|
source: 'local' | 'ai' | 'hybrid'
|
||||||
|
scopeLabel: string
|
||||||
|
rangeLabel: string
|
||||||
|
timeRange: AiSearchTimeRange
|
||||||
|
contactNames: string[]
|
||||||
|
/** A user-supplied identity candidate. It must be resolved by Contact Resolution. */
|
||||||
|
contactQuery?: string
|
||||||
|
/** The message-content query, never a contact display name. */
|
||||||
|
topicQuery?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSearchPipelineRequest {
|
||||||
|
requestId: string
|
||||||
|
text: string
|
||||||
|
scope: AiSearchScope
|
||||||
|
range: AiSearchRange
|
||||||
|
conversationId?: string
|
||||||
|
/** Explicit user retry takes precedence over natural-language inference. */
|
||||||
|
timeRangeOverride?: AiSearchTimeRange
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSearchProgressEvent {
|
||||||
|
requestId: string
|
||||||
|
stage: AiSearchProgressStage
|
||||||
|
status: AiSearchProgressStatus
|
||||||
|
message: string
|
||||||
|
plan?: AiSearchPlan
|
||||||
|
stats?: {
|
||||||
|
knowledgeMessageCount?: number
|
||||||
|
matchedMessages?: number
|
||||||
|
evidenceCount?: number
|
||||||
|
contextEvidenceCount?: number
|
||||||
|
tokenEstimate?: number
|
||||||
|
inputTokens?: number
|
||||||
|
inputTokensEstimated?: boolean
|
||||||
|
elapsedMs?: number
|
||||||
|
deduplicatedMessages?: number
|
||||||
|
peopleCount?: number
|
||||||
|
conversationCount?: number
|
||||||
|
}
|
||||||
|
timings?: AiSearchPipelineTimings
|
||||||
|
modelName?: string
|
||||||
|
agentTrace?: AiSearchAgentTraceItem
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AiSearchAgentToolName =
|
||||||
|
| 'search_conversations'
|
||||||
|
| 'search_people'
|
||||||
|
| 'search_messages'
|
||||||
|
| 'get_conversation_messages'
|
||||||
|
| 'get_messages_by_time'
|
||||||
|
| 'get_message_context'
|
||||||
|
|
||||||
|
export type AiSearchAgentTraceEvent =
|
||||||
|
| 'agentStart'
|
||||||
|
| 'toolCallStart'
|
||||||
|
| 'toolCallEnd'
|
||||||
|
| 'agentDecision'
|
||||||
|
| 'evidenceBuild'
|
||||||
|
| 'summaryStart'
|
||||||
|
| 'summaryEnd'
|
||||||
|
| 'fallback'
|
||||||
|
|
||||||
|
/** Public trace: deliberately contains no SQL, paths, raw IDs, or Worker details. */
|
||||||
|
export interface AiSearchAgentTraceItem {
|
||||||
|
sequence: number
|
||||||
|
event: AiSearchAgentTraceEvent
|
||||||
|
label: string
|
||||||
|
toolName?: AiSearchAgentToolName
|
||||||
|
/** Sanitized, human-readable arguments only. */
|
||||||
|
arguments?: Record<string, string | number | boolean>
|
||||||
|
resultCount?: number
|
||||||
|
elapsedMs?: number
|
||||||
|
decision?: string
|
||||||
|
/** Bounded local snapshot of the exact decision prompt; never sent to analytics. */
|
||||||
|
decisionInput?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSearchAgentRun {
|
||||||
|
mode: 'agent' | 'fallback'
|
||||||
|
toolCalls: number
|
||||||
|
trace: AiSearchAgentTraceItem[]
|
||||||
|
fallbackReason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSearchPipelineEvidence extends KnowledgeEvidence {
|
||||||
|
conversationName: string
|
||||||
|
conversationType: 'user' | 'group'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A program-generated, stable citation. This is the only Evidence shape sent to AI/UI. */
|
||||||
|
export interface AiSearchFinalEvidence extends AiSearchPipelineEvidence {
|
||||||
|
id: `E${number}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSearchPersonAggregation {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
messageCount: number
|
||||||
|
conversationCount: number
|
||||||
|
lastMessageAt: number
|
||||||
|
evidenceIds: Array<`E${number}`>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSearchConversationAggregation {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: 'user' | 'group'
|
||||||
|
messageCount: number
|
||||||
|
peopleCount: number
|
||||||
|
lastMessageAt: number
|
||||||
|
evidenceIds: Array<`E${number}`>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSearchAggregation {
|
||||||
|
messageCount: number
|
||||||
|
peopleCount: number
|
||||||
|
conversationCount: number
|
||||||
|
people: AiSearchPersonAggregation[]
|
||||||
|
conversations: AiSearchConversationAggregation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All fields are directly measured around real work. */
|
||||||
|
export interface AiSearchPipelineTimings {
|
||||||
|
queryUnderstandingMs: number
|
||||||
|
contactResolutionMs: number
|
||||||
|
knowledgeSearchMs: number
|
||||||
|
workerIpcMs: number
|
||||||
|
workerBootMs: number
|
||||||
|
dispatchMs: number
|
||||||
|
workerSqlMs: number
|
||||||
|
responseSerializeMs: number
|
||||||
|
responseTransferMs: number
|
||||||
|
ftsMs: number
|
||||||
|
chunkExpandMs: number
|
||||||
|
messageLoadMs: number
|
||||||
|
rankingMs: number
|
||||||
|
candidateRankingMs: number
|
||||||
|
evidenceBuildMs: number
|
||||||
|
aggregationMs: number
|
||||||
|
contextPreparationMs: number
|
||||||
|
agentDecisionMs: number
|
||||||
|
agentToolMs: number
|
||||||
|
aiGenerationMs: number
|
||||||
|
totalMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSearchCitationValidation {
|
||||||
|
status: 'valid' | 'sanitized'
|
||||||
|
invalidCitationIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truthful retrieval metadata shared by the AI, UI and diagnostics. */
|
||||||
|
export interface AiSearchRetrievalContract {
|
||||||
|
intent: AiSearchIntent
|
||||||
|
conversationId?: string
|
||||||
|
timeRange: AiSearchTimeRange
|
||||||
|
retrievalMode:
|
||||||
|
| 'conversation_metadata'
|
||||||
|
| 'conversation_topic_fts'
|
||||||
|
| 'global_fts'
|
||||||
|
| 'conversation_name'
|
||||||
|
| 'unresolved_identity'
|
||||||
|
candidateCount: number
|
||||||
|
sourceMessageCount?: number
|
||||||
|
sourceCoverage: 'complete' | 'partial' | 'keyword_match' | 'unknown'
|
||||||
|
isComplete: boolean
|
||||||
|
fallbackUsed: boolean
|
||||||
|
fallbackReason?: string
|
||||||
|
suspicious: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSearchPipelineResult {
|
||||||
|
requestId: string
|
||||||
|
status: 'completed' | 'no_evidence' | 'retrieval_incomplete' | 'ai_failed' | 'failed'
|
||||||
|
plan: AiSearchPlan
|
||||||
|
knowledge: Pick<
|
||||||
|
KnowledgeSearchIpcResult,
|
||||||
|
| 'source'
|
||||||
|
| 'state'
|
||||||
|
| 'fallbackReason'
|
||||||
|
| 'indexedMessageCount'
|
||||||
|
| 'indexedChunkCount'
|
||||||
|
| 'totalMessages'
|
||||||
|
>
|
||||||
|
candidateEvidenceCount: number
|
||||||
|
retrieval: AiSearchRetrievalContract
|
||||||
|
evidence: AiSearchFinalEvidence[]
|
||||||
|
contextEvidenceCount: number
|
||||||
|
aggregation: AiSearchAggregation
|
||||||
|
agent: AiSearchAgentRun
|
||||||
|
citationValidation?: AiSearchCitationValidation
|
||||||
|
timings: AiSearchPipelineTimings
|
||||||
|
answer?: string
|
||||||
|
ai?: {
|
||||||
|
providerName: string
|
||||||
|
modelName: string
|
||||||
|
inputTokens?: number
|
||||||
|
inputTokensEstimated: boolean
|
||||||
|
}
|
||||||
|
error?: string
|
||||||
|
errorStage?: Exclude<AiSearchProgressStage, 'completed' | 'error'>
|
||||||
|
elapsedMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGE_LABELS: Record<AiSearchRange, string> = {
|
||||||
|
today: '今天',
|
||||||
|
'7d': '近 7 天',
|
||||||
|
'30d': '近 30 天',
|
||||||
|
all: '全部历史'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_INTENT_PHRASES = [
|
||||||
|
'全局搜一下',
|
||||||
|
'全局搜索',
|
||||||
|
'搜索一下',
|
||||||
|
'搜一下',
|
||||||
|
'查询一下',
|
||||||
|
'查一下',
|
||||||
|
'找一下',
|
||||||
|
'我和谁聊过',
|
||||||
|
'谁和我聊过',
|
||||||
|
'谁聊过',
|
||||||
|
'哪些人和我聊过',
|
||||||
|
'最近讨论了什么',
|
||||||
|
'最近聊了什么',
|
||||||
|
'最近说了什么',
|
||||||
|
'讨论了什么',
|
||||||
|
'讨论什么',
|
||||||
|
'聊了什么',
|
||||||
|
'聊些什么',
|
||||||
|
'说了什么',
|
||||||
|
'说些什么',
|
||||||
|
'最近讨论',
|
||||||
|
'最近聊天',
|
||||||
|
'这个话题',
|
||||||
|
'相关话题',
|
||||||
|
'的聊天',
|
||||||
|
'的内容',
|
||||||
|
'的记录',
|
||||||
|
'关于',
|
||||||
|
'聊天',
|
||||||
|
'记录',
|
||||||
|
'聊天记录',
|
||||||
|
'帮我',
|
||||||
|
'请问',
|
||||||
|
'最近'
|
||||||
|
].sort((left, right) => right.length - left.length)
|
||||||
|
|
||||||
|
const SEARCH_STOP_WORDS = new Set([
|
||||||
|
'我',
|
||||||
|
'谁',
|
||||||
|
'什么',
|
||||||
|
'哪些',
|
||||||
|
'哪个',
|
||||||
|
'人',
|
||||||
|
'和',
|
||||||
|
'聊过',
|
||||||
|
'说过',
|
||||||
|
'提到',
|
||||||
|
'讨论',
|
||||||
|
'聊天',
|
||||||
|
'记录',
|
||||||
|
'说',
|
||||||
|
'聊',
|
||||||
|
'话题',
|
||||||
|
'内容',
|
||||||
|
'相关',
|
||||||
|
'最近',
|
||||||
|
'一下'
|
||||||
|
])
|
||||||
|
|
||||||
|
export const aiSearchRangeLabel = (range: AiSearchRange): string => RANGE_LABELS[range]
|
||||||
|
|
||||||
|
export const aiSearchRangeStart = (range: AiSearchRange): number | undefined => {
|
||||||
|
if (range === 'all') return undefined
|
||||||
|
if (range === 'today') {
|
||||||
|
const now = new Date()
|
||||||
|
return Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000)
|
||||||
|
}
|
||||||
|
return Math.floor(Date.now() / 1000) - (range === '7d' ? 7 : 30) * 86400
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayStart = (date: Date): number =>
|
||||||
|
Math.floor(new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000)
|
||||||
|
|
||||||
|
const currentYearStart = (date: Date): number =>
|
||||||
|
Math.floor(new Date(date.getFullYear(), 0, 1).getTime() / 1000)
|
||||||
|
|
||||||
|
const currentMonthStart = (date: Date): number =>
|
||||||
|
Math.floor(new Date(date.getFullYear(), date.getMonth(), 1).getTime() / 1000)
|
||||||
|
|
||||||
|
const CHINESE_NUMBERS: Record<string, number> = {
|
||||||
|
一: 1,
|
||||||
|
二: 2,
|
||||||
|
两: 2,
|
||||||
|
三: 3,
|
||||||
|
四: 4,
|
||||||
|
五: 5,
|
||||||
|
六: 6,
|
||||||
|
七: 7,
|
||||||
|
八: 8,
|
||||||
|
九: 9,
|
||||||
|
十: 10
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseNaturalNumber = (value: string | undefined): number | undefined => {
|
||||||
|
if (!value) return undefined
|
||||||
|
const numeric = Number(value)
|
||||||
|
if (Number.isFinite(numeric)) return numeric
|
||||||
|
return CHINESE_NUMBERS[value]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query time expressions are part of SearchPlan, never a renderer-only rule.
|
||||||
|
* A natural-language time constraint is more specific than the broad "all" UI scope.
|
||||||
|
*/
|
||||||
|
export const inferAiSearchTimeRange = (
|
||||||
|
query: string,
|
||||||
|
uiRange: AiSearchRange,
|
||||||
|
now = new Date(),
|
||||||
|
override?: AiSearchTimeRange
|
||||||
|
): AiSearchTimeRange => {
|
||||||
|
if (override?.source === 'user_retry') return override
|
||||||
|
const nowSeconds = Math.floor(now.getTime() / 1000)
|
||||||
|
const fromQuery = (startTime: number, label: string, reason: string): AiSearchTimeRange => ({
|
||||||
|
startTime,
|
||||||
|
endTime: nowSeconds,
|
||||||
|
label,
|
||||||
|
reason,
|
||||||
|
source: 'query'
|
||||||
|
})
|
||||||
|
const recentDays = query.match(/最近\s*(\d{1,3}|[一二两三四五六七八九十])\s*天/)
|
||||||
|
if (recentDays) {
|
||||||
|
const days = Math.max(1, Math.min(365, parseNaturalNumber(recentDays[1]) || 30))
|
||||||
|
return fromQuery(nowSeconds - days * 86400, `近 ${days} 天`, `用户说“最近 ${days} 天”`)
|
||||||
|
}
|
||||||
|
const recentMonths = query.match(/最近\s*(\d{1,2}|[一二两三四五六七八九十])\s*个?月/)
|
||||||
|
if (recentMonths) {
|
||||||
|
const months = Math.max(1, Math.min(24, parseNaturalNumber(recentMonths[1]) || 1))
|
||||||
|
const start = new Date(now.getFullYear(), now.getMonth() - months, now.getDate()).getTime()
|
||||||
|
return fromQuery(Math.floor(start / 1000), `近 ${months} 个月`, `用户说“最近 ${months} 个月”`)
|
||||||
|
}
|
||||||
|
if (/刚刚|刚才/.test(query))
|
||||||
|
return fromQuery(nowSeconds - 24 * 3600, '近 24 小时', '用户说“刚刚”')
|
||||||
|
if (/这几天/.test(query)) return fromQuery(nowSeconds - 7 * 86400, '近 7 天', '用户说“这几天”')
|
||||||
|
if (/这周|本周/.test(query)) {
|
||||||
|
const weekday = now.getDay() || 7
|
||||||
|
return fromQuery(dayStart(now) - (weekday - 1) * 86400, '本周', '用户说“这周”')
|
||||||
|
}
|
||||||
|
if (/这个月|本月/.test(query)) return fromQuery(currentMonthStart(now), '本月', '用户说“这个月”')
|
||||||
|
if (/上个月/.test(query)) {
|
||||||
|
const start = Math.floor(new Date(now.getFullYear(), now.getMonth() - 1, 1).getTime() / 1000)
|
||||||
|
const end = Math.floor(new Date(now.getFullYear(), now.getMonth(), 1).getTime() / 1000) - 1
|
||||||
|
return {
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
label: '上个月',
|
||||||
|
reason: '用户说“上个月”',
|
||||||
|
source: 'query'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/今年/.test(query)) return fromQuery(currentYearStart(now), '今年', '用户说“今年”')
|
||||||
|
if (/最近/.test(query)) return fromQuery(nowSeconds - 30 * 86400, '近 30 天', '用户说“最近”')
|
||||||
|
return {
|
||||||
|
startTime: aiSearchRangeStart(uiRange),
|
||||||
|
endTime: undefined,
|
||||||
|
label: aiSearchRangeLabel(uiRange),
|
||||||
|
reason: '使用界面选择的时间范围',
|
||||||
|
source: 'ui'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiSearchIntentLabel = (intent: AiSearchIntent): string => {
|
||||||
|
if (intent === 'conversation_recall') return '回顾最近聊天'
|
||||||
|
if (intent === 'conversation_topic_search') return '在指定聊天中查找话题'
|
||||||
|
if (intent === 'global_topic_search') return '按话题查找'
|
||||||
|
if (intent === 'conversation_name_search') return '查找聊天'
|
||||||
|
return '综合查找'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiSearchScopeLabel = (scope: AiSearchScope, conversationName?: string): string => {
|
||||||
|
if (scope === 'groups') return '群聊'
|
||||||
|
if (scope === 'contacts') return '单聊'
|
||||||
|
if (scope === 'conversation') return conversationName || '当前会话'
|
||||||
|
return '所有聊天'
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeTerms = (terms: unknown): string[] => {
|
||||||
|
if (!Array.isArray(terms)) return []
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
terms
|
||||||
|
.filter((term): term is string => typeof term === 'string')
|
||||||
|
.map((term) => term.trim())
|
||||||
|
.filter((term) => term.length >= 2 && term.length <= 32)
|
||||||
|
)
|
||||||
|
).slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractKeywords = (query: string): string[] => {
|
||||||
|
const cleaned = SEARCH_INTENT_PHRASES.reduce(
|
||||||
|
(value, phrase) => value.split(phrase).join(' '),
|
||||||
|
query.toLowerCase()
|
||||||
|
)
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
cleaned
|
||||||
|
.split(/[\s,,。!?!?、::;;"“”‘’()()[\]【】]+/)
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter((token) => token.length >= 2 && !SEARCH_STOP_WORDS.has(token))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywordVariants = (keywords: string[]): string[] =>
|
||||||
|
Array.from(
|
||||||
|
new Set(
|
||||||
|
keywords.flatMap((keyword) => {
|
||||||
|
const variants = [keyword]
|
||||||
|
if (/^[\u4e00-\u9fff]+$/.test(keyword) && keyword.length > 2) {
|
||||||
|
variants.push(keyword.slice(-2))
|
||||||
|
}
|
||||||
|
return variants
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const buildLocalAiSearchPlan = (
|
||||||
|
query: string
|
||||||
|
): Pick<
|
||||||
|
AiSearchPlan,
|
||||||
|
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
|
||||||
|
> => {
|
||||||
|
const keywords = extractKeywords(query)
|
||||||
|
const normalized = query.replace(/[“”"'‘’「」『』]/g, '').trim()
|
||||||
|
const recall = normalized.match(
|
||||||
|
/(?:我和|我跟|我与)\s*(.+?)\s*(?:最近|这几天|本周|这个月|本月|今年|上个月|刚刚|刚才)?\s*(?:聊了什么|聊过什么|说了什么|谈了什么)/
|
||||||
|
)
|
||||||
|
const reverseRecall = normalized.match(
|
||||||
|
/^\s*(.+?)\s*(?:最近)?(?:跟我|和我|与我)\s*(?:聊了什么|聊过什么|说了什么|谈了什么)/
|
||||||
|
)
|
||||||
|
const namedConversationRecall = normalized.match(
|
||||||
|
/(?:我在|在)\s*(.+?)\s*(?:最近)?\s*(?:聊了什么|聊过什么|说了什么|谈了什么)/
|
||||||
|
)
|
||||||
|
const conversationTopic = normalized.match(
|
||||||
|
/(?:我和|我跟|我与)\s*(.+?)\s*(?:最近|这几天|本周|这个月|本月|今年|上个月)?\s*(?:聊过|提过|说过|讨论过)\s*(.+?)(?:吗|么|沒有|没有)?[??。!!]*$/
|
||||||
|
)
|
||||||
|
const globalTopic = normalized.match(
|
||||||
|
/(?:最近|这几天|本周|这个月|本月|今年)?\s*(?:谁|哪些人|大家)\s*(?:聊过|提过|说过|讨论过)\s*(.+?)[??。!!]*$/
|
||||||
|
)
|
||||||
|
const conversationName =
|
||||||
|
!recall &&
|
||||||
|
!reverseRecall &&
|
||||||
|
!conversationTopic &&
|
||||||
|
!namedConversationRecall &&
|
||||||
|
!globalTopic &&
|
||||||
|
/^[^,,。!?!?]{2,32}(?:群|群聊|交流群)$/.test(normalized)
|
||||||
|
? normalized
|
||||||
|
: undefined
|
||||||
|
const contactQuery = (
|
||||||
|
conversationTopic?.[1] ||
|
||||||
|
recall?.[1] ||
|
||||||
|
reverseRecall?.[1] ||
|
||||||
|
namedConversationRecall?.[1]
|
||||||
|
)
|
||||||
|
?.replace(/^(?:和|跟|与)\s*/, '')
|
||||||
|
.trim()
|
||||||
|
const topicQuery = (conversationTopic?.[2] || globalTopic?.[1])
|
||||||
|
?.replace(/^(?:关于|一下|吗|么)\s*/, '')
|
||||||
|
.trim()
|
||||||
|
const intent: AiSearchIntent = conversationTopic
|
||||||
|
? 'conversation_topic_search'
|
||||||
|
: recall || reverseRecall
|
||||||
|
? 'conversation_recall'
|
||||||
|
: namedConversationRecall
|
||||||
|
? 'conversation_name_search'
|
||||||
|
: globalTopic
|
||||||
|
? 'global_topic_search'
|
||||||
|
: conversationName
|
||||||
|
? 'conversation_name_search'
|
||||||
|
: keywords.length
|
||||||
|
? 'global_topic_search'
|
||||||
|
: 'general'
|
||||||
|
const effectiveKeywords = topicQuery ? [topicQuery] : keywords
|
||||||
|
return {
|
||||||
|
intent,
|
||||||
|
keywords: effectiveKeywords,
|
||||||
|
variants: keywordVariants(effectiveKeywords),
|
||||||
|
source: 'local',
|
||||||
|
contactQuery: contactQuery || conversationName,
|
||||||
|
topicQuery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseAiSearchPlan = (
|
||||||
|
value: string
|
||||||
|
): Partial<Pick<AiSearchPlan, 'intent' | 'keywords' | 'variants' | 'topicQuery'>> | null => {
|
||||||
|
const jsonMatch = value.match(/\{[\s\S]*\}/)
|
||||||
|
if (!jsonMatch) return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>
|
||||||
|
const intent = [
|
||||||
|
'general',
|
||||||
|
'conversation_recall',
|
||||||
|
'conversation_topic_search',
|
||||||
|
'global_topic_search',
|
||||||
|
'conversation_name_search'
|
||||||
|
].includes(String(parsed.intent))
|
||||||
|
? (parsed.intent as AiSearchIntent)
|
||||||
|
: undefined
|
||||||
|
return {
|
||||||
|
intent,
|
||||||
|
keywords: normalizeTerms(parsed.keywords),
|
||||||
|
variants: normalizeTerms(parsed.variants),
|
||||||
|
topicQuery:
|
||||||
|
typeof parsed.topicQuery === 'string' && parsed.topicQuery.trim().length >= 2
|
||||||
|
? parsed.topicQuery.trim().slice(0, 64)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mergeAiSearchPlans = (
|
||||||
|
local: Pick<
|
||||||
|
AiSearchPlan,
|
||||||
|
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
|
||||||
|
>,
|
||||||
|
ai: Partial<Pick<AiSearchPlan, 'intent' | 'keywords' | 'variants' | 'topicQuery'>> | null
|
||||||
|
): Pick<
|
||||||
|
AiSearchPlan,
|
||||||
|
'intent' | 'keywords' | 'variants' | 'source' | 'contactQuery' | 'topicQuery'
|
||||||
|
> => {
|
||||||
|
if (!ai) return local
|
||||||
|
const keywords = normalizeTerms([...local.keywords, ...(ai.keywords || [])])
|
||||||
|
const variants = normalizeTerms([
|
||||||
|
...keywordVariants(keywords),
|
||||||
|
...local.variants,
|
||||||
|
...(ai.variants || [])
|
||||||
|
])
|
||||||
|
// Identity-bearing local intents are deterministic contracts. A planner may
|
||||||
|
// refine topic terms but may not weaken them into an unrelated FTS intent.
|
||||||
|
const lockedIntent =
|
||||||
|
local.intent === 'conversation_recall' ||
|
||||||
|
local.intent === 'conversation_topic_search' ||
|
||||||
|
local.intent === 'conversation_name_search'
|
||||||
|
return {
|
||||||
|
intent: lockedIntent ? local.intent : ai.intent || local.intent,
|
||||||
|
keywords,
|
||||||
|
variants,
|
||||||
|
source: 'hybrid',
|
||||||
|
contactQuery: local.contactQuery,
|
||||||
|
topicQuery: local.topicQuery || ai.topicQuery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const includesExplicitAiSearchAlias = (query: string, alias: string): boolean => {
|
||||||
|
const name = alias.trim()
|
||||||
|
if (!name || name.length < 2) return false
|
||||||
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const quoted = new RegExp(`[“"'‘「『]${escaped}[”"'’」』]`)
|
||||||
|
const relational = new RegExp(
|
||||||
|
`(?:我和|我跟|我与|和|跟|与|在|给|向|@)${escaped}(?=$|[\\s,,。!?!?、::;;])`
|
||||||
|
)
|
||||||
|
if (quoted.test(query) || relational.test(query)) return true
|
||||||
|
|
||||||
|
// Users often omit a nickname's punctuation, for example typing
|
||||||
|
// “中田健身弘毅” for “中田健身-弘毅”. Keep this tolerant matching limited
|
||||||
|
// to an explicit relational query so a short alias cannot accidentally
|
||||||
|
// select a contact from unrelated prose.
|
||||||
|
const compact = (value: string): string =>
|
||||||
|
value.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
|
||||||
|
const compactName = compact(name)
|
||||||
|
return (
|
||||||
|
compactName.length >= 2 &&
|
||||||
|
/(?:我和|我跟|我与|和|跟|与|在|给|向|@)/.test(query) &&
|
||||||
|
compact(query).includes(compactName)
|
||||||
|
)
|
||||||
|
}
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
export type CacheClearScope = 'bootstrap' | 'electron' | 'all'
|
export type CacheClearScope = 'bootstrap' | 'electron' | 'knowledge' | 'all'
|
||||||
|
|
||||||
export interface CacheSummaryItem {
|
export interface CacheSummaryItem {
|
||||||
id: 'bootstrap' | 'electron'
|
id: 'bootstrap' | 'electron' | 'knowledge'
|
||||||
label: string
|
label: string
|
||||||
description: string
|
description: string
|
||||||
sizeBytes: number
|
sizeBytes: number
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export type ContactResolutionMatch = 'exact' | 'normalized' | 'alias' | 'fuzzy'
|
||||||
|
|
||||||
|
export interface ContactResolutionCandidate {
|
||||||
|
conversationId: string
|
||||||
|
displayName: string
|
||||||
|
matchedBy: ContactResolutionMatch
|
||||||
|
confidence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactResolutionResult {
|
||||||
|
matched: boolean
|
||||||
|
personId?: string
|
||||||
|
conversationId?: string
|
||||||
|
canonicalName?: string
|
||||||
|
displayName?: string
|
||||||
|
matchedBy?: ContactResolutionMatch
|
||||||
|
confidence: number
|
||||||
|
candidates: ContactResolutionCandidate[]
|
||||||
|
ambiguous: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity-only canonicalization. It deliberately does not use substring
|
||||||
|
* matching: callers may use a separate UI-filter policy for broad discovery,
|
||||||
|
* but identity resolution must never turn 张三 into 张三丰.
|
||||||
|
*/
|
||||||
|
export const normalizeContactName = (value: string): string =>
|
||||||
|
String(value || '')
|
||||||
|
.normalize('NFKC')
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.replace(/[\p{White_Space}\p{P}\p{S}_]+/gu, '')
|
||||||
|
|
||||||
|
export const emptyContactResolution = (): ContactResolutionResult => ({
|
||||||
|
matched: false,
|
||||||
|
confidence: 0,
|
||||||
|
candidates: [],
|
||||||
|
ambiguous: false
|
||||||
|
})
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
/**
|
||||||
|
* Contracts for the local, derived knowledge base. These values deliberately
|
||||||
|
* contain no WCDB handles, Electron objects, database keys, or UI state so the
|
||||||
|
* indexer can run in an isolated process.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const KNOWLEDGE_SCHEMA_VERSION = 1
|
||||||
|
export const DEFAULT_CHUNKER_VERSION = 'conversation-v1'
|
||||||
|
|
||||||
|
export type KnowledgeMessageKind = 'text' | 'voice' | 'file' | 'link' | 'system' | 'other'
|
||||||
|
export type KnowledgeIndexPhase =
|
||||||
|
| 'idle'
|
||||||
|
| 'preflight'
|
||||||
|
| 'indexing'
|
||||||
|
| 'ready'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'error'
|
||||||
|
export type KnowledgeTemporalIntent = 'none' | 'current' | 'historical' | 'timeline'
|
||||||
|
export type KnowledgeFtsTokenizer = 'unicode61' | 'trigram'
|
||||||
|
export type KnowledgeFtsContentMode = 'external' | 'internal'
|
||||||
|
export type KnowledgeFtsDetail = 'full' | 'column' | 'none'
|
||||||
|
|
||||||
|
export interface KnowledgeAttachmentMetadata {
|
||||||
|
name: string
|
||||||
|
kind?: 'file' | 'link' | 'image' | 'video' | 'other'
|
||||||
|
url?: string
|
||||||
|
sizeBytes?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A read-only source record prepared by the future WCDB adapter. */
|
||||||
|
export interface KnowledgeSourceMessage {
|
||||||
|
accountId: string
|
||||||
|
conversationId: string
|
||||||
|
messageId: string
|
||||||
|
/** Unix epoch milliseconds. Adapters must convert source-specific units. */
|
||||||
|
createTime: number
|
||||||
|
senderId?: string
|
||||||
|
senderName?: string
|
||||||
|
kind: KnowledgeMessageKind
|
||||||
|
text?: string
|
||||||
|
attachment?: KnowledgeAttachmentMetadata
|
||||||
|
voiceTranscript?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeNormalizedMessage extends KnowledgeSourceMessage {
|
||||||
|
searchableText: string
|
||||||
|
contentHash: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeChunkerConfig {
|
||||||
|
version: string
|
||||||
|
maxGapMs: number
|
||||||
|
maxMessages: number
|
||||||
|
maxCharacters: number
|
||||||
|
overlapMessages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeChunk {
|
||||||
|
chunkId: string
|
||||||
|
accountId: string
|
||||||
|
conversationId: string
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
text: string
|
||||||
|
messageIds: string[]
|
||||||
|
participantIds: string[]
|
||||||
|
messageKinds: KnowledgeMessageKind[]
|
||||||
|
contentHash: string
|
||||||
|
chunkerVersion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every FTS choice is explicit. The first production profile must be selected
|
||||||
|
* from the Task 0 report rather than being silently hard-coded in the UI.
|
||||||
|
*/
|
||||||
|
export interface KnowledgeFtsConfig {
|
||||||
|
profileId: string
|
||||||
|
tokenizer: KnowledgeFtsTokenizer
|
||||||
|
contentMode: KnowledgeFtsContentMode
|
||||||
|
detail: KnowledgeFtsDetail
|
||||||
|
columnsize: 0 | 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chosen after the realistic desensitized WeChat benchmark: trigram preserves
|
||||||
|
* Chinese-substring recall while external content avoids a second text copy.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_KNOWLEDGE_FTS_CONFIG: KnowledgeFtsConfig = {
|
||||||
|
profileId: 'trigram-external-full-columnsize-v1',
|
||||||
|
tokenizer: 'trigram',
|
||||||
|
contentMode: 'external',
|
||||||
|
detail: 'full',
|
||||||
|
columnsize: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeConversationInput {
|
||||||
|
conversationId: string
|
||||||
|
/** true means this is a complete read-only snapshot of the conversation. */
|
||||||
|
completeSnapshot: boolean
|
||||||
|
messages: KnowledgeSourceMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeIndexRequest {
|
||||||
|
accountId: string
|
||||||
|
databaseRoot: string
|
||||||
|
conversations: KnowledgeConversationInput[]
|
||||||
|
chunker: KnowledgeChunkerConfig
|
||||||
|
fts: KnowledgeFtsConfig
|
||||||
|
/** Written only after a complete source pass; used for truthful coverage. */
|
||||||
|
sourceMessageCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeIndexProgress {
|
||||||
|
accountId: string
|
||||||
|
phase: KnowledgeIndexPhase
|
||||||
|
conversationId?: string
|
||||||
|
processedMessages: number
|
||||||
|
totalMessages: number
|
||||||
|
indexedChunks: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeIndexResult {
|
||||||
|
accountId: string
|
||||||
|
processedMessages: number
|
||||||
|
indexedChunks: number
|
||||||
|
updatedChunks: number
|
||||||
|
unchangedConversations: number
|
||||||
|
databaseBytes: number
|
||||||
|
walBytes: number
|
||||||
|
elapsedMs: number
|
||||||
|
cancelled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeCapacityPreflightRequest {
|
||||||
|
accountId: string
|
||||||
|
databaseRoot: string
|
||||||
|
conversations: KnowledgeConversationInput[]
|
||||||
|
chunker: KnowledgeChunkerConfig
|
||||||
|
/** Optional free space supplied by the platform layer; this module never probes WCDB paths. */
|
||||||
|
availableDiskBytes?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeCapacityPreflight {
|
||||||
|
accountId: string
|
||||||
|
sourceMessageCount: number
|
||||||
|
indexableMessageCount: number
|
||||||
|
indexableTextBytes: number
|
||||||
|
voiceTranscriptCount: number
|
||||||
|
attachmentMetadataCount: number
|
||||||
|
sampledChunkCount: number
|
||||||
|
estimatedChunkCount: number
|
||||||
|
estimatedDatabaseBytesLow: number
|
||||||
|
estimatedDatabaseBytesHigh: number
|
||||||
|
estimatedBuildPeakBytesLow: number
|
||||||
|
estimatedBuildPeakBytesHigh: number
|
||||||
|
availableDiskBytes?: number
|
||||||
|
hasSufficientDiskSpace?: boolean
|
||||||
|
warnings: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeEvidence {
|
||||||
|
chunkId: string
|
||||||
|
conversationId: string
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
/** Stable source-message identity used by the archive jump action. */
|
||||||
|
messageId: string
|
||||||
|
senderId?: string
|
||||||
|
sender: string
|
||||||
|
/** Unix epoch milliseconds. */
|
||||||
|
timestamp: number
|
||||||
|
messageIds: string[]
|
||||||
|
text: string
|
||||||
|
score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bounded, local summary of a single conversation retrieval. */
|
||||||
|
export interface KnowledgeConversationRetrieval {
|
||||||
|
conversationId: string
|
||||||
|
totalMessages: number
|
||||||
|
chunkCount: number
|
||||||
|
candidateMessages: number
|
||||||
|
systemMessagesDeprioritized: number
|
||||||
|
complete: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeQuery {
|
||||||
|
accountId: string
|
||||||
|
text: string
|
||||||
|
/** Query-router terms. The raw question remains available for diagnostics. */
|
||||||
|
terms?: string[]
|
||||||
|
limit: number
|
||||||
|
conversationId?: string
|
||||||
|
conversationIds?: string[]
|
||||||
|
senderIds?: string[]
|
||||||
|
/** Unix epoch milliseconds. */
|
||||||
|
startTime?: number
|
||||||
|
/** Unix epoch milliseconds. */
|
||||||
|
endTime?: number
|
||||||
|
temporalIntent?: KnowledgeTemporalIntent
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeSearchRequest extends KnowledgeQuery {
|
||||||
|
databaseRoot: string
|
||||||
|
fts: KnowledgeFtsConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KnowledgeSearchState = 'unavailable' | 'indexing' | 'ready'
|
||||||
|
|
||||||
|
/** Measured in the Worker; never inferred from message counts or UI timers. */
|
||||||
|
export interface KnowledgeSearchTimings {
|
||||||
|
/** Parent/child-process transport and host scheduling outside SQLite work. */
|
||||||
|
workerIpcMs: number
|
||||||
|
/** First request only: child process spawn and Node initialization until it received the request. */
|
||||||
|
workerBootMs: number
|
||||||
|
/** Parent send → Worker handler start. */
|
||||||
|
dispatchMs: number
|
||||||
|
/** Worker local SQLite/chunk work; equals the Worker-side search total. */
|
||||||
|
workerSqlMs: number
|
||||||
|
/** Worker response preparation → parent receipt; includes IPC serialization/transfer. */
|
||||||
|
responseTransferMs: number
|
||||||
|
/** Worker-side serialization preflight for the result payload. */
|
||||||
|
responseSerializeMs: number
|
||||||
|
/** FTS (or short-term database lookup) query time. */
|
||||||
|
ftsMs: number
|
||||||
|
/** Reading source message rows from matching chunks. */
|
||||||
|
messageLoadMs: number
|
||||||
|
/** Expanding chunk members, scoring terms and per-chunk de-duplication. */
|
||||||
|
chunkExpandMs: number
|
||||||
|
/** Final result ordering and limit application. */
|
||||||
|
rankingMs: number
|
||||||
|
/** Worker-side local search total. */
|
||||||
|
totalMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyKnowledgeSearchTimings = (): KnowledgeSearchTimings => ({
|
||||||
|
workerIpcMs: 0,
|
||||||
|
workerBootMs: 0,
|
||||||
|
dispatchMs: 0,
|
||||||
|
workerSqlMs: 0,
|
||||||
|
responseTransferMs: 0,
|
||||||
|
responseSerializeMs: 0,
|
||||||
|
ftsMs: 0,
|
||||||
|
messageLoadMs: 0,
|
||||||
|
chunkExpandMs: 0,
|
||||||
|
rankingMs: 0,
|
||||||
|
totalMs: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface KnowledgeSearchResult {
|
||||||
|
state: KnowledgeSearchState
|
||||||
|
evidence: KnowledgeEvidence[]
|
||||||
|
indexedMessageCount: number
|
||||||
|
indexedChunkCount: number
|
||||||
|
timings: KnowledgeSearchTimings
|
||||||
|
conversationRetrieval?: KnowledgeConversationRetrieval
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renderer-facing request. Chat timestamps use Unix seconds in the existing UI. */
|
||||||
|
export interface KnowledgeSearchIpcRequest {
|
||||||
|
text: string
|
||||||
|
terms: string[]
|
||||||
|
conversationIds?: string[]
|
||||||
|
senderIds?: string[]
|
||||||
|
startTime?: number
|
||||||
|
endTime?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeSearchIpcResult extends KnowledgeSearchResult {
|
||||||
|
source: 'knowledge' | 'fallback'
|
||||||
|
totalMessages: number
|
||||||
|
fallbackReason?: 'unavailable' | 'indexing' | 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KnowledgeRuntimeState = 'unavailable' | 'building' | 'syncing' | 'ready' | 'error'
|
||||||
|
|
||||||
|
export interface KnowledgeRuntimeStatus {
|
||||||
|
accountId: string
|
||||||
|
state: KnowledgeRuntimeState
|
||||||
|
indexedMessageCount: number
|
||||||
|
indexedChunkCount: number
|
||||||
|
/** Null means this source pass has not yet counted every source message. */
|
||||||
|
sourceMessageCount: number | null
|
||||||
|
processedMessages: number
|
||||||
|
totalMessages: number | null
|
||||||
|
currentConversationId?: string
|
||||||
|
/** Null is displayed as unavailable rather than a fabricated ETA. */
|
||||||
|
estimatedRemainingMs: number | null
|
||||||
|
databaseBytes: number
|
||||||
|
walBytes: number
|
||||||
|
shmBytes: number
|
||||||
|
lastError?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeStatusRequest {
|
||||||
|
accountId: string
|
||||||
|
databaseRoot: string
|
||||||
|
fts: KnowledgeFtsConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeWorkerRequest {
|
||||||
|
version: 1
|
||||||
|
type: 'index' | 'preflight' | 'search' | 'status' | 'remove' | 'cancel' | 'close'
|
||||||
|
requestId: string
|
||||||
|
/** Parent monotonic wall-clock used only for transport timing. */
|
||||||
|
sentAt?: number
|
||||||
|
payload:
|
||||||
|
| KnowledgeIndexRequest
|
||||||
|
| KnowledgeCapacityPreflightRequest
|
||||||
|
| KnowledgeSearchRequest
|
||||||
|
| KnowledgeStatusRequest
|
||||||
|
| { accountId: string; databaseRoot: string }
|
||||||
|
| { targetRequestId: string }
|
||||||
|
| Record<string, never>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeWorkerResponse {
|
||||||
|
version: 1
|
||||||
|
type: 'progress' | 'result' | 'error'
|
||||||
|
requestId: string
|
||||||
|
payload?:
|
||||||
|
| KnowledgeIndexProgress
|
||||||
|
| KnowledgeIndexResult
|
||||||
|
| KnowledgeCapacityPreflight
|
||||||
|
| KnowledgeSearchResult
|
||||||
|
| KnowledgeRuntimeStatus
|
||||||
|
| { removed: true }
|
||||||
|
error?: string
|
||||||
|
transport?: {
|
||||||
|
workerReceivedAt: number
|
||||||
|
workerCompletedAt: number
|
||||||
|
responseSerializeMs: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_KNOWLEDGE_CHUNKER: KnowledgeChunkerConfig = {
|
||||||
|
version: DEFAULT_CHUNKER_VERSION,
|
||||||
|
maxGapMs: 10 * 60 * 1000,
|
||||||
|
maxMessages: 12,
|
||||||
|
maxCharacters: 1200,
|
||||||
|
overlapMessages: 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { mkdtempSync } from 'fs'
|
||||||
|
import { rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { performance } from 'perf_hooks'
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
DEFAULT_KNOWLEDGE_CHUNKER,
|
||||||
|
type KnowledgeEvidence,
|
||||||
|
type KnowledgeFtsConfig,
|
||||||
|
type KnowledgeSourceMessage
|
||||||
|
} from '../../src/shared/knowledge'
|
||||||
|
import { KnowledgeStore } from '../../src/main/knowledge/knowledge-store'
|
||||||
|
import {
|
||||||
|
createKnowledgeBenchmarkFixture,
|
||||||
|
type KnowledgeBenchmarkCase
|
||||||
|
} from '../fixtures/knowledge-rag'
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'wxe-knowledge-benchmark-'))
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(root, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
type Retrieval = { messageIds: string[] }
|
||||||
|
type Metrics = {
|
||||||
|
recallAt5: number
|
||||||
|
recallAt10: number
|
||||||
|
mrr: number
|
||||||
|
evidenceAccuracy: number
|
||||||
|
findSuccessAt10: number
|
||||||
|
p50LatencyMs: number
|
||||||
|
p95LatencyMs: number
|
||||||
|
averageInputTokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentile(values: number[], percentileValue: number): number {
|
||||||
|
if (!values.length) return 0
|
||||||
|
const sorted = values.slice().sort((left, right) => left - right)
|
||||||
|
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * percentileValue) - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateInputTokens(messages: KnowledgeSourceMessage[]): number {
|
||||||
|
const chars = messages.reduce((total, message) => total + (message.text || '').length, 0)
|
||||||
|
// Conservative Chinese-oriented baseline: question/system metadata plus the selected old-search context.
|
||||||
|
return 1_000 + Math.ceil(chars / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function oldSearch(
|
||||||
|
messages: KnowledgeSourceMessage[],
|
||||||
|
testCase: KnowledgeBenchmarkCase
|
||||||
|
): Retrieval[] {
|
||||||
|
const normalizedTerms = testCase.oldSearchTerms.map((term) => term.toLowerCase())
|
||||||
|
return messages
|
||||||
|
.map((message) => {
|
||||||
|
const text =
|
||||||
|
`${message.text || ''}\n${message.voiceTranscript || ''}\n${message.attachment?.name || ''}`.toLowerCase()
|
||||||
|
const score = normalizedTerms.reduce(
|
||||||
|
(total, term) => total + (text.includes(term) ? 1 : 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
return { messageIds: [message.messageId], score }
|
||||||
|
})
|
||||||
|
.filter((item) => item.score > 0)
|
||||||
|
.sort((left, right) => right.score - left.score)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreCases(
|
||||||
|
cases: KnowledgeBenchmarkCase[],
|
||||||
|
search: (testCase: KnowledgeBenchmarkCase) => Retrieval[],
|
||||||
|
inputTokens: (testCase: KnowledgeBenchmarkCase) => number
|
||||||
|
): Metrics {
|
||||||
|
const latency: number[] = []
|
||||||
|
let recallAt5 = 0
|
||||||
|
let recallAt10 = 0
|
||||||
|
let reciprocalRank = 0
|
||||||
|
let evidenceAccuracy = 0
|
||||||
|
let findSuccessAt10 = 0
|
||||||
|
let totalInputTokens = 0
|
||||||
|
for (const testCase of cases) {
|
||||||
|
const started = performance.now()
|
||||||
|
const retrieved = search(testCase)
|
||||||
|
latency.push(performance.now() - started)
|
||||||
|
const flattened = retrieved.map((item) => item.messageIds)
|
||||||
|
const expected = new Set(testCase.expectedMessageIds)
|
||||||
|
const hitPosition = flattened.findIndex((ids) => ids.some((id) => expected.has(id)))
|
||||||
|
if (flattened.slice(0, 5).some((ids) => ids.some((id) => expected.has(id)))) recallAt5 += 1
|
||||||
|
if (hitPosition >= 0 && hitPosition < 10) {
|
||||||
|
recallAt10 += 1
|
||||||
|
findSuccessAt10 += 1
|
||||||
|
reciprocalRank += 1 / (hitPosition + 1)
|
||||||
|
}
|
||||||
|
const firstFive = flattened.slice(0, 5)
|
||||||
|
if (firstFive.length) {
|
||||||
|
evidenceAccuracy +=
|
||||||
|
firstFive.filter((ids) => ids.some((id) => expected.has(id))).length / firstFive.length
|
||||||
|
}
|
||||||
|
totalInputTokens += inputTokens(testCase)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
recallAt5: recallAt5 / cases.length,
|
||||||
|
recallAt10: recallAt10 / cases.length,
|
||||||
|
mrr: reciprocalRank / cases.length,
|
||||||
|
evidenceAccuracy: evidenceAccuracy / cases.length,
|
||||||
|
findSuccessAt10: findSuccessAt10 / cases.length,
|
||||||
|
p50LatencyMs: percentile(latency, 0.5),
|
||||||
|
p95LatencyMs: percentile(latency, 0.95),
|
||||||
|
averageInputTokens: totalInputTokens / cases.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles: KnowledgeFtsConfig[] = [
|
||||||
|
{
|
||||||
|
profileId: 'unicode61-external-full-columnsize',
|
||||||
|
tokenizer: 'unicode61',
|
||||||
|
contentMode: 'external',
|
||||||
|
detail: 'full',
|
||||||
|
columnsize: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
profileId: 'trigram-external-full-columnsize',
|
||||||
|
tokenizer: 'trigram',
|
||||||
|
contentMode: 'external',
|
||||||
|
detail: 'full',
|
||||||
|
columnsize: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
profileId: 'trigram-external-column-no-columnsize',
|
||||||
|
tokenizer: 'trigram',
|
||||||
|
contentMode: 'external',
|
||||||
|
detail: 'column',
|
||||||
|
columnsize: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
profileId: 'trigram-internal-none-no-columnsize',
|
||||||
|
tokenizer: 'trigram',
|
||||||
|
contentMode: 'internal',
|
||||||
|
detail: 'none',
|
||||||
|
columnsize: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('desensitized local knowledge benchmark', () => {
|
||||||
|
it('records the 100-question Old Search baseline and FTS5 configuration comparisons', async () => {
|
||||||
|
const fixture = createKnowledgeBenchmarkFixture()
|
||||||
|
expect(fixture.cases).toHaveLength(100)
|
||||||
|
expect(new Set(fixture.cases.map((item) => item.category))).toEqual(
|
||||||
|
new Set(['fact', 'person', 'time', 'decision', 'semantic'])
|
||||||
|
)
|
||||||
|
const sourceMessages = fixture.conversations.flatMap((conversation) => conversation.messages)
|
||||||
|
const oldMetrics = scoreCases(
|
||||||
|
fixture.cases,
|
||||||
|
(testCase) => oldSearch(sourceMessages, testCase),
|
||||||
|
(testCase) => {
|
||||||
|
const selected = oldSearch(sourceMessages, testCase).slice(0, 240)
|
||||||
|
const ids = new Set(selected.flatMap((item) => item.messageIds))
|
||||||
|
return estimateInputTokens(sourceMessages.filter((message) => ids.has(message.messageId)))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const comparisons: Array<{
|
||||||
|
profile: KnowledgeFtsConfig
|
||||||
|
metrics: Metrics
|
||||||
|
databaseBytes: number
|
||||||
|
}> = []
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const store = new KnowledgeStore(
|
||||||
|
join(root, profile.profileId),
|
||||||
|
sourceMessages[0].accountId,
|
||||||
|
profile
|
||||||
|
)
|
||||||
|
await store.index({
|
||||||
|
conversations: fixture.conversations,
|
||||||
|
chunker: DEFAULT_KNOWLEDGE_CHUNKER
|
||||||
|
})
|
||||||
|
const metrics = scoreCases(
|
||||||
|
fixture.cases,
|
||||||
|
(testCase) =>
|
||||||
|
store.search({
|
||||||
|
accountId: sourceMessages[0].accountId,
|
||||||
|
text: testCase.question,
|
||||||
|
terms: testCase.oldSearchTerms,
|
||||||
|
limit: 10
|
||||||
|
}),
|
||||||
|
(testCase) => {
|
||||||
|
const evidence: KnowledgeEvidence[] = store.search({
|
||||||
|
accountId: sourceMessages[0].accountId,
|
||||||
|
text: testCase.question,
|
||||||
|
terms: testCase.oldSearchTerms,
|
||||||
|
limit: 10
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
1_000 + Math.ceil(evidence.reduce((total, item) => total + item.text.length, 0) / 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
store.checkpoint()
|
||||||
|
comparisons.push({ profile, metrics, databaseBytes: store.getStorageStats().databaseBytes })
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`KNOWLEDGE_BENCHMARK_REPORT=${JSON.stringify(
|
||||||
|
{
|
||||||
|
fixture: 'synthetic-desensitized-v1',
|
||||||
|
questions: fixture.cases.length,
|
||||||
|
categories: ['fact', 'person', 'time', 'decision', 'semantic'],
|
||||||
|
oldSearch: oldMetrics,
|
||||||
|
fts5Comparisons: comparisons
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
expect(oldMetrics.averageInputTokens).toBeGreaterThan(1_000)
|
||||||
|
expect(comparisons).toHaveLength(profiles.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { mkdtempSync } from 'fs'
|
||||||
|
import { rename, rm, writeFile } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { performance } from 'perf_hooks'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { DEFAULT_KNOWLEDGE_CHUNKER, type KnowledgeFtsConfig } from '../../src/shared/knowledge'
|
||||||
|
import { KnowledgeStore } from '../../src/main/knowledge/knowledge-store'
|
||||||
|
import { createSyntheticConversation, FIXTURE_ACCOUNT_A } from '../fixtures/knowledge-rag'
|
||||||
|
|
||||||
|
const runCapacity = process.env.KNOWLEDGE_CAPACITY === '1'
|
||||||
|
const capacityIt = runCapacity ? it : it.skip
|
||||||
|
const scales = [100_000, 500_000, 1_000_000] as const
|
||||||
|
const distributions = ['short', 'mixed', 'long'] as const
|
||||||
|
const batchSize = 10_000
|
||||||
|
const reportPath = process.env.KNOWLEDGE_CAPACITY_REPORT_PATH || join(tmpdir(), 'wechatexplorer-knowledge-capacity-report.json')
|
||||||
|
const profile: KnowledgeFtsConfig = {
|
||||||
|
profileId: 'capacity-unicode-external-full-columnsize',
|
||||||
|
tokenizer: 'unicode61',
|
||||||
|
contentMode: 'external',
|
||||||
|
detail: 'full',
|
||||||
|
columnsize: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('knowledge capacity benchmark', () => {
|
||||||
|
capacityIt(
|
||||||
|
'measures 100k, 500k and 1m desensitized messages across text distributions',
|
||||||
|
async () => {
|
||||||
|
const reports: Array<Record<string, number | string>> = []
|
||||||
|
for (const distribution of distributions) {
|
||||||
|
for (const messageCount of scales) {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), `wxe-knowledge-capacity-${distribution}-${messageCount}-`))
|
||||||
|
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, profile)
|
||||||
|
let peakWalBytes = 0
|
||||||
|
let peakTemporaryBytes = 0
|
||||||
|
let peakRssBytes = process.memoryUsage().rss
|
||||||
|
const started = performance.now()
|
||||||
|
try {
|
||||||
|
for (let offset = 0; offset < messageCount; offset += batchSize) {
|
||||||
|
const count = Math.min(batchSize, messageCount - offset)
|
||||||
|
await store.index({
|
||||||
|
conversations: [
|
||||||
|
createSyntheticConversation(
|
||||||
|
FIXTURE_ACCOUNT_A,
|
||||||
|
`capacity-${distribution}-${offset / batchSize}`,
|
||||||
|
offset,
|
||||||
|
count,
|
||||||
|
distribution
|
||||||
|
)
|
||||||
|
],
|
||||||
|
chunker: DEFAULT_KNOWLEDGE_CHUNKER
|
||||||
|
})
|
||||||
|
const stats = store.getStorageStats()
|
||||||
|
peakWalBytes = Math.max(peakWalBytes, stats.walBytes)
|
||||||
|
peakTemporaryBytes = Math.max(peakTemporaryBytes, stats.walBytes + stats.shmBytes)
|
||||||
|
peakRssBytes = Math.max(peakRssBytes, process.memoryUsage().rss)
|
||||||
|
}
|
||||||
|
store.checkpoint()
|
||||||
|
const stats = store.getStorageStats()
|
||||||
|
reports.push({
|
||||||
|
profile: profile.profileId,
|
||||||
|
distribution,
|
||||||
|
messageCount,
|
||||||
|
finalDatabaseBytes: stats.databaseBytes,
|
||||||
|
perTenThousandMessagesBytes: Math.round(stats.databaseBytes / (messageCount / 10_000)),
|
||||||
|
peakWalBytes,
|
||||||
|
peakTemporaryBytes,
|
||||||
|
elapsedMs: Math.round(performance.now() - started),
|
||||||
|
workerPeakRssBytes: peakRssBytes,
|
||||||
|
pageSize: stats.pageSize,
|
||||||
|
pageCount: stats.pageCount,
|
||||||
|
freelistCount: stats.freelistCount
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
store.close()
|
||||||
|
await rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const report = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
fixture: 'synthetic-desensitized-v1',
|
||||||
|
profile,
|
||||||
|
scenarios: reports
|
||||||
|
}
|
||||||
|
const temporaryReportPath = `${reportPath}.partial`
|
||||||
|
await writeFile(temporaryReportPath, JSON.stringify(report, null, 2), 'utf8')
|
||||||
|
await rename(temporaryReportPath, reportPath)
|
||||||
|
console.log(`KNOWLEDGE_CAPACITY_REPORT_PATH=${reportPath}`)
|
||||||
|
expect(reports).toHaveLength(scales.length * distributions.length)
|
||||||
|
},
|
||||||
|
20 * 60 * 1000
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { mkdtempSync } from 'fs'
|
||||||
|
import { rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { performance } from 'perf_hooks'
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
DEFAULT_KNOWLEDGE_CHUNKER,
|
||||||
|
type KnowledgeEvidence,
|
||||||
|
type KnowledgeFtsConfig,
|
||||||
|
type KnowledgeSourceMessage
|
||||||
|
} from '../../src/shared/knowledge'
|
||||||
|
import { KnowledgeStore } from '../../src/main/knowledge/knowledge-store'
|
||||||
|
import {
|
||||||
|
createRealisticKnowledgeFixture,
|
||||||
|
type RealisticBenchmarkCase,
|
||||||
|
type RealisticBenchmarkCategory
|
||||||
|
} from '../fixtures/knowledge-realistic'
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'wxe-realistic-fts-'))
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(root, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
type Retrieval = { messageIds: string[] }
|
||||||
|
type Metrics = {
|
||||||
|
recallAt5: number
|
||||||
|
recallAt10: number
|
||||||
|
mrr: number
|
||||||
|
evidenceAccuracy: number
|
||||||
|
findSuccessAt10: number
|
||||||
|
p50LatencyMs: number
|
||||||
|
p95LatencyMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentile(values: number[], ratio: number): number {
|
||||||
|
if (!values.length) return 0
|
||||||
|
const sorted = values.slice().sort((left, right) => left - right)
|
||||||
|
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function oldSearch(
|
||||||
|
messages: KnowledgeSourceMessage[],
|
||||||
|
testCase: RealisticBenchmarkCase
|
||||||
|
): Retrieval[] {
|
||||||
|
return messages
|
||||||
|
.map((message) => {
|
||||||
|
const text =
|
||||||
|
`${message.text || ''}\n${message.voiceTranscript || ''}\n${message.attachment?.name || ''}`.toLowerCase()
|
||||||
|
const score = testCase.searchTerms.reduce(
|
||||||
|
(total, term) => total + (text.includes(term.toLowerCase()) ? 1 : 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
return { messageIds: [message.messageId], score }
|
||||||
|
})
|
||||||
|
.filter((item) => item.score > 0)
|
||||||
|
.sort((left, right) => right.score - left.score)
|
||||||
|
}
|
||||||
|
|
||||||
|
function score(
|
||||||
|
cases: RealisticBenchmarkCase[],
|
||||||
|
search: (testCase: RealisticBenchmarkCase) => Retrieval[]
|
||||||
|
): Metrics {
|
||||||
|
const latency: number[] = []
|
||||||
|
let recallAt5 = 0
|
||||||
|
let recallAt10 = 0
|
||||||
|
let reciprocalRank = 0
|
||||||
|
let evidenceAccuracy = 0
|
||||||
|
let findSuccessAt10 = 0
|
||||||
|
for (const testCase of cases) {
|
||||||
|
const started = performance.now()
|
||||||
|
const results = search(testCase)
|
||||||
|
latency.push(performance.now() - started)
|
||||||
|
const expected = new Set(testCase.expectedMessageIds)
|
||||||
|
const firstTen = results.slice(0, 10)
|
||||||
|
const hitIndex = firstTen.findIndex((item) => item.messageIds.some((id) => expected.has(id)))
|
||||||
|
if (results.slice(0, 5).some((item) => item.messageIds.some((id) => expected.has(id)))) {
|
||||||
|
recallAt5 += 1
|
||||||
|
}
|
||||||
|
if (hitIndex >= 0) {
|
||||||
|
recallAt10 += 1
|
||||||
|
findSuccessAt10 += 1
|
||||||
|
reciprocalRank += 1 / (hitIndex + 1)
|
||||||
|
}
|
||||||
|
const firstFive = results.slice(0, 5)
|
||||||
|
if (firstFive.length) {
|
||||||
|
evidenceAccuracy +=
|
||||||
|
firstFive.filter((item) => item.messageIds.some((id) => expected.has(id))).length /
|
||||||
|
firstFive.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
recallAt5: recallAt5 / cases.length,
|
||||||
|
recallAt10: recallAt10 / cases.length,
|
||||||
|
mrr: reciprocalRank / cases.length,
|
||||||
|
evidenceAccuracy: evidenceAccuracy / cases.length,
|
||||||
|
findSuccessAt10: findSuccessAt10 / cases.length,
|
||||||
|
p50LatencyMs: percentile(latency, 0.5),
|
||||||
|
p95LatencyMs: percentile(latency, 0.95)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupedByCategory<T>(
|
||||||
|
cases: RealisticBenchmarkCase[],
|
||||||
|
evaluate: (items: RealisticBenchmarkCase[]) => T
|
||||||
|
): Record<RealisticBenchmarkCategory, T> {
|
||||||
|
const groups = new Map<RealisticBenchmarkCategory, RealisticBenchmarkCase[]>()
|
||||||
|
for (const item of cases) groups.set(item.category, [...(groups.get(item.category) || []), item])
|
||||||
|
return Object.fromEntries(
|
||||||
|
Array.from(groups.entries()).map(([category, items]) => [category, evaluate(items)])
|
||||||
|
) as Record<RealisticBenchmarkCategory, T>
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles: KnowledgeFtsConfig[] = [
|
||||||
|
{
|
||||||
|
profileId: 'unicode61-external-full-columnsize',
|
||||||
|
tokenizer: 'unicode61',
|
||||||
|
contentMode: 'external',
|
||||||
|
detail: 'full',
|
||||||
|
columnsize: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
profileId: 'trigram-external-full-columnsize',
|
||||||
|
tokenizer: 'trigram',
|
||||||
|
contentMode: 'external',
|
||||||
|
detail: 'full',
|
||||||
|
columnsize: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
profileId: 'trigram-external-column-no-columnsize',
|
||||||
|
tokenizer: 'trigram',
|
||||||
|
contentMode: 'external',
|
||||||
|
detail: 'column',
|
||||||
|
columnsize: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('realistic desensitized WeChat FTS5 benchmark', () => {
|
||||||
|
it('compares unicode61 and trigram by recall quality before Task 3 chooses a runtime profile', async () => {
|
||||||
|
const fixture = createRealisticKnowledgeFixture()
|
||||||
|
const messages = fixture.conversations.flatMap((conversation) => conversation.messages)
|
||||||
|
const oldMetrics = score(fixture.cases, (testCase) => oldSearch(messages, testCase))
|
||||||
|
const comparisons: Array<{
|
||||||
|
profile: KnowledgeFtsConfig
|
||||||
|
metrics: Metrics
|
||||||
|
categoryMetrics: Record<RealisticBenchmarkCategory, Metrics>
|
||||||
|
databaseBytes: number
|
||||||
|
}> = []
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const store = new KnowledgeStore(
|
||||||
|
join(root, profile.profileId),
|
||||||
|
messages[0].accountId,
|
||||||
|
profile
|
||||||
|
)
|
||||||
|
await store.index({
|
||||||
|
conversations: fixture.conversations,
|
||||||
|
chunker: DEFAULT_KNOWLEDGE_CHUNKER
|
||||||
|
})
|
||||||
|
const search = (testCase: RealisticBenchmarkCase): Retrieval[] =>
|
||||||
|
store
|
||||||
|
.search({
|
||||||
|
accountId: messages[0].accountId,
|
||||||
|
text: testCase.question,
|
||||||
|
terms: testCase.searchTerms,
|
||||||
|
limit: 10
|
||||||
|
})
|
||||||
|
.map((item: KnowledgeEvidence) => ({ messageIds: item.messageIds }))
|
||||||
|
const metrics = score(fixture.cases, search)
|
||||||
|
store.checkpoint()
|
||||||
|
comparisons.push({
|
||||||
|
profile,
|
||||||
|
metrics,
|
||||||
|
categoryMetrics: groupedByCategory(fixture.cases, (items) => score(items, search)),
|
||||||
|
databaseBytes: store.getStorageStats().databaseBytes
|
||||||
|
})
|
||||||
|
store.close()
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`KNOWLEDGE_REALISTIC_FTS_REPORT=${JSON.stringify(
|
||||||
|
{
|
||||||
|
fixture: 'realistic-desensitized-wechat-v1',
|
||||||
|
questionCount: fixture.cases.length,
|
||||||
|
categories: Array.from(new Set(fixture.cases.map((item) => item.category))),
|
||||||
|
oldSearch: oldMetrics,
|
||||||
|
fts5Comparisons: comparisons
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
expect(fixture.cases).toHaveLength(14)
|
||||||
|
expect(new Set(fixture.cases.map((item) => item.category))).toEqual(
|
||||||
|
new Set([
|
||||||
|
'chinese-continuous',
|
||||||
|
'chinese-short',
|
||||||
|
'person-name',
|
||||||
|
'mixed-language',
|
||||||
|
'url',
|
||||||
|
'file-name',
|
||||||
|
'technical-term',
|
||||||
|
'number-email-path',
|
||||||
|
'short-message',
|
||||||
|
'long-voice'
|
||||||
|
])
|
||||||
|
)
|
||||||
|
expect(comparisons).toHaveLength(profiles.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
Vendored
+158
@@ -0,0 +1,158 @@
|
|||||||
|
import type { KnowledgeConversationInput, KnowledgeSourceMessage } from '../../src/shared/knowledge'
|
||||||
|
|
||||||
|
export type KnowledgeBenchmarkCategory = 'fact' | 'person' | 'time' | 'decision' | 'semantic'
|
||||||
|
|
||||||
|
export interface KnowledgeBenchmarkCase {
|
||||||
|
id: string
|
||||||
|
category: KnowledgeBenchmarkCategory
|
||||||
|
question: string
|
||||||
|
oldSearchTerms: string[]
|
||||||
|
expectedMessageIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FIXTURE_ACCOUNT_A = 'fixture-account-alpha'
|
||||||
|
export const FIXTURE_ACCOUNT_B = 'fixture-account-beta'
|
||||||
|
|
||||||
|
function message(
|
||||||
|
id: string,
|
||||||
|
conversationId: string,
|
||||||
|
createTime: number,
|
||||||
|
text: string,
|
||||||
|
extra: Partial<KnowledgeSourceMessage> = {}
|
||||||
|
): KnowledgeSourceMessage {
|
||||||
|
return {
|
||||||
|
accountId: extra.accountId || FIXTURE_ACCOUNT_A,
|
||||||
|
conversationId,
|
||||||
|
messageId: id,
|
||||||
|
createTime,
|
||||||
|
senderId: extra.senderId || 'fixture-sender',
|
||||||
|
senderName: extra.senderName || '脱敏成员',
|
||||||
|
kind: extra.kind || 'text',
|
||||||
|
text,
|
||||||
|
attachment: extra.attachment,
|
||||||
|
voiceTranscript: extra.voiceTranscript
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic and fully synthetic: no wxid, file path, secret, or real chat text. */
|
||||||
|
export function createKnowledgeBenchmarkFixture(): {
|
||||||
|
conversations: KnowledgeConversationInput[]
|
||||||
|
cases: KnowledgeBenchmarkCase[]
|
||||||
|
} {
|
||||||
|
const messages: KnowledgeSourceMessage[] = []
|
||||||
|
const cases: KnowledgeBenchmarkCase[] = []
|
||||||
|
const base = Date.UTC(2025, 0, 1)
|
||||||
|
const add = (
|
||||||
|
category: KnowledgeBenchmarkCategory,
|
||||||
|
index: number,
|
||||||
|
text: string,
|
||||||
|
question: string,
|
||||||
|
oldSearchTerms: string[],
|
||||||
|
extra: Partial<KnowledgeSourceMessage> = {}
|
||||||
|
): void => {
|
||||||
|
const conversationId = `fixture-${category}-${index % 5}`
|
||||||
|
const id = `fixture-${category}-${index}-evidence`
|
||||||
|
messages.push(message(id, conversationId, base + (cases.length + 1) * 60_000, text, extra))
|
||||||
|
messages.push(
|
||||||
|
message(
|
||||||
|
`fixture-${category}-${index}-context`,
|
||||||
|
conversationId,
|
||||||
|
base + (cases.length + 1) * 60_000 + 20_000,
|
||||||
|
`脱敏上下文 ${index}:确认后续会回到原始消息核对。`,
|
||||||
|
{ senderId: 'fixture-context', senderName: '脱敏同事' }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cases.push({
|
||||||
|
id: `question-${category}-${index}`,
|
||||||
|
category,
|
||||||
|
question,
|
||||||
|
oldSearchTerms,
|
||||||
|
expectedMessageIds: [id]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for (let index = 1; index <= 20; index += 1) {
|
||||||
|
add(
|
||||||
|
'fact',
|
||||||
|
index,
|
||||||
|
`资料编号 FACT-${index} 的部署地址是 https://example.invalid/fact-${index},附件名称是 runbook-${index}.pdf。`,
|
||||||
|
`第 ${index} 项部署资料在哪里?`,
|
||||||
|
[`FACT-${index}`, `runbook-${index}.pdf`],
|
||||||
|
{ attachment: { name: `runbook-${index}.pdf`, kind: 'file' } }
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
'person',
|
||||||
|
index,
|
||||||
|
`成员 代号成员${index} 负责发布检查,并说明本周会完成验证清单。`,
|
||||||
|
`代号成员${index} 最近负责什么?`,
|
||||||
|
[`代号成员${index}`, '发布检查']
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
'time',
|
||||||
|
index,
|
||||||
|
`日期标记 TIME-${index}:在第 ${index} 次周会讨论了回归安排和验收顺序。`,
|
||||||
|
`TIME-${index} 当天讨论了什么?`,
|
||||||
|
[`TIME-${index}`, '回归安排']
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
'decision',
|
||||||
|
index,
|
||||||
|
`决策 DECISION-${index}:最终选择方案蓝图${index},原因是可追溯、可回滚且维护成本更低。`,
|
||||||
|
`为什么第 ${index} 个决策选择方案蓝图${index}?`,
|
||||||
|
[`DECISION-${index}`, `方案蓝图${index}`]
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
'semantic',
|
||||||
|
index,
|
||||||
|
`语义样本 ${index}:把分散的讨论归档,方便以后重新查看和核对当时的上下文。`,
|
||||||
|
`哪里提到把内容收起来以后查看?第 ${index} 条。`,
|
||||||
|
[`内容收起来${index}`],
|
||||||
|
{ kind: 'voice', voiceTranscript: `请将分散讨论集中保存,便于之后重新查看,第 ${index} 条。` }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const grouped = new Map<string, KnowledgeSourceMessage[]>()
|
||||||
|
for (const item of messages) {
|
||||||
|
const current = grouped.get(item.conversationId) || []
|
||||||
|
current.push(item)
|
||||||
|
grouped.set(item.conversationId, current)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
conversations: Array.from(grouped.entries()).map(([conversationId, source]) => ({
|
||||||
|
conversationId,
|
||||||
|
completeSnapshot: true,
|
||||||
|
messages: source
|
||||||
|
})),
|
||||||
|
cases
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSyntheticConversation(
|
||||||
|
accountId: string,
|
||||||
|
conversationId: string,
|
||||||
|
startIndex: number,
|
||||||
|
count: number,
|
||||||
|
distribution: 'short' | 'mixed' | 'long'
|
||||||
|
): KnowledgeConversationInput {
|
||||||
|
const base = Date.UTC(2025, 0, 1) + startIndex * 1000
|
||||||
|
const messages: KnowledgeSourceMessage[] = []
|
||||||
|
const shortText = '脱敏短消息:已确认。'
|
||||||
|
const mixedText = '脱敏普通消息:讨论本地知识库、索引状态、证据回跳和增量恢复。'
|
||||||
|
const longText = `脱敏长文本/语音转写:${'用于容量测试的可检索上下文。'.repeat(12)}`
|
||||||
|
for (let offset = 0; offset < count; offset += 1) {
|
||||||
|
const index = startIndex + offset
|
||||||
|
const text = distribution === 'short' ? shortText : distribution === 'mixed' ? mixedText : longText
|
||||||
|
messages.push({
|
||||||
|
accountId,
|
||||||
|
conversationId,
|
||||||
|
messageId: `synthetic-${distribution}-${index}`,
|
||||||
|
createTime: base + offset * 60_000,
|
||||||
|
senderId: `fixture-member-${index % 8}`,
|
||||||
|
senderName: `脱敏成员${index % 8}`,
|
||||||
|
kind: distribution === 'long' && index % 4 === 0 ? 'voice' : 'text',
|
||||||
|
text,
|
||||||
|
voiceTranscript: distribution === 'long' && index % 4 === 0 ? longText : undefined,
|
||||||
|
attachment:
|
||||||
|
index % 97 === 0 ? { name: `fixture-${index}.txt`, kind: 'file', sizeBytes: 2048 } : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { conversationId, completeSnapshot: true, messages }
|
||||||
|
}
|
||||||
+304
@@ -0,0 +1,304 @@
|
|||||||
|
import type { KnowledgeConversationInput, KnowledgeSourceMessage } from '../../src/shared/knowledge'
|
||||||
|
|
||||||
|
export type RealisticBenchmarkCategory =
|
||||||
|
| 'chinese-continuous'
|
||||||
|
| 'chinese-short'
|
||||||
|
| 'person-name'
|
||||||
|
| 'mixed-language'
|
||||||
|
| 'url'
|
||||||
|
| 'file-name'
|
||||||
|
| 'technical-term'
|
||||||
|
| 'number-email-path'
|
||||||
|
| 'short-message'
|
||||||
|
| 'long-voice'
|
||||||
|
|
||||||
|
export interface RealisticBenchmarkCase {
|
||||||
|
id: string
|
||||||
|
category: RealisticBenchmarkCategory
|
||||||
|
/** The question as a user would naturally phrase it. */
|
||||||
|
question: string
|
||||||
|
/** Deterministic local query-router output, not an LLM-generated answer. */
|
||||||
|
searchTerms: string[]
|
||||||
|
expectedMessageIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REALISTIC_FIXTURE_ACCOUNT = 'fixture-realistic-account'
|
||||||
|
|
||||||
|
function sourceMessage(
|
||||||
|
messageId: string,
|
||||||
|
conversationId: string,
|
||||||
|
createTime: number,
|
||||||
|
text: string,
|
||||||
|
extra: Partial<KnowledgeSourceMessage> = {}
|
||||||
|
): KnowledgeSourceMessage {
|
||||||
|
return {
|
||||||
|
accountId: REALISTIC_FIXTURE_ACCOUNT,
|
||||||
|
conversationId,
|
||||||
|
messageId,
|
||||||
|
createTime,
|
||||||
|
senderId: extra.senderId || 'fixture-member-a',
|
||||||
|
senderName: extra.senderName || '脱敏成员甲',
|
||||||
|
kind: extra.kind || 'text',
|
||||||
|
text,
|
||||||
|
attachment: extra.attachment,
|
||||||
|
voiceTranscript: extra.voiceTranscript
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully artificial messages written in the style of real WeChat conversations.
|
||||||
|
* Nicknames, domains, addresses, mailboxes, file names and paths are all fixtures;
|
||||||
|
* no user chat record, wxid, account directory, or source-database value is included.
|
||||||
|
*/
|
||||||
|
export function createRealisticKnowledgeFixture(): {
|
||||||
|
conversations: KnowledgeConversationInput[]
|
||||||
|
cases: RealisticBenchmarkCase[]
|
||||||
|
} {
|
||||||
|
const base = Date.UTC(2026, 6, 12, 8, 0, 0)
|
||||||
|
const messages: KnowledgeSourceMessage[] = [
|
||||||
|
sourceMessage(
|
||||||
|
'msg-cn-continuous-1',
|
||||||
|
'conv-product-group',
|
||||||
|
base,
|
||||||
|
'刚确认:聊天档案导出失败时,先保留原图链接,再回退缩略图,避免用户以为图片丢了。',
|
||||||
|
{ senderId: 'member-lan', senderName: '蓝图同学' }
|
||||||
|
),
|
||||||
|
sourceMessage(
|
||||||
|
'msg-cn-continuous-noise',
|
||||||
|
'conv-product-group',
|
||||||
|
base + 60,
|
||||||
|
'导出完成后可以在任务中心查看文件夹。',
|
||||||
|
{ senderId: 'member-river', senderName: '河岸' }
|
||||||
|
),
|
||||||
|
sourceMessage('msg-cn-short-1', 'conv-family', base + 120, '周六见,咖啡我来带。', {
|
||||||
|
senderId: 'member-yu',
|
||||||
|
senderName: '小雨'
|
||||||
|
}),
|
||||||
|
sourceMessage(
|
||||||
|
'msg-person-1',
|
||||||
|
'conv-product-group',
|
||||||
|
base + 180,
|
||||||
|
'林澈把 Windows 安装包的签名检查补好了,今晚发测试包。',
|
||||||
|
{ senderId: 'member-lin', senderName: '林澈' }
|
||||||
|
),
|
||||||
|
sourceMessage(
|
||||||
|
'msg-person-noise',
|
||||||
|
'conv-product-group',
|
||||||
|
base + 240,
|
||||||
|
'小林晚点把截图发到群里。',
|
||||||
|
{ senderId: 'member-lin', senderName: '林澈' }
|
||||||
|
),
|
||||||
|
sourceMessage(
|
||||||
|
'msg-mixed-1',
|
||||||
|
'conv-engineering',
|
||||||
|
base + 300,
|
||||||
|
'Web 端的 dark mode 先跟随系统,Desktop 端继续保留手动切换。',
|
||||||
|
{ senderId: 'member-echo', senderName: 'Echo' }
|
||||||
|
),
|
||||||
|
sourceMessage(
|
||||||
|
'msg-url-1',
|
||||||
|
'conv-engineering',
|
||||||
|
base + 360,
|
||||||
|
'排障说明在 https://docs.example.invalid/guide/image-export?from=wechat ,不要把真实日志贴到公开 issue。',
|
||||||
|
{ senderId: 'member-echo', senderName: 'Echo' }
|
||||||
|
),
|
||||||
|
sourceMessage(
|
||||||
|
'msg-file-1',
|
||||||
|
'conv-filehelper',
|
||||||
|
base + 420,
|
||||||
|
'已上传 release-checklist-v2.1.9.xlsx,发布前把 macOS 和 Windows 两栏都勾完。',
|
||||||
|
{
|
||||||
|
senderId: 'self-fixture',
|
||||||
|
senderName: '我',
|
||||||
|
attachment: { name: 'release-checklist-v2.1.9.xlsx', kind: 'file', sizeBytes: 20480 }
|
||||||
|
}
|
||||||
|
),
|
||||||
|
sourceMessage(
|
||||||
|
'msg-tech-mcp',
|
||||||
|
'conv-engineering',
|
||||||
|
base + 480,
|
||||||
|
'MCP Reader 只暴露只读查询;写入操作必须经过本地确认,不能让 Agent 直接改微信数据。',
|
||||||
|
{ senderId: 'member-q', senderName: 'Q' }
|
||||||
|
),
|
||||||
|
sourceMessage(
|
||||||
|
'msg-tech-react',
|
||||||
|
'conv-engineering',
|
||||||
|
base + 540,
|
||||||
|
'React 列表先做虚拟滚动,Electron 主进程不要把十万条消息一次性发给 renderer。',
|
||||||
|
{ senderId: 'member-q', senderName: 'Q' }
|
||||||
|
),
|
||||||
|
sourceMessage(
|
||||||
|
'msg-tech-sqlite',
|
||||||
|
'conv-engineering',
|
||||||
|
base + 600,
|
||||||
|
'SQLite FTS5 的 trigram 对中文子串更友好,但短词仍要有精确匹配补偿。',
|
||||||
|
{ senderId: 'member-lan', senderName: '蓝图同学' }
|
||||||
|
),
|
||||||
|
sourceMessage(
|
||||||
|
'msg-number-email-path',
|
||||||
|
'conv-operations',
|
||||||
|
base + 660,
|
||||||
|
'工单 48291 请发给 fixture@example.invalid;测试附件放到 /tmp/wechat-fixture/export-preview/,不要使用个人目录。',
|
||||||
|
{ senderId: 'member-ops', senderName: '运营小组' }
|
||||||
|
),
|
||||||
|
sourceMessage('msg-short-1', 'conv-family', base + 720, '收到,明早十点。', {
|
||||||
|
senderId: 'member-yu',
|
||||||
|
senderName: '小雨'
|
||||||
|
}),
|
||||||
|
sourceMessage('msg-short-noise', 'conv-family', base + 780, '好的,晚安。', {
|
||||||
|
senderId: 'member-yu',
|
||||||
|
senderName: '小雨'
|
||||||
|
}),
|
||||||
|
sourceMessage('msg-voice-long-1', 'conv-project-sync', base + 840, '[语音消息]', {
|
||||||
|
senderId: 'member-voice',
|
||||||
|
senderName: '语音同学',
|
||||||
|
kind: 'voice',
|
||||||
|
voiceTranscript:
|
||||||
|
'刚才同步一下长语音结论:本周不做向量检索,也不新增记忆页面。先把现有问问微信的关键词检索放进独立 Knowledge Worker,索引只读取原始数据库,结果必须保留 messageId、会话、发送人和时间,异常时继续使用旧搜索。'
|
||||||
|
}),
|
||||||
|
sourceMessage(
|
||||||
|
'msg-decision-1',
|
||||||
|
'conv-project-sync',
|
||||||
|
base + 900,
|
||||||
|
'决定先上 FTS,不接 Embedding:先验证中文、文件名和技术词的召回,再考虑下一阶段。',
|
||||||
|
{ senderId: 'member-voice', senderName: '语音同学' }
|
||||||
|
),
|
||||||
|
sourceMessage(
|
||||||
|
'msg-url-noise',
|
||||||
|
'conv-engineering',
|
||||||
|
base + 960,
|
||||||
|
'本周会议链接仍然走内部日历,不要混在发布文档里。',
|
||||||
|
{ senderId: 'member-echo', senderName: 'Echo' }
|
||||||
|
),
|
||||||
|
sourceMessage(
|
||||||
|
'msg-file-noise',
|
||||||
|
'conv-filehelper',
|
||||||
|
base + 1020,
|
||||||
|
'旧版 release-note.txt 仅供历史核对,不要再上传。',
|
||||||
|
{ senderId: 'self-fixture', senderName: '我' }
|
||||||
|
),
|
||||||
|
sourceMessage(
|
||||||
|
'msg-long-text-1',
|
||||||
|
'conv-project-sync',
|
||||||
|
base + 1080,
|
||||||
|
'补充记录:当索引仍在建立或 Worker 发生异常,界面行为不能中断。主进程需要保留旧关键词检索作为临时回退,但 renderer 不应重新批量加载全部会话消息。等索引完成后,Evidence 应统一由知识库返回,并能跳回原聊天。',
|
||||||
|
{ senderId: 'member-lan', senderName: '蓝图同学' }
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
const cases: RealisticBenchmarkCase[] = [
|
||||||
|
{
|
||||||
|
id: 'cn-continuous',
|
||||||
|
category: 'chinese-continuous',
|
||||||
|
question: '图片导出失败时应该怎样避免用户误以为图片丢失?',
|
||||||
|
searchTerms: ['原图链接', '缩略图'],
|
||||||
|
expectedMessageIds: ['msg-cn-continuous-1']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cn-short',
|
||||||
|
category: 'chinese-short',
|
||||||
|
question: '周六谁带咖啡?',
|
||||||
|
searchTerms: ['周六见', '咖啡'],
|
||||||
|
expectedMessageIds: ['msg-cn-short-1']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'person-name',
|
||||||
|
category: 'person-name',
|
||||||
|
question: '林澈最近补了什么?',
|
||||||
|
searchTerms: ['林澈', '签名检查'],
|
||||||
|
expectedMessageIds: ['msg-person-1']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mixed-language',
|
||||||
|
category: 'mixed-language',
|
||||||
|
question: 'dark mode 在 Web 和 Desktop 分别怎么处理?',
|
||||||
|
searchTerms: ['dark mode', 'Desktop'],
|
||||||
|
expectedMessageIds: ['msg-mixed-1']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'url',
|
||||||
|
category: 'url',
|
||||||
|
question: '图片导出排障文档的网址是什么?',
|
||||||
|
searchTerms: ['docs.example.invalid/guide/image-export'],
|
||||||
|
expectedMessageIds: ['msg-url-1']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'file-name',
|
||||||
|
category: 'file-name',
|
||||||
|
question: '发布检查表文件叫什么?',
|
||||||
|
searchTerms: ['release-checklist-v2.1.9.xlsx'],
|
||||||
|
expectedMessageIds: ['msg-file-1']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'technical-mcp',
|
||||||
|
category: 'technical-term',
|
||||||
|
question: 'MCP Reader 的写入限制是什么?',
|
||||||
|
searchTerms: ['MCP Reader', '只读查询'],
|
||||||
|
expectedMessageIds: ['msg-tech-mcp']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'technical-react-electron',
|
||||||
|
category: 'technical-term',
|
||||||
|
question: 'React 和 Electron 的大量消息处理原则是什么?',
|
||||||
|
searchTerms: ['React', 'Electron'],
|
||||||
|
expectedMessageIds: ['msg-tech-react']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'technical-sqlite',
|
||||||
|
category: 'technical-term',
|
||||||
|
question: 'SQLite 的中文全文检索要选什么?',
|
||||||
|
searchTerms: ['SQLite FTS5', 'trigram'],
|
||||||
|
expectedMessageIds: ['msg-tech-sqlite']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'number-email-path',
|
||||||
|
category: 'number-email-path',
|
||||||
|
question: '工单 48291 的邮箱和测试附件目录在哪?',
|
||||||
|
searchTerms: ['48291', 'fixture@example.invalid', '/tmp/wechat-fixture/export-preview'],
|
||||||
|
expectedMessageIds: ['msg-number-email-path']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'short-message',
|
||||||
|
category: 'short-message',
|
||||||
|
question: '约的是几点?',
|
||||||
|
searchTerms: ['十点'],
|
||||||
|
expectedMessageIds: ['msg-short-1']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'long-voice',
|
||||||
|
category: 'long-voice',
|
||||||
|
question: '长语音里对 Knowledge Worker 和 fallback 的要求是什么?',
|
||||||
|
searchTerms: ['Knowledge Worker', '旧搜索'],
|
||||||
|
expectedMessageIds: ['msg-voice-long-1']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'decision',
|
||||||
|
category: 'long-voice',
|
||||||
|
question: '为什么暂时不接 Embedding?',
|
||||||
|
searchTerms: ['不接 Embedding', 'FTS'],
|
||||||
|
expectedMessageIds: ['msg-decision-1']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fallback',
|
||||||
|
category: 'long-voice',
|
||||||
|
question: '索引未完成时搜索如何处理?',
|
||||||
|
searchTerms: ['Worker 发生异常', '旧关键词检索'],
|
||||||
|
expectedMessageIds: ['msg-long-text-1']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const grouped = new Map<string, KnowledgeSourceMessage[]>()
|
||||||
|
for (const item of messages) {
|
||||||
|
const current = grouped.get(item.conversationId) || []
|
||||||
|
current.push(item)
|
||||||
|
grouped.set(item.conversationId, current)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
conversations: Array.from(grouped.entries()).map(([conversationId, source]) => ({
|
||||||
|
conversationId,
|
||||||
|
completeSnapshot: true,
|
||||||
|
messages: source
|
||||||
|
})),
|
||||||
|
cases
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { AiSearchPipelineRequest } from '../../src/shared/ai-search'
|
||||||
|
import type { KnowledgeSearchIpcRequest } from '../../src/shared/knowledge'
|
||||||
|
|
||||||
const invoke = vi.fn()
|
const invoke = vi.fn()
|
||||||
const on = vi.fn()
|
const on = vi.fn()
|
||||||
@@ -37,6 +39,28 @@ describe('preload IPC contract', () => {
|
|||||||
limit: 50
|
limit: 50
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const knowledgeSearch: KnowledgeSearchIpcRequest = {
|
||||||
|
text: '测试 Knowledge Worker 检索',
|
||||||
|
terms: ['Knowledge Worker'],
|
||||||
|
conversationIds: ['fixture-user'],
|
||||||
|
startTime: 10,
|
||||||
|
limit: 20
|
||||||
|
}
|
||||||
|
await api.searchKnowledge(knowledgeSearch)
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('knowledge:search', knowledgeSearch)
|
||||||
|
const aiSearch: AiSearchPipelineRequest = {
|
||||||
|
requestId: 'fixture-search',
|
||||||
|
text: '最近谁聊过健身',
|
||||||
|
scope: 'global',
|
||||||
|
range: '7d'
|
||||||
|
}
|
||||||
|
await api.runAiSearch(aiSearch)
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('ai-search:run', aiSearch)
|
||||||
|
await api.startKnowledgeIndex()
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('knowledge:startIndex')
|
||||||
|
await api.clearCache('knowledge')
|
||||||
|
expect(invoke).toHaveBeenLastCalledWith('cache:clear', 'knowledge')
|
||||||
|
|
||||||
await api.getImage('fixture-md5', 'fixture.dat', 'fixture-session', {
|
await api.getImage('fixture-md5', 'fixture.dat', 'fixture-session', {
|
||||||
force: true,
|
force: true,
|
||||||
priority: 0
|
priority: 0
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildFinalEvidence,
|
||||||
|
evidenceIdentity,
|
||||||
|
sanitizeAnswerCitations
|
||||||
|
} from '../../src/main/services/ai-search-evidence'
|
||||||
|
import type { AiSearchPipelineEvidence } from '../../src/shared/ai-search'
|
||||||
|
|
||||||
|
const candidate = (
|
||||||
|
index: number,
|
||||||
|
options: Partial<AiSearchPipelineEvidence> = {}
|
||||||
|
): AiSearchPipelineEvidence => ({
|
||||||
|
chunkId: `chunk-${index}`,
|
||||||
|
conversationId: index % 2 ? 'fitness-group-a' : 'fitness-group-b',
|
||||||
|
conversationName: index % 2 ? '健身群 A' : '健身群 B',
|
||||||
|
conversationType: 'group',
|
||||||
|
messageId: `message-${index}`,
|
||||||
|
senderId: index % 3 ? 'member-yang' : 'member-dongfang',
|
||||||
|
sender: index % 3 ? '杨伟' : '东方小唠',
|
||||||
|
startTime: 1_785_895_200_000 + index,
|
||||||
|
endTime: 1_785_895_200_000 + index,
|
||||||
|
timestamp: 1_785_895_200_000 + index,
|
||||||
|
messageIds: [`message-${index}`],
|
||||||
|
text: `第 ${index} 条去健身相关消息`,
|
||||||
|
score: -index,
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Final Evidence builder', () => {
|
||||||
|
it('uses exactly the same program-owned E1-E8 collection for final context', () => {
|
||||||
|
const candidates = Array.from({ length: 16 }, (_, index) => candidate(index + 1))
|
||||||
|
const result = buildFinalEvidence(candidates, 8)
|
||||||
|
|
||||||
|
expect(result.candidateCount).toBe(16)
|
||||||
|
expect(result.evidence).toHaveLength(8)
|
||||||
|
expect(result.evidence.map((item) => item.id)).toEqual([
|
||||||
|
'E1',
|
||||||
|
'E2',
|
||||||
|
'E3',
|
||||||
|
'E4',
|
||||||
|
'E5',
|
||||||
|
'E6',
|
||||||
|
'E7',
|
||||||
|
'E8'
|
||||||
|
])
|
||||||
|
expect(result.evidence.map(evidenceIdentity)).toEqual(
|
||||||
|
Array.from({ length: 8 }, (_, index) => evidenceIdentity(candidate(16 - index)))
|
||||||
|
)
|
||||||
|
expect(result.aggregation.messageCount).toBe(8)
|
||||||
|
expect(result.aggregation.peopleCount).toBe(2)
|
||||||
|
expect(result.aggregation.conversationCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not merge same message ids from different conversations', () => {
|
||||||
|
const first = candidate(1, { conversationId: 'conversation-a', messageId: 'same-message-id' })
|
||||||
|
const second = candidate(2, { conversationId: 'conversation-b', messageId: 'same-message-id' })
|
||||||
|
|
||||||
|
const result = buildFinalEvidence([first, second], 8)
|
||||||
|
|
||||||
|
expect(result.evidence).toHaveLength(2)
|
||||||
|
expect(result.evidence.map(evidenceIdentity)).toEqual([
|
||||||
|
'conversation-b\u0000same-message-id',
|
||||||
|
'conversation-a\u0000same-message-id'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes citations which do not resolve to Final Evidence', () => {
|
||||||
|
const evidence = buildFinalEvidence([candidate(1), candidate(2)], 8).evidence
|
||||||
|
const result = sanitizeAnswerCitations('杨伟提到健身。[E1] 另有无效来源。[E10][E23]', evidence)
|
||||||
|
|
||||||
|
expect(result.status).toBe('sanitized')
|
||||||
|
expect(result.invalidCitationIds).toEqual(['E10', 'E23'])
|
||||||
|
expect(result.answer).toContain('[E1]')
|
||||||
|
expect(result.answer).not.toMatch(/\[E(?:10|23)\]/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,732 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const { chatState, listContactsAsync } = vi.hoisted(() => ({
|
||||||
|
chatState: { ready: true },
|
||||||
|
listContactsAsync: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../src/main/services/chat-service', () => ({
|
||||||
|
isReady: () => chatState.ready,
|
||||||
|
listContactsAsync
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { AiSearchPipelineService } from '../../src/main/services/ai-search-pipeline-service'
|
||||||
|
import type { KnowledgeEvidence } from '../../src/shared/knowledge'
|
||||||
|
|
||||||
|
const makeCandidate = (index: number): KnowledgeEvidence => ({
|
||||||
|
chunkId: `chunk-${index}`,
|
||||||
|
conversationId: index % 2 ? 'fitness-group-a' : 'fitness-group-b',
|
||||||
|
startTime: 1785900000000 + index,
|
||||||
|
endTime: 1785900000000 + index,
|
||||||
|
messageId: `message-${index}`,
|
||||||
|
sender: index % 2 ? '杨伟' : '东方小唠',
|
||||||
|
senderId: index % 2 ? 'member-yang' : 'member-dongfang',
|
||||||
|
timestamp: 1785900000000 + index,
|
||||||
|
messageIds: [`message-${index}`],
|
||||||
|
text: `candidate-${index} 去健身`,
|
||||||
|
score: -index
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AiSearchPipelineService', () => {
|
||||||
|
const knowledge = { search: vi.fn() }
|
||||||
|
const aiProvider = { getRuntimeConfig: vi.fn(), chat: vi.fn() }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
chatState.ready = true
|
||||||
|
listContactsAsync.mockReset()
|
||||||
|
knowledge.search.mockReset()
|
||||||
|
aiProvider.getRuntimeConfig.mockReset()
|
||||||
|
aiProvider.chat.mockReset()
|
||||||
|
listContactsAsync.mockResolvedValue([
|
||||||
|
{
|
||||||
|
md5: 'fitness-group',
|
||||||
|
m_nsUsrName: 'fitness-group@chatroom',
|
||||||
|
m_nsNickName: '健身交流组',
|
||||||
|
type: 'group'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
knowledge.search.mockResolvedValue({
|
||||||
|
source: 'knowledge',
|
||||||
|
state: 'ready',
|
||||||
|
indexedMessageCount: 2_000,
|
||||||
|
indexedChunkCount: 300,
|
||||||
|
totalMessages: 2_000,
|
||||||
|
evidence: [
|
||||||
|
{
|
||||||
|
chunkId: 'chunk-1',
|
||||||
|
conversationId: 'fitness-group',
|
||||||
|
startTime: 1785900000000,
|
||||||
|
endTime: 1785900000000,
|
||||||
|
messageId: 'message-1',
|
||||||
|
sender: '小明',
|
||||||
|
senderId: 'wxid_fixture',
|
||||||
|
timestamp: 1785900000000,
|
||||||
|
messageIds: ['message-1'],
|
||||||
|
text: '今天下班去健身。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
aiProvider.getRuntimeConfig.mockReturnValue({
|
||||||
|
configured: true,
|
||||||
|
providerName: 'DeepSeek',
|
||||||
|
modelName: 'DeepSeek Chat'
|
||||||
|
})
|
||||||
|
aiProvider.chat
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"健身"}}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"finalize","reason":"已找到足够的相关消息"}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '小明提到今天下班去健身。[E1]',
|
||||||
|
usage: { input: 120 }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits actual planning, knowledge, evidence and AI completion states', async () => {
|
||||||
|
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
|
||||||
|
const events: Array<{ stage: string; status: string; message: string }> = []
|
||||||
|
const result = await service.run(
|
||||||
|
{
|
||||||
|
requestId: 'fixture-request',
|
||||||
|
text: '最近谁聊过健身',
|
||||||
|
scope: 'global',
|
||||||
|
range: '7d'
|
||||||
|
},
|
||||||
|
(event) => events.push(event)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(knowledge.search).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ text: '最近谁聊过健身', terms: ['健身'] })
|
||||||
|
)
|
||||||
|
expect(events).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ stage: 'query_understanding', status: 'running' }),
|
||||||
|
expect.objectContaining({ stage: 'agent_start', status: 'completed' }),
|
||||||
|
expect.objectContaining({ stage: 'agent_tool', status: 'completed' }),
|
||||||
|
expect.objectContaining({ stage: 'search_plan_ready', status: 'completed' }),
|
||||||
|
expect.objectContaining({ stage: 'knowledge_searching', status: 'completed' }),
|
||||||
|
expect.objectContaining({ stage: 'evidence_ready', status: 'completed' }),
|
||||||
|
expect.objectContaining({ stage: 'aggregation', status: 'completed' }),
|
||||||
|
expect.objectContaining({
|
||||||
|
stage: 'ai_generating',
|
||||||
|
status: 'running',
|
||||||
|
modelName: 'DeepSeek Chat'
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ stage: 'completed', status: 'completed' })
|
||||||
|
])
|
||||||
|
)
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'completed',
|
||||||
|
candidateEvidenceCount: 1,
|
||||||
|
contextEvidenceCount: 1,
|
||||||
|
answer: '小明提到今天下班去健身。[E1]',
|
||||||
|
ai: { inputTokens: 120, inputTokensEstimated: false }
|
||||||
|
})
|
||||||
|
expect(result.agent).toMatchObject({ mode: 'agent', toolCalls: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps real evidence when the answer model fails', async () => {
|
||||||
|
aiProvider.chat.mockReset()
|
||||||
|
aiProvider.chat
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"健身"}}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ success: true, data: '{"action":"finalize","reason":"证据足够"}' })
|
||||||
|
.mockResolvedValueOnce({ success: false, error: '模型超时' })
|
||||||
|
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
|
||||||
|
const events: Array<{ stage: string; status: string; message: string }> = []
|
||||||
|
const result = await service.run(
|
||||||
|
{
|
||||||
|
requestId: 'fixture-ai-error',
|
||||||
|
text: '最近聊过健身吗',
|
||||||
|
scope: 'global',
|
||||||
|
range: '7d'
|
||||||
|
},
|
||||||
|
(event) => events.push(event)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 'ai_failed', evidence: [expect.any(Object)] })
|
||||||
|
expect(events).toContainEqual(
|
||||||
|
expect.objectContaining({ stage: 'ai_generating', status: 'error', error: '模型超时' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses Final Evidence only for AI context and strips invalid citations', async () => {
|
||||||
|
knowledge.search.mockResolvedValue({
|
||||||
|
source: 'knowledge',
|
||||||
|
state: 'ready',
|
||||||
|
indexedMessageCount: 2_000,
|
||||||
|
indexedChunkCount: 300,
|
||||||
|
totalMessages: 2_000,
|
||||||
|
evidence: Array.from({ length: 16 }, (_, index) => makeCandidate(index + 1)),
|
||||||
|
timings: {
|
||||||
|
workerIpcMs: 4,
|
||||||
|
ftsMs: 8,
|
||||||
|
messageLoadMs: 5,
|
||||||
|
chunkExpandMs: 6,
|
||||||
|
rankingMs: 2,
|
||||||
|
totalMs: 25
|
||||||
|
}
|
||||||
|
})
|
||||||
|
aiProvider.chat.mockReset()
|
||||||
|
aiProvider.chat
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"健身"}}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ success: true, data: '{"action":"finalize","reason":"证据足够"}' })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '杨伟聊过去健身。[E1] 错误引用。[E10][E23]',
|
||||||
|
usage: { input: 160 }
|
||||||
|
})
|
||||||
|
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
|
||||||
|
|
||||||
|
const result = await service.run(
|
||||||
|
{
|
||||||
|
requestId: 'final-evidence-only',
|
||||||
|
text: '全局搜一下 谁聊过 去健身',
|
||||||
|
scope: 'global',
|
||||||
|
range: '30d'
|
||||||
|
},
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
const answerPrompt = aiProvider.chat.mock.calls[2][0][1].content as string
|
||||||
|
const contextIds = Array.from(answerPrompt.matchAll(/\[E(\d+)\]\nconversationId:/g)).map(
|
||||||
|
(match) => Number(match[1])
|
||||||
|
)
|
||||||
|
expect(contextIds).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
|
||||||
|
expect(answerPrompt).not.toContain('candidate-1 去健身')
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'completed',
|
||||||
|
candidateEvidenceCount: 16,
|
||||||
|
contextEvidenceCount: 8,
|
||||||
|
citationValidation: { status: 'sanitized', invalidCitationIds: ['E10', 'E23'] }
|
||||||
|
})
|
||||||
|
expect(result.evidence.map((item) => item.id)).toEqual([
|
||||||
|
'E1',
|
||||||
|
'E2',
|
||||||
|
'E3',
|
||||||
|
'E4',
|
||||||
|
'E5',
|
||||||
|
'E6',
|
||||||
|
'E7',
|
||||||
|
'E8'
|
||||||
|
])
|
||||||
|
expect(result.answer).toContain('[E1]')
|
||||||
|
expect(result.answer).not.toMatch(/\[E(?:10|23)\]/)
|
||||||
|
expect(result.aggregation).toMatchObject({
|
||||||
|
messageCount: 8,
|
||||||
|
peopleCount: 2,
|
||||||
|
conversationCount: 2
|
||||||
|
})
|
||||||
|
expect(result.timings).toMatchObject({
|
||||||
|
queryUnderstandingMs: expect.any(Number),
|
||||||
|
contactResolutionMs: expect.any(Number),
|
||||||
|
knowledgeSearchMs: expect.any(Number),
|
||||||
|
ftsMs: 8,
|
||||||
|
totalMs: expect.any(Number)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries a different conversation query after the first search returns zero results', async () => {
|
||||||
|
listContactsAsync.mockResolvedValue([
|
||||||
|
{
|
||||||
|
md5: 'technology-group',
|
||||||
|
m_nsUsrName: 'technology-group@chatroom',
|
||||||
|
m_nsNickName: '技术交流',
|
||||||
|
type: 'group'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
knowledge.search.mockResolvedValue({
|
||||||
|
source: 'knowledge',
|
||||||
|
state: 'ready',
|
||||||
|
indexedMessageCount: 2_000,
|
||||||
|
indexedChunkCount: 300,
|
||||||
|
totalMessages: 2_000,
|
||||||
|
evidence: [
|
||||||
|
{
|
||||||
|
chunkId: 'technology-chunk',
|
||||||
|
conversationId: 'technology-group',
|
||||||
|
startTime: 1785900000000,
|
||||||
|
endTime: 1785900000000,
|
||||||
|
messageId: 'technology-message',
|
||||||
|
sender: '小周',
|
||||||
|
timestamp: 1785900000000,
|
||||||
|
messageIds: ['technology-message'],
|
||||||
|
text: '今天讨论了 Electron 的打包问题。'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
timings: {
|
||||||
|
workerIpcMs: 1,
|
||||||
|
ftsMs: 2,
|
||||||
|
messageLoadMs: 1,
|
||||||
|
chunkExpandMs: 1,
|
||||||
|
rankingMs: 1,
|
||||||
|
totalMs: 6
|
||||||
|
}
|
||||||
|
})
|
||||||
|
aiProvider.chat.mockReset()
|
||||||
|
aiProvider.chat
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"search_conversations","arguments":{"query":"技术交流群"}}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"search_conversations","arguments":{"query":"技术交流"}}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"get_conversation_messages","arguments":{"conversationRef":"conversation-1","limit":50}}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"finalize","reason":"已获得会话近期消息"}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ success: true, data: '技术交流讨论了 Electron 打包问题。[E1]' })
|
||||||
|
const events: Array<Record<string, unknown>> = []
|
||||||
|
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
|
||||||
|
|
||||||
|
const result = await service.run(
|
||||||
|
{ requestId: 'retry-query', text: '我在技术交流群聊了什么?', scope: 'global', range: '30d' },
|
||||||
|
(event) => events.push(event as unknown as Record<string, unknown>)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 'completed', agent: { mode: 'agent', toolCalls: 3 } })
|
||||||
|
expect(result.agent.trace).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ toolName: 'search_conversations', resultCount: 0 }),
|
||||||
|
expect.objectContaining({ toolName: 'search_conversations', resultCount: 1 }),
|
||||||
|
expect.objectContaining({ toolName: 'get_conversation_messages', resultCount: 1 })
|
||||||
|
])
|
||||||
|
)
|
||||||
|
expect(knowledge.search).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ terms: [], conversationIds: ['technology-group'], limit: 50 })
|
||||||
|
)
|
||||||
|
expect(events).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
stage: 'agent_tool',
|
||||||
|
agentTrace: expect.objectContaining({ resultCount: 0 })
|
||||||
|
})
|
||||||
|
])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses person lookup then metadata conversation retrieval for a contact summary', async () => {
|
||||||
|
listContactsAsync.mockResolvedValue([
|
||||||
|
{
|
||||||
|
md5: 'zhongtian-contact',
|
||||||
|
m_nsUsrName: 'wxid_zhongtian',
|
||||||
|
m_nsNickName: '中田健身-弘毅',
|
||||||
|
type: 'user'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
knowledge.search.mockResolvedValue({
|
||||||
|
source: 'knowledge',
|
||||||
|
state: 'ready',
|
||||||
|
indexedMessageCount: 2_000,
|
||||||
|
indexedChunkCount: 300,
|
||||||
|
totalMessages: 2_000,
|
||||||
|
evidence: Array.from({ length: 8 }, (_, index) => ({
|
||||||
|
...makeCandidate(index + 1),
|
||||||
|
conversationId: 'zhongtian-contact'
|
||||||
|
})),
|
||||||
|
timings: {
|
||||||
|
workerIpcMs: 1,
|
||||||
|
ftsMs: 0,
|
||||||
|
messageLoadMs: 2,
|
||||||
|
chunkExpandMs: 0,
|
||||||
|
rankingMs: 1,
|
||||||
|
totalMs: 4
|
||||||
|
},
|
||||||
|
conversationRetrieval: {
|
||||||
|
conversationId: 'zhongtian-contact',
|
||||||
|
totalMessages: 327,
|
||||||
|
chunkCount: 10,
|
||||||
|
candidateMessages: 30,
|
||||||
|
systemMessagesDeprioritized: 2,
|
||||||
|
complete: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
aiProvider.chat.mockReset()
|
||||||
|
aiProvider.chat
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"search_people","arguments":{"query":"中田健身-弘毅"}}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"get_conversation_messages","arguments":{"conversationRef":"conversation-1"}}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ success: true, data: '你们最近聊过健身安排。[E1]' })
|
||||||
|
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
|
||||||
|
|
||||||
|
const result = await service.run(
|
||||||
|
{
|
||||||
|
requestId: 'contact-summary',
|
||||||
|
text: '我和中田健身-弘毅最近聊了什么?',
|
||||||
|
scope: 'global',
|
||||||
|
range: '30d'
|
||||||
|
},
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 'completed', agent: { mode: 'agent', toolCalls: 2 } })
|
||||||
|
expect(knowledge.search).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
terms: [],
|
||||||
|
conversationIds: ['zhongtian-contact'],
|
||||||
|
startTime: expect.any(Number)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(knowledge.search).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ terms: expect.arrayContaining(['中田健身-弘毅']) })
|
||||||
|
)
|
||||||
|
expect(aiProvider.chat).toHaveBeenCalledTimes(3)
|
||||||
|
expect(result.agent.trace).toContainEqual(
|
||||||
|
expect.objectContaining({ label: '本地资料已覆盖所选时间范围,可直接整理回答' })
|
||||||
|
)
|
||||||
|
const decisions = result.agent.trace.filter((item) => item.event === 'agentDecision')
|
||||||
|
expect(decisions[0]?.decisionInput).toContain('上一次 Tool 结果:尚未执行 Tool。')
|
||||||
|
expect(decisions[1]?.decisionInput).toContain('中田健身-弘毅')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a direct contact recap on metadata retrieval when the Agent JSON response is invalid', async () => {
|
||||||
|
listContactsAsync.mockResolvedValue([
|
||||||
|
{
|
||||||
|
md5: 'zhongtian-contact',
|
||||||
|
m_nsUsrName: 'wxid_zhongtian',
|
||||||
|
m_nsNickName: '中田健身-弘毅',
|
||||||
|
type: 'user'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
knowledge.search.mockResolvedValue({
|
||||||
|
source: 'knowledge',
|
||||||
|
state: 'ready',
|
||||||
|
indexedMessageCount: 2_000,
|
||||||
|
indexedChunkCount: 300,
|
||||||
|
totalMessages: 2_000,
|
||||||
|
evidence: Array.from({ length: 8 }, (_, index) => ({
|
||||||
|
...makeCandidate(index + 1),
|
||||||
|
conversationId: 'zhongtian-contact',
|
||||||
|
text: `我肚子前面放盒肌酸,才是 ${118 + index}。`
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
aiProvider.chat.mockReset()
|
||||||
|
aiProvider.chat
|
||||||
|
.mockResolvedValueOnce({ success: true, data: '我建议先找到这位联系人。' })
|
||||||
|
.mockResolvedValueOnce({ success: true, data: '你们最近聊到了腰围和肌酸。[E1]' })
|
||||||
|
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
|
||||||
|
|
||||||
|
const result = await service.run(
|
||||||
|
{
|
||||||
|
requestId: 'contact-summary-agent-recovery',
|
||||||
|
text: '我和中田健身弘毅最近聊了什么?',
|
||||||
|
scope: 'global',
|
||||||
|
range: 'all'
|
||||||
|
},
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'completed',
|
||||||
|
agent: {
|
||||||
|
mode: 'fallback',
|
||||||
|
fallbackReason: expect.stringContaining('相同检索意图的本地确定性策略')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(knowledge.search).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
conversationIds: ['zhongtian-contact'],
|
||||||
|
terms: [],
|
||||||
|
startTime: expect.any(Number)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(knowledge.search).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ terms: expect.arrayContaining(['中田健身弘毅']) })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses person lookup plus conversation-scoped topic search for a contact question', async () => {
|
||||||
|
listContactsAsync.mockResolvedValue([
|
||||||
|
{
|
||||||
|
md5: 'zhongtian-contact',
|
||||||
|
m_nsUsrName: 'wxid_zhongtian',
|
||||||
|
m_nsNickName: '中田健身-弘毅',
|
||||||
|
type: 'user'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
aiProvider.chat.mockReset()
|
||||||
|
aiProvider.chat
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"search_people","arguments":{"query":"中田健身-弘毅"}}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"search_messages","arguments":{"conversationRef":"conversation-1","query":"健身"}}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"finalize","reason":"已找到话题证据"}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ success: true, data: '你们最近聊过健身。[E1]' })
|
||||||
|
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
|
||||||
|
|
||||||
|
const result = await service.run(
|
||||||
|
{
|
||||||
|
requestId: 'contact-topic',
|
||||||
|
text: '我和中田健身-弘毅最近聊过健身吗?',
|
||||||
|
scope: 'global',
|
||||||
|
range: 'all'
|
||||||
|
},
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 'completed', agent: { mode: 'agent', toolCalls: 2 } })
|
||||||
|
expect(knowledge.search).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
terms: ['健身'],
|
||||||
|
conversationIds: ['zhongtian-contact'],
|
||||||
|
startTime: expect.any(Number)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(knowledge.search).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ terms: expect.arrayContaining(['中田健身-弘毅']) })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a forbidden contact-recall FTS action and keeps the deterministic fallback semantic', async () => {
|
||||||
|
listContactsAsync.mockResolvedValue([
|
||||||
|
{
|
||||||
|
md5: 'zhongtian-contact',
|
||||||
|
m_nsUsrName: 'wxid_zhongtian',
|
||||||
|
m_nsNickName: '中田健身-弘毅',
|
||||||
|
type: 'user'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
aiProvider.chat.mockReset()
|
||||||
|
aiProvider.chat
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"中田健身弘毅"}}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ success: true, data: '这不是有效 Agent JSON' })
|
||||||
|
.mockResolvedValueOnce({ success: true, data: '已从会话中整理出最近内容。[E1]' })
|
||||||
|
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
|
||||||
|
|
||||||
|
const result = await service.run(
|
||||||
|
{
|
||||||
|
requestId: 'forbidden-contact-recall-fts',
|
||||||
|
text: '我和中田健身弘毅最近聊了什么?',
|
||||||
|
scope: 'global',
|
||||||
|
range: '30d'
|
||||||
|
},
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.agent).toMatchObject({ mode: 'fallback' })
|
||||||
|
expect(result.agent.trace).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
toolName: 'search_messages',
|
||||||
|
decision: expect.stringContaining('联系人回顾只允许')
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(knowledge.search).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ conversationIds: ['zhongtian-contact'], terms: [] })
|
||||||
|
)
|
||||||
|
expect(knowledge.search).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ terms: expect.arrayContaining(['中田健身弘毅']) })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an unscoped FTS action for a contact topic question', async () => {
|
||||||
|
listContactsAsync.mockResolvedValue([
|
||||||
|
{
|
||||||
|
md5: 'zhongtian-contact',
|
||||||
|
m_nsUsrName: 'wxid_zhongtian',
|
||||||
|
m_nsNickName: '中田健身-弘毅',
|
||||||
|
type: 'user'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
aiProvider.chat.mockReset()
|
||||||
|
aiProvider.chat
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"search_messages","arguments":{"query":"健身"}}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ success: true, data: '无效控制输出' })
|
||||||
|
.mockResolvedValueOnce({ success: true, data: '你们聊过健身。[E1]' })
|
||||||
|
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
|
||||||
|
|
||||||
|
await service.run(
|
||||||
|
{
|
||||||
|
requestId: 'forbidden-unscoped-contact-topic',
|
||||||
|
text: '我和中田健身弘毅最近聊过健身吗?',
|
||||||
|
scope: 'global',
|
||||||
|
range: '30d'
|
||||||
|
},
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(knowledge.search).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
terms: ['健身'],
|
||||||
|
conversationIds: ['zhongtian-contact']
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(knowledge.search).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ terms: ['健身'], conversationIds: undefined })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags suspicious contact retrieval and refuses to summarize one message as a full conversation', async () => {
|
||||||
|
listContactsAsync.mockResolvedValue([
|
||||||
|
{
|
||||||
|
md5: 'zhongtian-contact',
|
||||||
|
m_nsUsrName: 'wxid_zhongtian',
|
||||||
|
m_nsNickName: '中田健身-弘毅',
|
||||||
|
type: 'user'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
knowledge.search.mockResolvedValue({
|
||||||
|
source: 'knowledge',
|
||||||
|
state: 'ready',
|
||||||
|
indexedMessageCount: 2_000,
|
||||||
|
indexedChunkCount: 300,
|
||||||
|
totalMessages: 2_000,
|
||||||
|
evidence: [{ ...makeCandidate(1), conversationId: 'zhongtian-contact' }],
|
||||||
|
conversationRetrieval: {
|
||||||
|
conversationId: 'zhongtian-contact',
|
||||||
|
totalMessages: 134,
|
||||||
|
chunkCount: 8,
|
||||||
|
candidateMessages: 1,
|
||||||
|
systemMessagesDeprioritized: 1,
|
||||||
|
complete: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
aiProvider.chat.mockReset()
|
||||||
|
aiProvider.chat
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"search_people","arguments":{"query":"中田健身弘毅"}}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"get_conversation_messages","arguments":{"conversationRef":"conversation-1"}}'
|
||||||
|
})
|
||||||
|
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
|
||||||
|
|
||||||
|
const result = await service.run(
|
||||||
|
{
|
||||||
|
requestId: 'suspicious-contact-retrieval',
|
||||||
|
text: '我和中田健身弘毅最近聊了什么?',
|
||||||
|
scope: 'global',
|
||||||
|
range: '30d'
|
||||||
|
},
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'retrieval_incomplete',
|
||||||
|
retrieval: {
|
||||||
|
conversationId: 'zhongtian-contact',
|
||||||
|
sourceMessageCount: 134,
|
||||||
|
candidateCount: 1,
|
||||||
|
suspicious: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(knowledge.search).toHaveBeenCalledTimes(2)
|
||||||
|
expect(aiProvider.chat).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not turn a zero-result person lookup or early Agent finalize into contact-name FTS', async () => {
|
||||||
|
listContactsAsync.mockResolvedValue([
|
||||||
|
{
|
||||||
|
md5: 'zhongtian-contact',
|
||||||
|
m_nsUsrName: 'wxid_zhongtian',
|
||||||
|
m_nsNickName: '中田健身-弘毅',
|
||||||
|
type: 'user'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
aiProvider.chat.mockReset()
|
||||||
|
aiProvider.chat
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"tool","tool":"search_people","arguments":{"query":"不存在的人"}}'
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: '{"action":"finalize","reason":"没有足够证据"}'
|
||||||
|
})
|
||||||
|
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
|
||||||
|
|
||||||
|
const result = await service.run(
|
||||||
|
{
|
||||||
|
requestId: 'zero-person-lookup-safe',
|
||||||
|
text: '我和中田健身弘毅最近聊了什么?',
|
||||||
|
scope: 'global',
|
||||||
|
range: '30d'
|
||||||
|
},
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 'no_evidence', agent: { mode: 'agent', toolCalls: 1 } })
|
||||||
|
expect(knowledge.search).not.toHaveBeenCalled()
|
||||||
|
expect(aiProvider.chat).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops after five Tool calls instead of searching indefinitely', async () => {
|
||||||
|
aiProvider.chat.mockReset()
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
aiProvider.chat.mockResolvedValueOnce({
|
||||||
|
success: true,
|
||||||
|
data: `{"action":"tool","tool":"search_conversations","arguments":{"query":"不存在的群${index}"}}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
|
||||||
|
|
||||||
|
const result = await service.run(
|
||||||
|
{
|
||||||
|
requestId: 'max-tool-calls',
|
||||||
|
text: '我在一个不存在的群聊了什么?',
|
||||||
|
scope: 'global',
|
||||||
|
range: '30d'
|
||||||
|
},
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 'no_evidence', agent: { mode: 'agent', toolCalls: 5 } })
|
||||||
|
expect(result.agent.trace).toContainEqual(
|
||||||
|
expect.objectContaining({ label: '已达到本次检索上限' })
|
||||||
|
)
|
||||||
|
expect(aiProvider.chat).toHaveBeenCalledTimes(5)
|
||||||
|
expect(knowledge.search).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the existing one-shot search when Agent output violates the control protocol', async () => {
|
||||||
|
aiProvider.chat.mockReset()
|
||||||
|
aiProvider.chat
|
||||||
|
.mockResolvedValueOnce({ success: true, data: '我来执行任意代码' })
|
||||||
|
.mockResolvedValueOnce({ success: true, data: '{"intent":"topic","keywords":["健身"]}' })
|
||||||
|
.mockResolvedValueOnce({ success: true, data: '小明聊到健身。[E1]' })
|
||||||
|
const service = new AiSearchPipelineService(knowledge as never, aiProvider as never)
|
||||||
|
|
||||||
|
const result = await service.run(
|
||||||
|
{ requestId: 'agent-fallback', text: '最近聊过健身吗?', scope: 'global', range: '7d' },
|
||||||
|
() => undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ status: 'completed', agent: { mode: 'fallback', toolCalls: 0 } })
|
||||||
|
expect(result.agent.fallbackReason).toContain('受控搜索 Agent')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildLocalAiSearchPlan,
|
||||||
|
includesExplicitAiSearchAlias,
|
||||||
|
inferAiSearchTimeRange
|
||||||
|
} from '../../src/shared/ai-search'
|
||||||
|
|
||||||
|
const NOW = new Date('2026-08-05T12:00:00+08:00')
|
||||||
|
|
||||||
|
describe('AI search natural-language time ranges', () => {
|
||||||
|
it('tightens an all-history selection when the user says 最近', () => {
|
||||||
|
expect(inferAiSearchTimeRange('我和张三最近聊了什么?', 'all', NOW)).toMatchObject({
|
||||||
|
label: '近 30 天',
|
||||||
|
source: 'query',
|
||||||
|
startTime: Math.floor(NOW.getTime() / 1000) - 30 * 86400
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recognizes explicit recent days and the current year', () => {
|
||||||
|
expect(inferAiSearchTimeRange('我和张三最近三天聊了什么?', 'all', NOW)).toMatchObject({
|
||||||
|
label: '近 3 天',
|
||||||
|
source: 'query'
|
||||||
|
})
|
||||||
|
expect(inferAiSearchTimeRange('我和张三今年聊了什么?', 'all', NOW)).toMatchObject({
|
||||||
|
label: '今年',
|
||||||
|
startTime: Math.floor(new Date(2026, 0, 1).getTime() / 1000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an explicit user retry override above the word 最近 in the original question', () => {
|
||||||
|
expect(
|
||||||
|
inferAiSearchTimeRange('我和张三最近聊了什么?', 'all', NOW, {
|
||||||
|
label: '全部历史',
|
||||||
|
reason: '用户主动扩大到全部历史',
|
||||||
|
source: 'user_retry'
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
label: '全部历史',
|
||||||
|
source: 'user_retry'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies a direct person recap as conversation_recall rather than a topic FTS query', () => {
|
||||||
|
expect(buildLocalAiSearchPlan('我和张三最近聊了什么?')).toMatchObject({
|
||||||
|
intent: 'conversation_recall',
|
||||||
|
contactQuery: '张三'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps identity and message topic separate for a contact topic search', () => {
|
||||||
|
expect(buildLocalAiSearchPlan('我和张三最近聊过健身吗?')).toMatchObject({
|
||||||
|
intent: 'conversation_topic_search',
|
||||||
|
contactQuery: '张三',
|
||||||
|
topicQuery: '健身',
|
||||||
|
keywords: ['健身']
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies global topics and bare conversation names without turning names into FTS terms', () => {
|
||||||
|
expect(buildLocalAiSearchPlan('最近谁聊过 MCP?')).toMatchObject({
|
||||||
|
intent: 'global_topic_search',
|
||||||
|
topicQuery: 'MCP',
|
||||||
|
keywords: ['MCP']
|
||||||
|
})
|
||||||
|
expect(buildLocalAiSearchPlan('技术交流群')).toMatchObject({
|
||||||
|
intent: 'conversation_name_search',
|
||||||
|
contactQuery: '技术交流群',
|
||||||
|
topicQuery: undefined
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches an explicitly mentioned nickname when the user omits punctuation', () => {
|
||||||
|
expect(includesExplicitAiSearchAlias('我和中田健身弘毅最近聊了什么?', '中田健身-弘毅')).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { normalizeContactName } from '../../src/shared/contact-resolution'
|
||||||
|
import { resolveContact } from '../../src/main/services/contact-resolution-service'
|
||||||
|
|
||||||
|
const contacts = [
|
||||||
|
{
|
||||||
|
md5: 'coach',
|
||||||
|
m_nsUsrName: 'wxid_coach',
|
||||||
|
m_nsNickName: '中田健身-弘毅',
|
||||||
|
type: 'user' as const,
|
||||||
|
remark: '弘毅教练'
|
||||||
|
},
|
||||||
|
{ md5: 'zhangsan', m_nsUsrName: 'wxid_zhangsan', m_nsNickName: '张三', type: 'user' as const },
|
||||||
|
{
|
||||||
|
md5: 'zhangsanfeng',
|
||||||
|
m_nsUsrName: 'wxid_zhangsanfeng',
|
||||||
|
m_nsNickName: '张三丰',
|
||||||
|
type: 'user' as const
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('ContactResolutionService', () => {
|
||||||
|
it('canonicalizes whitespace, Unicode separators, punctuation and full-width variants', () => {
|
||||||
|
const forms = [
|
||||||
|
'中田健身-弘毅',
|
||||||
|
'中田健身弘毅',
|
||||||
|
'中田健身 弘毅',
|
||||||
|
'中田健身—弘毅',
|
||||||
|
'中田健身_弘毅'
|
||||||
|
]
|
||||||
|
expect(new Set(forms.map(normalizeContactName))).toEqual(new Set(['中田健身弘毅']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves every canonical name form to one conversation without substring guessing', () => {
|
||||||
|
for (const value of [
|
||||||
|
'中田健身-弘毅',
|
||||||
|
'中田健身弘毅',
|
||||||
|
'中田健身 弘毅',
|
||||||
|
'中田健身—弘毅',
|
||||||
|
'中田健身_弘毅'
|
||||||
|
]) {
|
||||||
|
expect(resolveContact(value, contacts, 'person')).toMatchObject({
|
||||||
|
matched: true,
|
||||||
|
conversationId: 'coach',
|
||||||
|
ambiguous: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not treat a partial name as an identity match', () => {
|
||||||
|
expect(resolveContact('张三丰老师', contacts, 'person')).toMatchObject({
|
||||||
|
matched: false,
|
||||||
|
ambiguous: false,
|
||||||
|
candidates: []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not auto-select duplicate canonical aliases', () => {
|
||||||
|
const duplicate = [
|
||||||
|
...contacts,
|
||||||
|
{ ...contacts[0], md5: 'coach-duplicate', m_nsUsrName: 'wxid_other' }
|
||||||
|
]
|
||||||
|
expect(resolveContact('中田健身弘毅', duplicate, 'person')).toMatchObject({
|
||||||
|
matched: false,
|
||||||
|
ambiguous: true,
|
||||||
|
candidates: [expect.any(Object), expect.any(Object)]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const { chatState, getGroupSnapshotAsync, listContactsAsync, listMessagesAsync, knowledgeService } =
|
||||||
|
vi.hoisted(() => ({
|
||||||
|
chatState: {
|
||||||
|
ready: false,
|
||||||
|
accountId: ''
|
||||||
|
},
|
||||||
|
getGroupSnapshotAsync: vi.fn(),
|
||||||
|
listContactsAsync: vi.fn(),
|
||||||
|
listMessagesAsync: vi.fn(),
|
||||||
|
knowledgeService: {
|
||||||
|
dispose: vi.fn().mockResolvedValue(undefined),
|
||||||
|
index: vi.fn().mockResolvedValue(undefined),
|
||||||
|
search: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../src/main/services/chat-service', () => ({
|
||||||
|
isReady: () => chatState.ready,
|
||||||
|
getSelfAccountInfo: () => (chatState.accountId ? { wxid: chatState.accountId } : null),
|
||||||
|
getCurrentAccountRoot: () => chatState.accountId,
|
||||||
|
getGroupSnapshotAsync,
|
||||||
|
listContactsAsync,
|
||||||
|
listMessagesAsync
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../src/main/knowledge/knowledge-service', () => ({
|
||||||
|
KnowledgeService: class {
|
||||||
|
dispose = knowledgeService.dispose
|
||||||
|
index = knowledgeService.index
|
||||||
|
search = knowledgeService.search
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { KnowledgeSearchService } from '../../src/main/knowledge/knowledge-search-service'
|
||||||
|
|
||||||
|
describe('KnowledgeSearchService legacy fallback', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
chatState.ready = false
|
||||||
|
chatState.accountId = ''
|
||||||
|
getGroupSnapshotAsync.mockReset()
|
||||||
|
listContactsAsync.mockReset()
|
||||||
|
listMessagesAsync.mockReset()
|
||||||
|
knowledgeService.dispose.mockClear()
|
||||||
|
knowledgeService.index.mockClear()
|
||||||
|
knowledgeService.search.mockReset()
|
||||||
|
listContactsAsync.mockResolvedValue([
|
||||||
|
{
|
||||||
|
m_nsUsrName: 'fixture-contact',
|
||||||
|
m_nsNickName: '脱敏会话',
|
||||||
|
md5: 'fixture-conversation',
|
||||||
|
type: 'user'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
listMessagesAsync.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'fixture-message',
|
||||||
|
localId: 42,
|
||||||
|
from: 'user',
|
||||||
|
type: '普通文本',
|
||||||
|
datetime: '2026/8/5 10:00:00',
|
||||||
|
content: '请把 Knowledge Worker 的 fallback 保留下来。',
|
||||||
|
isSender: false,
|
||||||
|
senderId: 'fixture-sender',
|
||||||
|
name: '脱敏成员',
|
||||||
|
createTime: 1785895200
|
||||||
|
}
|
||||||
|
])
|
||||||
|
getGroupSnapshotAsync.mockResolvedValue(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the old main-process search path when Knowledge is unavailable', async () => {
|
||||||
|
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||||||
|
const result = await service.search({
|
||||||
|
text: 'Knowledge Worker fallback',
|
||||||
|
terms: ['Knowledge Worker', 'fallback'],
|
||||||
|
conversationIds: ['fixture-conversation'],
|
||||||
|
startTime: 1785800000,
|
||||||
|
limit: 10
|
||||||
|
})
|
||||||
|
expect(listMessagesAsync).toHaveBeenCalledWith('fixture-conversation', 1785800000, undefined)
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
source: 'fallback',
|
||||||
|
fallbackReason: 'unavailable',
|
||||||
|
state: 'unavailable',
|
||||||
|
totalMessages: 1
|
||||||
|
})
|
||||||
|
expect(result.evidence).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
messageId: 'local:42',
|
||||||
|
conversationId: 'fixture-conversation',
|
||||||
|
sender: '脱敏成员',
|
||||||
|
senderId: 'fixture-sender',
|
||||||
|
timestamp: 1785895200000
|
||||||
|
})
|
||||||
|
])
|
||||||
|
await service.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses existing Knowledge evidence while a new incremental pass is running', async () => {
|
||||||
|
chatState.ready = true
|
||||||
|
chatState.accountId = 'fixture-account'
|
||||||
|
knowledgeService.search.mockResolvedValue({
|
||||||
|
state: 'indexing',
|
||||||
|
indexedMessageCount: 300,
|
||||||
|
indexedChunkCount: 60,
|
||||||
|
evidence: [
|
||||||
|
{
|
||||||
|
chunkId: 'chunk-1',
|
||||||
|
conversationId: 'fixture-conversation',
|
||||||
|
messageId: 'fixture-message',
|
||||||
|
senderId: 'fixture-sender',
|
||||||
|
sender: '脱敏成员',
|
||||||
|
timestamp: 1785895200000,
|
||||||
|
startTime: 1785895200000,
|
||||||
|
endTime: 1785895200000,
|
||||||
|
messageIds: ['fixture-message'],
|
||||||
|
text: 'Knowledge 已完成的部分可以立即检索。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||||||
|
const result = await service.search({
|
||||||
|
text: 'fallback',
|
||||||
|
terms: ['fallback'],
|
||||||
|
limit: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
source: 'knowledge',
|
||||||
|
state: 'indexing',
|
||||||
|
totalMessages: 300
|
||||||
|
})
|
||||||
|
expect(result.evidence).toHaveLength(1)
|
||||||
|
expect(listMessagesAsync).not.toHaveBeenCalled()
|
||||||
|
await service.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits a large scope filter before sending it to the Knowledge Worker', async () => {
|
||||||
|
chatState.ready = true
|
||||||
|
chatState.accountId = 'fixture-account'
|
||||||
|
knowledgeService.search.mockResolvedValue({
|
||||||
|
state: 'ready',
|
||||||
|
indexedMessageCount: 1_500,
|
||||||
|
indexedChunkCount: 300,
|
||||||
|
evidence: []
|
||||||
|
})
|
||||||
|
const conversationIds = Array.from({ length: 1_401 }, (_, index) => `conversation-${index}`)
|
||||||
|
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||||||
|
|
||||||
|
const result = await service.search({
|
||||||
|
text: '知识库',
|
||||||
|
terms: ['知识库'],
|
||||||
|
conversationIds,
|
||||||
|
limit: 10
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ source: 'knowledge', totalMessages: 1_500 })
|
||||||
|
expect(knowledgeService.search).toHaveBeenCalledTimes(3)
|
||||||
|
for (const [request] of knowledgeService.search.mock.calls) {
|
||||||
|
expect(request.conversationIds.length).toBeLessThanOrEqual(700)
|
||||||
|
}
|
||||||
|
await service.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves a group member wxid to its group nickname in fallback evidence', async () => {
|
||||||
|
listContactsAsync.mockResolvedValue([
|
||||||
|
{ md5: 'fixture-group', m_nsNickName: '脱敏群聊', type: 'group' }
|
||||||
|
])
|
||||||
|
listMessagesAsync.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'group-message',
|
||||||
|
from: 'wxid_member',
|
||||||
|
type: '普通文本',
|
||||||
|
content: '今天继续健身。',
|
||||||
|
isSender: false,
|
||||||
|
senderId: 'wxid_member',
|
||||||
|
name: 'wxid_member',
|
||||||
|
createTime: 1785895200
|
||||||
|
}
|
||||||
|
])
|
||||||
|
getGroupSnapshotAsync.mockResolvedValue({
|
||||||
|
roomId: 'fixture-group@chatroom',
|
||||||
|
memberCount: 1,
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
wxid: 'wxid_member',
|
||||||
|
nickname: '微信昵称',
|
||||||
|
groupNickname: '健身同学',
|
||||||
|
wechatNickname: '微信昵称',
|
||||||
|
remark: '',
|
||||||
|
avatar: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||||||
|
const result = await service.search({ text: '健身', terms: ['健身'], limit: 10 })
|
||||||
|
|
||||||
|
expect(result.evidence).toEqual([
|
||||||
|
expect.objectContaining({ senderId: 'wxid_member', sender: '健身同学' })
|
||||||
|
])
|
||||||
|
expect(getGroupSnapshotAsync).toHaveBeenCalledWith('fixture-group')
|
||||||
|
await service.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { mkdtempSync, existsSync } from 'fs'
|
||||||
|
import { rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { DEFAULT_KNOWLEDGE_CHUNKER, type KnowledgeFtsConfig } from '../../src/shared/knowledge'
|
||||||
|
import { chunkConversation } from '../../src/main/knowledge/chunker'
|
||||||
|
import {
|
||||||
|
estimateKnowledgeCapacityPreflight,
|
||||||
|
getKnowledgeDatabasePath,
|
||||||
|
KnowledgeStore,
|
||||||
|
removeKnowledgeDatabase
|
||||||
|
} from '../../src/main/knowledge/knowledge-store'
|
||||||
|
import { normalizeKnowledgeMessage } from '../../src/main/knowledge/normalizer'
|
||||||
|
import {
|
||||||
|
createSyntheticConversation,
|
||||||
|
FIXTURE_ACCOUNT_A,
|
||||||
|
FIXTURE_ACCOUNT_B
|
||||||
|
} from '../fixtures/knowledge-rag'
|
||||||
|
|
||||||
|
const roots: string[] = []
|
||||||
|
const fts: KnowledgeFtsConfig = {
|
||||||
|
profileId: 'test-trigram-external-full',
|
||||||
|
tokenizer: 'trigram',
|
||||||
|
contentMode: 'external',
|
||||||
|
detail: 'full',
|
||||||
|
columnsize: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRoot(): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'wxe-knowledge-'))
|
||||||
|
roots.push(root)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('knowledge normalizer and chunker', () => {
|
||||||
|
it('indexes text, attachment metadata and existing voice transcripts without paths or binary data', () => {
|
||||||
|
const normalized = normalizeKnowledgeMessage({
|
||||||
|
accountId: FIXTURE_ACCOUNT_A,
|
||||||
|
conversationId: 'conversation-a',
|
||||||
|
messageId: 'message-a',
|
||||||
|
createTime: 1,
|
||||||
|
kind: 'voice',
|
||||||
|
text: ' 原始说明 ',
|
||||||
|
attachment: { name: 'plan.txt', kind: 'file' },
|
||||||
|
voiceTranscript: ' 已完成语音转写 '
|
||||||
|
})
|
||||||
|
expect(normalized.searchableText).toContain('原始说明')
|
||||||
|
expect(normalized.searchableText).toContain('附件:plan.txt')
|
||||||
|
expect(normalized.searchableText).toContain('语音转写:已完成语音转写')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cuts on time gaps and preserves message evidence ids', () => {
|
||||||
|
const source = createSyntheticConversation(
|
||||||
|
FIXTURE_ACCOUNT_A,
|
||||||
|
'conversation-a',
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
'short'
|
||||||
|
).messages
|
||||||
|
source[3].createTime += 20 * 60 * 1000
|
||||||
|
const chunks = chunkConversation(source.map(normalizeKnowledgeMessage), {
|
||||||
|
...DEFAULT_KNOWLEDGE_CHUNKER,
|
||||||
|
maxMessages: 12
|
||||||
|
})
|
||||||
|
expect(chunks).toHaveLength(2)
|
||||||
|
expect(chunks.flatMap((chunk) => chunk.messageIds)).toEqual(
|
||||||
|
source.map((item) => item.messageId)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('knowledge sqlite', () => {
|
||||||
|
it('is idempotent, supports FTS evidence lookup, and does not mix accounts', async () => {
|
||||||
|
const root = makeRoot()
|
||||||
|
const source = createSyntheticConversation(FIXTURE_ACCOUNT_A, 'conversation-a', 0, 25, 'mixed')
|
||||||
|
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
|
||||||
|
const first = await store.index({ conversations: [source], chunker: DEFAULT_KNOWLEDGE_CHUNKER })
|
||||||
|
const second = await store.index({
|
||||||
|
conversations: [source],
|
||||||
|
chunker: DEFAULT_KNOWLEDGE_CHUNKER
|
||||||
|
})
|
||||||
|
expect(first.updatedChunks).toBeGreaterThan(0)
|
||||||
|
expect(second.updatedChunks).toBe(0)
|
||||||
|
expect(second.unchangedConversations).toBe(1)
|
||||||
|
const evidence = store.search({ accountId: FIXTURE_ACCOUNT_A, text: '本地知识库', limit: 10 })
|
||||||
|
expect(evidence).not.toHaveLength(0)
|
||||||
|
expect(evidence[0]).toMatchObject({
|
||||||
|
messageId: expect.stringMatching(/^synthetic-mixed-/),
|
||||||
|
conversationId: 'conversation-a',
|
||||||
|
sender: expect.any(String),
|
||||||
|
timestamp: expect.any(Number)
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
evidence.every((item) => item.messageIds.every((id) => id.startsWith('synthetic-mixed-')))
|
||||||
|
).toBe(true)
|
||||||
|
expect(() =>
|
||||||
|
store.search({ accountId: FIXTURE_ACCOUNT_B, text: '本地知识库', limit: 10 })
|
||||||
|
).toThrow(/account/)
|
||||||
|
store.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recovers safely after cancellation and only removes the derived database', async () => {
|
||||||
|
const root = makeRoot()
|
||||||
|
const source = createSyntheticConversation(
|
||||||
|
FIXTURE_ACCOUNT_A,
|
||||||
|
'conversation-a',
|
||||||
|
0,
|
||||||
|
2_000,
|
||||||
|
'mixed'
|
||||||
|
)
|
||||||
|
const controller = new AbortController()
|
||||||
|
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
|
||||||
|
const cancelled = await store.index(
|
||||||
|
{ conversations: [source], chunker: DEFAULT_KNOWLEDGE_CHUNKER },
|
||||||
|
controller.signal,
|
||||||
|
(progress) => {
|
||||||
|
if (progress.processedMessages >= 501) controller.abort()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(cancelled.cancelled).toBe(true)
|
||||||
|
const resumed = await store.index({
|
||||||
|
conversations: [source],
|
||||||
|
chunker: DEFAULT_KNOWLEDGE_CHUNKER
|
||||||
|
})
|
||||||
|
expect(resumed.cancelled).toBe(false)
|
||||||
|
const databasePath = getKnowledgeDatabasePath(root, FIXTURE_ACCOUNT_A)
|
||||||
|
store.close()
|
||||||
|
expect(existsSync(databasePath)).toBe(true)
|
||||||
|
removeKnowledgeDatabase(root, FIXTURE_ACCOUNT_A)
|
||||||
|
expect(existsSync(databasePath)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a bounded exact fallback for two-character Chinese queries with the trigram profile', async () => {
|
||||||
|
const root = makeRoot()
|
||||||
|
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
|
||||||
|
await store.index({
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
conversationId: 'short-query',
|
||||||
|
completeSnapshot: true,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
accountId: FIXTURE_ACCOUNT_A,
|
||||||
|
conversationId: 'short-query',
|
||||||
|
messageId: 'short-query-message',
|
||||||
|
createTime: Date.UTC(2026, 7, 5),
|
||||||
|
senderId: 'fixture-member',
|
||||||
|
senderName: '脱敏成员',
|
||||||
|
kind: 'text',
|
||||||
|
text: '收到,明早十点。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
chunker: DEFAULT_KNOWLEDGE_CHUNKER
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
store.search({ accountId: FIXTURE_ACCOUNT_A, text: '十点', terms: ['十点'], limit: 10 })
|
||||||
|
).toEqual([
|
||||||
|
expect.objectContaining({ messageId: 'short-query-message', conversationId: 'short-query' })
|
||||||
|
])
|
||||||
|
store.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps equal message ids from different conversations as separate Evidence', async () => {
|
||||||
|
const root = makeRoot()
|
||||||
|
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
|
||||||
|
await store.index({
|
||||||
|
conversations: ['conversation-a', 'conversation-b'].map((conversationId) => ({
|
||||||
|
conversationId,
|
||||||
|
completeSnapshot: true,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
accountId: FIXTURE_ACCOUNT_A,
|
||||||
|
conversationId,
|
||||||
|
messageId: 'shared-message-id',
|
||||||
|
createTime: Date.UTC(2026, 7, 5),
|
||||||
|
senderId: `${conversationId}-sender`,
|
||||||
|
senderName: conversationId,
|
||||||
|
kind: 'text',
|
||||||
|
text: '今天去健身。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
chunker: DEFAULT_KNOWLEDGE_CHUNKER
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = store.searchWithStatus({
|
||||||
|
accountId: FIXTURE_ACCOUNT_A,
|
||||||
|
text: '去健身',
|
||||||
|
terms: ['去健身'],
|
||||||
|
limit: 10
|
||||||
|
})
|
||||||
|
const evidence = result.evidence
|
||||||
|
|
||||||
|
expect(evidence).toHaveLength(2)
|
||||||
|
expect(evidence.map((item) => `${item.conversationId}:${item.messageId}`).sort()).toEqual([
|
||||||
|
'conversation-a:shared-message-id',
|
||||||
|
'conversation-b:shared-message-id'
|
||||||
|
])
|
||||||
|
expect(result.timings).toMatchObject({
|
||||||
|
workerIpcMs: 0,
|
||||||
|
ftsMs: expect.any(Number),
|
||||||
|
messageLoadMs: expect.any(Number),
|
||||||
|
chunkExpandMs: expect.any(Number),
|
||||||
|
rankingMs: expect.any(Number),
|
||||||
|
totalMs: expect.any(Number)
|
||||||
|
})
|
||||||
|
expect(result.timings.totalMs).toBeGreaterThanOrEqual(result.timings.ftsMs)
|
||||||
|
store.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps conversation, sender and time filters when a participant question has no topic terms', async () => {
|
||||||
|
const root = makeRoot()
|
||||||
|
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
|
||||||
|
await store.index({
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
conversationId: 'participant-query',
|
||||||
|
completeSnapshot: true,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
accountId: FIXTURE_ACCOUNT_A,
|
||||||
|
conversationId: 'participant-query',
|
||||||
|
messageId: 'participant-a',
|
||||||
|
createTime: Date.UTC(2026, 7, 5, 9),
|
||||||
|
senderId: 'member-a',
|
||||||
|
senderName: '成员甲',
|
||||||
|
kind: 'text',
|
||||||
|
text: '第一条讨论。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: FIXTURE_ACCOUNT_A,
|
||||||
|
conversationId: 'participant-query',
|
||||||
|
messageId: 'participant-b',
|
||||||
|
createTime: Date.UTC(2026, 7, 5, 10),
|
||||||
|
senderId: 'member-b',
|
||||||
|
senderName: '成员乙',
|
||||||
|
kind: 'text',
|
||||||
|
text: '第二条讨论。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
chunker: DEFAULT_KNOWLEDGE_CHUNKER
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
store.search({
|
||||||
|
accountId: FIXTURE_ACCOUNT_A,
|
||||||
|
text: '成员甲最近聊了什么',
|
||||||
|
terms: [],
|
||||||
|
conversationIds: ['participant-query'],
|
||||||
|
senderIds: ['member-a'],
|
||||||
|
startTime: Date.UTC(2026, 7, 5, 8),
|
||||||
|
limit: 10
|
||||||
|
})
|
||||||
|
).toEqual([expect.objectContaining({ messageId: 'participant-a', sender: '成员甲' })])
|
||||||
|
store.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('compresses a single-conversation recap into time chunks and deprioritizes system messages', async () => {
|
||||||
|
const root = makeRoot()
|
||||||
|
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
|
||||||
|
const base = Date.UTC(2026, 6, 1)
|
||||||
|
await store.index({
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
conversationId: 'recap-query',
|
||||||
|
completeSnapshot: true,
|
||||||
|
messages: Array.from({ length: 48 }, (_, index) => ({
|
||||||
|
accountId: FIXTURE_ACCOUNT_A,
|
||||||
|
conversationId: 'recap-query',
|
||||||
|
messageId: `recap-${index}`,
|
||||||
|
createTime: base + Math.floor(index / 12) * 3 * 3600 * 1000 + (index % 12) * 60_000,
|
||||||
|
senderId: 'fixture-member',
|
||||||
|
senderName: '脱敏成员',
|
||||||
|
kind: index % 11 === 0 ? ('system' as const) : ('text' as const),
|
||||||
|
text: index % 11 === 0 ? '对方撤回了一条消息' : `第 ${index} 条健身计划和饮食安排讨论。`
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
],
|
||||||
|
chunker: DEFAULT_KNOWLEDGE_CHUNKER
|
||||||
|
})
|
||||||
|
const result = store.searchWithStatus({
|
||||||
|
accountId: FIXTURE_ACCOUNT_A,
|
||||||
|
text: '我和张三最近聊了什么',
|
||||||
|
terms: [],
|
||||||
|
conversationIds: ['recap-query'],
|
||||||
|
startTime: base,
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.conversationRetrieval).toMatchObject({
|
||||||
|
totalMessages: 48,
|
||||||
|
chunkCount: 4,
|
||||||
|
complete: true
|
||||||
|
})
|
||||||
|
expect(result.evidence.length).toBeLessThan(48)
|
||||||
|
expect(new Set(result.evidence.map((item) => item.chunkId)).size).toBeGreaterThan(1)
|
||||||
|
expect(result.evidence.filter((item) => item.text.includes('撤回')).length).toBeLessThan(5)
|
||||||
|
store.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps late conversation slices when the recap candidate budget is reached', async () => {
|
||||||
|
const root = makeRoot()
|
||||||
|
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
|
||||||
|
const base = Date.UTC(2026, 6, 1)
|
||||||
|
await store.index({
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
conversationId: 'long-recap-query',
|
||||||
|
completeSnapshot: true,
|
||||||
|
messages: Array.from({ length: 90 }, (_, index) => ({
|
||||||
|
accountId: FIXTURE_ACCOUNT_A,
|
||||||
|
conversationId: 'long-recap-query',
|
||||||
|
messageId: `long-recap-${index}`,
|
||||||
|
createTime: base + Math.floor(index / 3) * 3 * 3600 * 1000 + (index % 3) * 60_000,
|
||||||
|
senderId: 'fixture-member',
|
||||||
|
senderName: '脱敏成员',
|
||||||
|
kind: 'text' as const,
|
||||||
|
text: `第 ${index} 条近期聊天内容。`
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
],
|
||||||
|
chunker: DEFAULT_KNOWLEDGE_CHUNKER
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = store.searchWithStatus({
|
||||||
|
accountId: FIXTURE_ACCOUNT_A,
|
||||||
|
text: '我和张三最近聊了什么',
|
||||||
|
terms: [],
|
||||||
|
conversationIds: ['long-recap-query'],
|
||||||
|
startTime: base,
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.conversationRetrieval).toMatchObject({ chunkCount: 30, candidateMessages: 60 })
|
||||||
|
expect(Math.max(...result.evidence.map((item) => item.timestamp))).toBeGreaterThan(
|
||||||
|
base + 28 * 3 * 3600 * 1000
|
||||||
|
)
|
||||||
|
store.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('provides a read-only capacity preflight before a database exists', async () => {
|
||||||
|
const root = makeRoot()
|
||||||
|
const source = createSyntheticConversation(FIXTURE_ACCOUNT_A, 'conversation-a', 0, 20, 'long')
|
||||||
|
const result = await estimateKnowledgeCapacityPreflight({
|
||||||
|
accountId: FIXTURE_ACCOUNT_A,
|
||||||
|
databaseRoot: root,
|
||||||
|
conversations: [source],
|
||||||
|
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
|
||||||
|
availableDiskBytes: 1
|
||||||
|
})
|
||||||
|
expect(result.sourceMessageCount).toBe(20)
|
||||||
|
expect(result.voiceTranscriptCount).toBeGreaterThan(0)
|
||||||
|
expect(result.hasSufficientDiskSpace).toBe(false)
|
||||||
|
expect(existsSync(getKnowledgeDatabasePath(root, FIXTURE_ACCOUNT_A))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('indexes 100,000 desensitized messages without touching the main process database', async () => {
|
||||||
|
const root = makeRoot()
|
||||||
|
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
|
||||||
|
const started = performance.now()
|
||||||
|
for (let batch = 0; batch < 10; batch += 1) {
|
||||||
|
const source = createSyntheticConversation(
|
||||||
|
FIXTURE_ACCOUNT_A,
|
||||||
|
`performance-${batch}`,
|
||||||
|
batch * 10_000,
|
||||||
|
10_000,
|
||||||
|
'mixed'
|
||||||
|
)
|
||||||
|
await store.index({ conversations: [source], chunker: DEFAULT_KNOWLEDGE_CHUNKER })
|
||||||
|
}
|
||||||
|
const stats = store.getStorageStats()
|
||||||
|
expect(stats.databaseBytes).toBeGreaterThan(0)
|
||||||
|
expect(performance.now() - started).toBeLessThan(60_000)
|
||||||
|
store.close()
|
||||||
|
}, 70_000)
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
name: 'knowledge-benchmark',
|
||||||
|
environment: 'node',
|
||||||
|
include: ['tests/benchmark/**/*.test.ts'],
|
||||||
|
fileParallelism: false,
|
||||||
|
testTimeout: 20 * 60 * 1000
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user