From ad4b3a807401d0fbb9f4e482f70d6932f0d78a6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= <969409112@qq.com> Date: Thu, 6 Aug 2026 20:29:25 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=9A=82=E5=AD=98=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron.vite.config.ts | 3 +- package.json | 4 + pnpm-lock.yaml | 484 ++++++- scripts/test-knowledge-worker.cjs | 153 ++ src/main/index.ts | 49 +- src/main/knowledge/chunker.ts | 88 ++ .../knowledge/knowledge-search-service.ts | 611 ++++++++ src/main/knowledge/knowledge-service.ts | 54 + src/main/knowledge/knowledge-store.ts | 1175 ++++++++++++++++ src/main/knowledge/knowledge-worker-host.ts | 182 +++ src/main/knowledge/knowledge-worker.ts | 200 +++ src/main/knowledge/normalizer.ts | 55 + src/main/knowledge/worker-protocol.ts | 6 + src/main/services/ai-search-agent.ts | 185 +++ src/main/services/ai-search-evidence.ts | 217 +++ .../services/ai-search-pipeline-service.ts | 1240 ++++++++++++++++ src/main/services/cache-service.ts | 21 +- .../services/contact-resolution-service.ts | 86 ++ src/preload/index.d.ts | 18 +- src/preload/index.ts | 32 +- .../components/search/AISearchWorkspace.tsx | 1242 ++++++++++------- .../src/components/search/searchMarkdown.tsx | 44 +- .../src/components/search/searchTypes.ts | 6 +- .../src/components/search/searchUtils.ts | 7 +- .../settings/pages/CacheCleanupPage.tsx | 18 +- src/renderer/src/styles/search.scss | 736 +++++++++- src/shared/ai-search.ts | 625 +++++++++ src/shared/cache.ts | 4 +- src/shared/contact-resolution.ts | 38 + src/shared/knowledge.ts | 344 +++++ tests/benchmark/knowledge-benchmark.test.ts | 217 +++ .../knowledge-capacity.benchmark.test.ts | 94 ++ .../knowledge-realistic-fts.benchmark.test.ts | 209 +++ tests/fixtures/knowledge-rag.ts | 158 +++ tests/fixtures/knowledge-realistic.ts | 304 ++++ tests/integration/preload-contract.test.ts | 24 + tests/unit/ai-search-evidence.test.ts | 76 + tests/unit/ai-search-pipeline-service.test.ts | 732 ++++++++++ tests/unit/ai-search-time-range.test.ts | 77 + tests/unit/contact-resolution-service.test.ts | 69 + tests/unit/knowledge-search-service.test.ts | 208 +++ tests/unit/knowledge-store.test.ts | 384 +++++ vitest.knowledge-benchmark.config.ts | 11 + 43 files changed, 9978 insertions(+), 512 deletions(-) create mode 100644 scripts/test-knowledge-worker.cjs create mode 100644 src/main/knowledge/chunker.ts create mode 100644 src/main/knowledge/knowledge-search-service.ts create mode 100644 src/main/knowledge/knowledge-service.ts create mode 100644 src/main/knowledge/knowledge-store.ts create mode 100644 src/main/knowledge/knowledge-worker-host.ts create mode 100644 src/main/knowledge/knowledge-worker.ts create mode 100644 src/main/knowledge/normalizer.ts create mode 100644 src/main/knowledge/worker-protocol.ts create mode 100644 src/main/services/ai-search-agent.ts create mode 100644 src/main/services/ai-search-evidence.ts create mode 100644 src/main/services/ai-search-pipeline-service.ts create mode 100644 src/main/services/contact-resolution-service.ts create mode 100644 src/shared/ai-search.ts create mode 100644 src/shared/contact-resolution.ts create mode 100644 src/shared/knowledge.ts create mode 100644 tests/benchmark/knowledge-benchmark.test.ts create mode 100644 tests/benchmark/knowledge-capacity.benchmark.test.ts create mode 100644 tests/benchmark/knowledge-realistic-fts.benchmark.test.ts create mode 100644 tests/fixtures/knowledge-rag.ts create mode 100644 tests/fixtures/knowledge-realistic.ts create mode 100644 tests/unit/ai-search-evidence.test.ts create mode 100644 tests/unit/ai-search-pipeline-service.test.ts create mode 100644 tests/unit/ai-search-time-range.test.ts create mode 100644 tests/unit/contact-resolution-service.test.ts create mode 100644 tests/unit/knowledge-search-service.test.ts create mode 100644 tests/unit/knowledge-store.test.ts create mode 100644 vitest.knowledge-benchmark.config.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index da79803..471be5f 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -8,7 +8,8 @@ export default defineConfig({ rollupOptions: { input: { 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: { entryFileNames: '[name].js' diff --git a/package.json b/package.json index 9638455..5ed3e2c 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,10 @@ "test:unit": "vitest run --config vitest.unit.config.ts", "test:component": "vitest run --config vitest.component.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:knowledge-worker": "pnpm test:e2e:build && node scripts/test-knowledge-worker.cjs", "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:smoke": "node --test tests/smoke/native-environment.test.mjs", @@ -59,6 +62,7 @@ "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", "@koromix/koffi-win32-x64": "3.1.0", + "@radix-ui/react-popover": "^1.1.23", "@tanstack/react-virtual": "^3.14.6", "archiver": "^8.0.0", "cross-env": "^10.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3348e1c..a9c754a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,7 @@ specifiers: '@electron-toolkit/utils': ^4.0.0 '@koromix/koffi-win32-x64': 3.1.0 '@playwright/test': ^1.62.1 + '@radix-ui/react-popover': ^1.1.23 '@rollup/rollup-darwin-arm64': ^4.62.2 '@tanstack/react-virtual': ^3.14.6 '@testing-library/dom': ^10.4.1 @@ -58,6 +59,7 @@ dependencies: '@electron-toolkit/preload': 3.0.2_electron@43.1.0 '@electron-toolkit/utils': 4.0.0_electron@43.1.0 '@koromix/koffi-win32-x64': 3.1.0 + '@radix-ui/react-popover': 1.1.23_eijghdl4n2x4hz6j4cg7ctgbuu '@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq archiver: 8.0.0 cross-env: 10.1.0 @@ -888,6 +890,34 @@ packages: optional: 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: resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} dev: true @@ -1247,6 +1277,355 @@ packages: playwright: 1.62.1 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: resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} dev: true @@ -1655,13 +2034,11 @@ packages: '@types/react': ^19.2.0 dependencies: '@types/react': 19.2.7 - dev: true /@types/react/19.2.7: resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} dependencies: csstype: 3.2.3 - dev: true /@types/readdir-glob/1.1.5: resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} @@ -2095,6 +2472,13 @@ packages: /argparse/2.0.1: 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: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} dependencies: @@ -2723,7 +3107,6 @@ packages: /csstype/3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - dev: true /data-urls/7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} @@ -2832,6 +3215,10 @@ packages: engines: {node: '>=8'} dev: true + /detect-node-es/1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dev: false + /dir-compare/4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} dependencies: @@ -3661,6 +4048,11 @@ packages: math-intrinsics: 1.1.0 dev: true + /get-nonce/1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + dev: false + /get-proto/1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -5118,6 +5510,57 @@ packages: engines: {node: '>=0.10.0'} 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: resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} engines: {node: '>=0.10.0'} @@ -5912,6 +6355,10 @@ packages: typescript: 5.9.3 dev: true + /tslib/2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + dev: false + /type-check/0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -6053,6 +6500,37 @@ packages: punycode: 2.3.1 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: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} dev: true diff --git a/scripts/test-knowledge-worker.cjs b/scripts/test-knowledge-worker.cjs new file mode 100644 index 0000000..7ca8d66 --- /dev/null +++ b/scripts/test-knowledge-worker.cjs @@ -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 +}) diff --git a/src/main/index.ts b/src/main/index.ts index 20444a0..ae23e01 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -104,6 +104,10 @@ import type { ExportRequest } from '../shared/export' import { discoverAccounts } from './services/account-discovery' import { VoiceRecognitionUseCase } from './voice-pipeline/voice-recognition-use-case' 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. // 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 voiceRecognition: VoiceRecognitionUseCase | null = null +let knowledgeSearchService: KnowledgeSearchService | null = null +let aiSearchPipelineService: AiSearchPipelineService | null = null let imageDecryptService: ImageDecryptService | null = null let stickerService: StickerService | null = null let videoAssetService: VideoAssetService | null = null @@ -430,6 +436,16 @@ app.whenReady().then(async () => { databasePath: join(app.getPath('userData'), 'cache', 'voice-transcripts.sqlite'), 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) => { for (const window of BrowserWindow.getAllWindows()) { 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('cache:getSummary', () => getCacheSummary()) 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() imageDecryptService = null - return clearCache(scope) + return clearCache(scope, { + beforeClearKnowledge: () => + knowledgeSearchService?.prepareForCacheClear() || Promise.resolve() + }) }) 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( + 'knowledge:search', + (_, request: KnowledgeSearchIpcRequest): Promise => { + 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( 'ai:chat', @@ -1444,7 +1486,8 @@ app.on('before-quit', (event) => { const [, nativeCallsDrained] = await Promise.all([ apiServer.stop().catch(() => undefined), chat.closeChatDbForQuit().catch(() => false), - voiceRecognition?.dispose().catch(() => undefined) + voiceRecognition?.dispose().catch(() => undefined), + knowledgeSearchService?.dispose().catch(() => undefined) ]) if (!nativeCallsDrained) { console.warn('[Shutdown] WCDB async calls did not fully drain before quit') diff --git a/src/main/knowledge/chunker.ts b/src/main/knowledge/chunker.ts new file mode 100644 index 0000000..8da1305 --- /dev/null +++ b/src/main/knowledge/chunker.ts @@ -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 +} diff --git a/src/main/knowledge/knowledge-search-service.ts b/src/main/knowledge/knowledge-search-service.ts new file mode 100644 index 0000000..5b2116f --- /dev/null +++ b/src/main/knowledge/knowledge-search-service.ts @@ -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>() + private readonly statusByAccount = new Map() + private readonly statusListeners = new Set<(status: KnowledgeRuntimeStatus) => void>() + private wcdbReadTail: Promise = 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 { + const accountId = this.currentAccountId() + if (!accountId) return this.searchFallback(request, 'unavailable') + try { + const searchRequest: Omit = { + 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 { + await this.service.dispose() + } + + /** Safely release derived SQLite handles before the cache screen removes them. */ + async prepareForCacheClear(): Promise { + 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 { + 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 { + 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 { + 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 + ): Promise { + 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() + 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 + ): Promise { + 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 { + return this.enqueueWcdbRead(() => chat.listContactsAsync()) + } + + private listMessages( + conversationId: string, + startTime?: number, + endTime?: number + ): ReturnType { + return this.enqueueWcdbRead(() => chat.listMessagesAsync(conversationId, startTime, endTime)) + } + + private async toKnowledgeResult( + result: KnowledgeSearchResult + ): Promise { + return { + ...result, + evidence: await this.enrichEvidenceSenders(result.evidence), + source: 'knowledge', + totalMessages: result.indexedMessageCount + } + } + + private async enrichEvidenceSenders(evidence: KnowledgeEvidence[]): Promise { + 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>() + 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(operation: () => Promise): Promise { + 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 & { + startedAt?: number + } + ): Promise { + 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) + } +} diff --git a/src/main/knowledge/knowledge-service.ts b/src/main/knowledge/knowledge-service.ts new file mode 100644 index 0000000..9da77cf --- /dev/null +++ b/src/main/knowledge/knowledge-service.ts @@ -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, + onProgress?: (progress: KnowledgeIndexProgress) => void + ): Promise { + return this.worker.index({ ...request, databaseRoot: this.databaseRoot }, onProgress) + } + + preflight( + request: Omit + ): Promise { + return this.worker.preflight({ ...request, databaseRoot: this.databaseRoot }) + } + + remove(accountId: string): Promise<{ removed: true }> { + return this.worker.remove(accountId, this.databaseRoot) + } + + search(request: Omit): Promise { + return this.worker.search({ ...request, databaseRoot: this.databaseRoot }) + } + + status(request: Omit): Promise { + return this.worker.status({ ...request, databaseRoot: this.databaseRoot }) + } + + dispose(): Promise { + return this.worker.dispose() + } +} diff --git a/src/main/knowledge/knowledge-store.ts b/src/main/knowledge/knowledge-store.ts new file mode 100644 index 0000000..6c1c398 --- /dev/null +++ b/src/main/knowledge/knowledge-store.ts @@ -0,0 +1,1175 @@ +import { createHash } from 'crypto' +import { existsSync, mkdirSync, rmSync, statSync } from 'fs' +import { DatabaseSync } from 'node:sqlite' +import { dirname, join, resolve } from 'path' +import type { + KnowledgeCapacityPreflight, + KnowledgeCapacityPreflightRequest, + KnowledgeChunk, + KnowledgeConversationRetrieval, + KnowledgeEvidence, + KnowledgeFtsConfig, + KnowledgeIndexProgress, + KnowledgeIndexRequest, + KnowledgeIndexResult, + KnowledgeRuntimeStatus, + KnowledgeNormalizedMessage, + KnowledgeQuery, + KnowledgeSearchTimings, + KnowledgeSearchResult +} from '../../shared/knowledge' +import { emptyKnowledgeSearchTimings, KNOWLEDGE_SCHEMA_VERSION } from '../../shared/knowledge' +import { chunkConversation } from './chunker' +import { normalizeKnowledgeMessage } from './normalizer' + +type DbRow = Record + +const YIELD_EVERY = 500 +const MAX_SAFE_ACCOUNT_SEGMENT = /^[a-f0-9]{32}$/ +const MAX_CONVERSATION_SUMMARY_SOURCE_MESSAGES = 2_000 +const MAX_CONVERSATION_SUMMARY_CANDIDATES = 60 +const CONVERSATION_SUMMARY_MAX_MESSAGES_PER_CHUNK = 24 +const CONVERSATION_SUMMARY_GAP_MS = 2 * 60 * 60 * 1000 + +function digest(value: string): string { + return createHash('sha256').update(value).digest('hex') +} + +function asRows(value: unknown): DbRow[] { + return Array.isArray(value) ? (value as DbRow[]) : [] +} + +function encodedJson(value: unknown): string { + return JSON.stringify(value) +} + +function byteLength(value: string): number { + return Buffer.byteLength(value, 'utf8') +} + +function normalizeComparable(value: string): string { + return value.toLocaleLowerCase().replace(/\s+/g, '') +} + +function normalizedQueryTerms(query: KnowledgeQuery): string[] { + // An explicit empty term list means this is a person/session/time-only + // query. Omitted terms retain the legacy direct-text behavior for callers. + const values = query.terms === undefined ? [query.text] : query.terms + return Array.from( + new Set( + values + .map((value) => value.trim()) + .filter((value) => value.length >= 2 && value.length <= 160) + ) + ).slice(0, 16) +} + +function isTrigramEligible(term: string): boolean { + const searchableCharacters = Array.from(term.replace(/[^\p{L}\p{N}_]+/gu, '')) + return searchableCharacters.length >= 3 +} + +function messageTermScore(text: string, terms: string[]): number { + const normalizedText = normalizeComparable(text) + return terms.reduce((score, term) => { + const comparable = normalizeComparable(term) + return comparable && normalizedText.includes(comparable) ? score + comparable.length : score + }, 0) +} + +function waitForWorkerTurn(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} + +/** The on-disk directory never exposes the account identifier directly. */ +export function knowledgeAccountKey(accountId: string): string { + return digest(`knowledge-account-v1:${accountId}`).slice(0, 32) +} + +export function getKnowledgeDatabasePath(databaseRoot: string, accountId: string): string { + const accountKey = knowledgeAccountKey(accountId) + if (!MAX_SAFE_ACCOUNT_SEGMENT.test(accountKey)) throw new Error('Invalid knowledge account key') + return join(resolve(databaseRoot), accountKey, 'knowledge.sqlite') +} + +export function removeKnowledgeDatabase(databaseRoot: string, accountId: string): void { + const databasePath = getKnowledgeDatabasePath(databaseRoot, accountId) + // Only remove known derived files; never recurse into, inspect, or modify the source archive. + for (const suffix of ['', '-wal', '-shm']) rmSync(`${databasePath}${suffix}`, { force: true }) +} + +function ftsTokenize(config: KnowledgeFtsConfig): string { + return config.tokenizer === 'trigram' ? 'trigram' : 'unicode61 remove_diacritics 2' +} + +function ftsConfigFingerprint(config: KnowledgeFtsConfig): string { + return digest(JSON.stringify(config)) +} + +function normalizeFtsQuery(value: string, config: KnowledgeFtsConfig): string { + const terms = value + // FTS5 MATCH has its own punctuation grammar. URLs, file names and paths + // are ordinary WeChat search inputs, so turn separators into independent + // safe terms instead of passing `/`, `:`, `-`, or `.` through to MATCH. + .replace(/[^\p{L}\p{N}_]+/gu, ' ') + .split(/\s+/) + .map((term) => term.trim()) + .filter(Boolean) + .slice(0, 12) + if (config.detail === 'full') { + return terms.map((term) => `"${term.replace(/"/g, ' ')}"`).join(' OR ') + } + // detail=column/none does not retain positional data. Avoid passing a + // multi-token phrase to FTS5; a single trigram still gives a valid, lower- + // fidelity candidate set for this benchmark profile. + const tokens = terms + .map((term) => { + if (config.tokenizer !== 'trigram') return term + const characters = Array.from(term) + return characters.length >= 3 ? characters.slice(0, 3).join('') : '' + }) + .filter(Boolean) + return Array.from(new Set(tokens)).join(' OR ') +} + +export class KnowledgeStore { + private readonly database: DatabaseSync + private readonly databasePath: string + private readonly ftsExternalContent: boolean + + constructor( + private readonly databaseRoot: string, + private readonly accountId: string, + private readonly fts: KnowledgeFtsConfig + ) { + this.databasePath = getKnowledgeDatabasePath(databaseRoot, accountId) + mkdirSync(dirname(this.databasePath), { recursive: true, mode: 0o700 }) + this.database = new DatabaseSync(this.databasePath) + this.ftsExternalContent = fts.contentMode === 'external' + this.initialize() + } + + close(): void { + this.database.close() + } + + get path(): string { + return this.databasePath + } + + getStorageStats(): { + databaseBytes: number + walBytes: number + shmBytes: number + pageSize: number + pageCount: number + freelistCount: number + } { + const pageSize = Number((this.database.prepare('PRAGMA page_size').get() as DbRow).page_size) + const pageCount = Number((this.database.prepare('PRAGMA page_count').get() as DbRow).page_count) + const freelistCount = Number( + (this.database.prepare('PRAGMA freelist_count').get() as DbRow).freelist_count + ) + const shmPath = `${this.databasePath}-shm` + return { + databaseBytes: this.databaseBytes(), + walBytes: this.walBytes(), + shmBytes: existsSync(shmPath) ? statSync(shmPath).size : 0, + pageSize, + pageCount, + freelistCount + } + } + + /** Flush WAL into the main derived database before reporting final size. */ + checkpoint(): void { + this.database.exec('PRAGMA wal_checkpoint(TRUNCATE)') + } + + async index( + request: Pick & { + sourceMessageCount?: number + }, + signal?: AbortSignal, + onProgress?: (progress: KnowledgeIndexProgress) => void + ): Promise { + const startedAt = Date.now() + const totalMessages = request.conversations.reduce( + (total, conversation) => total + conversation.messages.length, + 0 + ) + let processedMessages = 0 + let indexedChunks = 0 + let updatedChunks = 0 + let unchangedConversations = 0 + this.setRunState('indexing') + try { + for (const conversation of request.conversations) { + this.assertNotAborted(signal) + const result = await this.indexConversation( + conversation, + request.chunker, + signal, + (processed, chunks) => { + onProgress?.({ + accountId: this.accountId, + phase: 'indexing', + conversationId: conversation.conversationId, + processedMessages: processedMessages + processed, + totalMessages, + indexedChunks: indexedChunks + chunks + }) + } + ) + processedMessages += conversation.messages.length + indexedChunks += result.chunkCount + updatedChunks += result.updatedChunks + if (!result.updatedChunks) unchangedConversations += 1 + onProgress?.({ + accountId: this.accountId, + phase: 'indexing', + conversationId: conversation.conversationId, + processedMessages, + totalMessages, + indexedChunks + }) + await waitForWorkerTurn() + } + if (request.sourceMessageCount !== undefined) { + this.writeMeta('source_message_count', String(request.sourceMessageCount)) + } + this.setRunState('ready') + return { + accountId: this.accountId, + processedMessages, + indexedChunks, + updatedChunks, + unchangedConversations, + databaseBytes: this.databaseBytes(), + walBytes: this.walBytes(), + elapsedMs: Date.now() - startedAt, + cancelled: false + } + } catch (error) { + const cancelled = signal?.aborted === true + this.setRunState( + cancelled ? 'cancelled' : 'error', + error instanceof Error ? error.message : String(error) + ) + if (cancelled) { + return { + accountId: this.accountId, + processedMessages, + indexedChunks, + updatedChunks, + unchangedConversations, + databaseBytes: this.databaseBytes(), + walBytes: this.walBytes(), + elapsedMs: Date.now() - startedAt, + cancelled: true + } + } + throw error + } + } + + async preflight( + request: Pick< + KnowledgeCapacityPreflightRequest, + 'conversations' | 'chunker' | 'availableDiskBytes' + > + ): Promise { + return estimateKnowledgeCapacityPreflight({ + ...request, + accountId: this.accountId, + databaseRoot: this.databaseRoot + }) + } + + getSearchStatus(): Omit { + const indexedMessageCount = Number( + (this.database.prepare('SELECT COUNT(*) AS count FROM knowledge_messages').get() as DbRow) + .count + ) + const indexedChunkCount = Number( + (this.database.prepare('SELECT COUNT(*) AS count FROM knowledge_chunks').get() as DbRow).count + ) + const runState = this.readMeta('run_state') + return { + state: + runState === 'indexing' + ? 'indexing' + : runState === 'ready' && indexedChunkCount > 0 + ? 'ready' + : 'unavailable', + indexedMessageCount, + indexedChunkCount, + timings: emptyKnowledgeSearchTimings() + } + } + + getRuntimeStatus(): KnowledgeRuntimeStatus { + const search = this.getSearchStatus() + const storage = this.getStorageStats() + const sourceRaw = this.readMeta('source_message_count') + const sourceMessageCount = + sourceRaw && Number.isFinite(Number(sourceRaw)) ? Number(sourceRaw) : null + const runState = this.readMeta('run_state') + const error = this.readMeta('run_error') || undefined + return { + accountId: this.accountId, + state: runState === 'error' ? 'error' : search.state === 'ready' ? 'ready' : 'unavailable', + indexedMessageCount: search.indexedMessageCount, + indexedChunkCount: search.indexedChunkCount, + sourceMessageCount, + processedMessages: search.indexedMessageCount, + totalMessages: sourceMessageCount, + estimatedRemainingMs: null, + databaseBytes: storage.databaseBytes, + walBytes: storage.walBytes, + shmBytes: storage.shmBytes, + lastError: error + } + } + + search(query: KnowledgeQuery): KnowledgeEvidence[] { + return this.searchMeasured(query).evidence + } + + private searchMeasured(query: KnowledgeQuery): { + evidence: KnowledgeEvidence[] + timings: KnowledgeSearchTimings + conversationRetrieval?: KnowledgeConversationRetrieval + } { + const startedAt = Date.now() + let ftsMs = 0 + let messageLoadMs = 0 + let chunkExpandMs = 0 + let rankingMs = 0 + if (query.accountId !== this.accountId) + throw new Error('Knowledge query account does not match database') + const terms = normalizedQueryTerms(query) + const conversationIds = Array.from( + new Set([ + ...(query.conversationIds || []), + ...(query.conversationId ? [query.conversationId] : []) + ]) + ).filter(Boolean) + const senderIds = new Set(query.senderIds || []) + if (!terms.length) { + const messageLoadStartedAt = Date.now() + const metadata = this.searchByMetadata(query, conversationIds, senderIds) + messageLoadMs = Date.now() - messageLoadStartedAt + return { + evidence: metadata.evidence, + conversationRetrieval: metadata.conversationRetrieval, + timings: { + ...emptyKnowledgeSearchTimings(), + messageLoadMs, + totalMs: Date.now() - startedAt + } + } + } + const ftsTerms = this.fts.tokenizer === 'trigram' ? terms.filter(isTrigramEligible) : terms + const match = normalizeFtsQuery(ftsTerms.join(' '), this.fts) + const clauses = match ? ['knowledge_fts MATCH ?'] : [] + const values: (string | number)[] = match ? [match] : [] + if (conversationIds.length) { + clauses.push(`c.conversation_id IN (${conversationIds.map(() => '?').join(', ')})`) + values.push(...conversationIds) + } + if (query.startTime !== undefined) { + clauses.push('c.end_time >= ?') + values.push(query.startTime) + } + if (query.endTime !== undefined) { + clauses.push('c.start_time <= ?') + values.push(query.endTime) + } + const ftsStartedAt = Date.now() + const chunks = match + ? asRows( + this.database + .prepare( + `SELECT c.chunk_id, c.conversation_id, c.start_time, c.end_time, c.message_ids_json, + bm25(knowledge_fts) AS score + FROM knowledge_fts + JOIN knowledge_chunks c ON c.rowid = knowledge_fts.rowid + WHERE ${clauses.join(' AND ')} + ORDER BY score, c.end_time DESC + LIMIT ?` + ) + .all(...values, Math.max(1, Math.min(query.limit * 4, 100))) + ) + : [] + ftsMs = Date.now() - ftsStartedAt + const evidenceByMessage = new Map() + for (const chunk of chunks) { + const chunkExpandStartedAt = Date.now() + const conversationId = String(chunk.conversation_id) + const messageIds = JSON.parse(String(chunk.message_ids_json)) as string[] + const messageLoadStartedAt = Date.now() + const messages = asRows( + this.database + .prepare( + `SELECT message_id, create_time, searchable_text, sender_id, sender_name + FROM knowledge_messages + WHERE conversation_id = ? AND message_id IN (${messageIds.map(() => '?').join(', ')})` + ) + .all(conversationId, ...messageIds) + ) + messageLoadMs += Date.now() - messageLoadStartedAt + const ranked = messages + .filter((message) => !senderIds.size || senderIds.has(String(message.sender_id || ''))) + .map((message) => ({ + row: message, + termScore: messageTermScore(String(message.searchable_text), terms) + })) + .filter((item) => item.termScore > 0 || senderIds.size > 0) + .sort( + (left, right) => + right.termScore - left.termScore || + Number(right.row.create_time) - Number(left.row.create_time) + ) + for (const item of ranked) { + const row = item.row + const messageId = String(row.message_id) + const identity = `${conversationId}\u0000${messageId}` + if (evidenceByMessage.has(identity)) continue + evidenceByMessage.set(identity, { + chunkId: String(chunk.chunk_id), + conversationId, + startTime: Number(chunk.start_time), + endTime: Number(chunk.end_time), + messageId, + senderId: row.sender_id ? String(row.sender_id) : undefined, + sender: String(row.sender_name || row.sender_id || '未知成员'), + timestamp: Number(row.create_time), + messageIds, + text: String(row.searchable_text), + score: Number(chunk.score) - item.termScore / 1000 + }) + } + chunkExpandMs += Date.now() - chunkExpandStartedAt + } + if (!chunks.length && this.fts.tokenizer === 'trigram') { + const shortTermStartedAt = Date.now() + for (const item of this.searchShortTerms(query, terms, conversationIds, senderIds)) { + evidenceByMessage.set(`${item.conversationId}\u0000${item.messageId}`, item) + } + // The trigram short-term fallback uses a real local SQLite query but not MATCH. + ftsMs += Date.now() - shortTermStartedAt + } + const rankingStartedAt = Date.now() + const evidence = Array.from(evidenceByMessage.values()) + .sort( + (left, right) => (left.score ?? 0) - (right.score ?? 0) || right.timestamp - left.timestamp + ) + .slice(0, Math.max(1, Math.min(query.limit, 100))) + rankingMs = Date.now() - rankingStartedAt + return { + evidence, + timings: { + ...emptyKnowledgeSearchTimings(), + ftsMs, + messageLoadMs, + chunkExpandMs, + rankingMs, + totalMs: Date.now() - startedAt + } + } + } + + private searchShortTerms( + query: KnowledgeQuery, + terms: string[], + conversationIds: string[], + senderIds: Set + ): KnowledgeEvidence[] { + const shortTerms = terms.filter((term) => !isTrigramEligible(term)) + if (!shortTerms.length) return [] + const clauses = ['1 = 1'] + const values: (string | number)[] = [] + if (conversationIds.length) { + clauses.push(`m.conversation_id IN (${conversationIds.map(() => '?').join(', ')})`) + values.push(...conversationIds) + } + if (query.startTime !== undefined) { + clauses.push('m.create_time >= ?') + values.push(query.startTime) + } + if (query.endTime !== undefined) { + clauses.push('m.create_time <= ?') + values.push(query.endTime) + } + if (senderIds.size) { + clauses.push( + `m.sender_id IN (${Array.from(senderIds) + .map(() => '?') + .join(', ')})` + ) + values.push(...senderIds) + } + clauses.push( + `(${shortTerms.map(() => 'm.searchable_text LIKE ? COLLATE NOCASE').join(' OR ')})` + ) + values.push(...shortTerms.map((term) => `%${term}%`)) + values.push(Math.max(1, Math.min(query.limit, 100))) + return asRows( + this.database + .prepare( + `SELECT m.conversation_id, m.message_id, m.create_time, m.searchable_text, m.sender_id, m.sender_name + FROM knowledge_messages m + WHERE ${clauses.join(' AND ')} + ORDER BY m.create_time DESC + LIMIT ?` + ) + .all(...values) + ) + .map((row) => ({ + messageId: String(row.message_id), + text: String(row.searchable_text), + termScore: messageTermScore(String(row.searchable_text), shortTerms), + row + })) + .sort( + (left, right) => + right.termScore - left.termScore || + Number(right.row.create_time) - Number(left.row.create_time) + ) + .map(({ messageId, text, row, termScore }) => ({ + chunkId: `short-exact:${String(row.conversation_id)}:${messageId}`, + conversationId: String(row.conversation_id), + startTime: Number(row.create_time), + endTime: Number(row.create_time), + messageId, + senderId: row.sender_id ? String(row.sender_id) : undefined, + sender: String(row.sender_name || row.sender_id || '未知成员'), + timestamp: Number(row.create_time), + messageIds: [messageId], + text, + score: -termScore / 1000 + })) + } + + private searchByMetadata( + query: KnowledgeQuery, + conversationIds: string[], + senderIds: Set + ): { + evidence: KnowledgeEvidence[] + conversationRetrieval?: KnowledgeConversationRetrieval + } { + const clauses = ['1 = 1'] + const values: (string | number)[] = [] + if (conversationIds.length) { + clauses.push(`m.conversation_id IN (${conversationIds.map(() => '?').join(', ')})`) + values.push(...conversationIds) + } + if (query.startTime !== undefined) { + clauses.push('m.create_time >= ?') + values.push(query.startTime) + } + if (query.endTime !== undefined) { + clauses.push('m.create_time <= ?') + values.push(query.endTime) + } + if (senderIds.size) { + clauses.push( + `m.sender_id IN (${Array.from(senderIds) + .map(() => '?') + .join(', ')})` + ) + values.push(...senderIds) + } + const singleConversation = conversationIds.length === 1 && senderIds.size === 0 + if (singleConversation) { + const count = this.database + .prepare( + `SELECT COUNT(*) AS total FROM knowledge_messages m WHERE ${clauses.join(' AND ')}` + ) + .get(...values) as DbRow | undefined + const totalMessages = Number(count?.total || 0) + const rows = asRows( + this.database + .prepare( + `SELECT m.conversation_id, m.message_id, m.create_time, m.searchable_text, m.kind, + m.sender_id, m.sender_name + FROM knowledge_messages m + WHERE ${clauses.join(' AND ')} + ORDER BY m.create_time ASC + LIMIT ?` + ) + .all(...values, MAX_CONVERSATION_SUMMARY_SOURCE_MESSAGES) + ) + const chunks: DbRow[][] = [] + for (const row of rows) { + const previous = chunks.at(-1)?.at(-1) + const isNewChunk = + !previous || + Number(row.create_time) - Number(previous.create_time) > CONVERSATION_SUMMARY_GAP_MS || + chunks.at(-1)!.length >= CONVERSATION_SUMMARY_MAX_MESSAGES_PER_CHUNK + if (isNewChunk) chunks.push([]) + chunks.at(-1)!.push(row) + } + const representativesByChunk: Array> = [] + for (const chunk of chunks) { + const preferred = chunk.filter((row) => String(row.kind) !== 'system') + const pool = preferred.length ? preferred : chunk + const ranked = [...pool].sort( + (left, right) => + String(right.searchable_text).length - String(left.searchable_text).length || + Number(right.create_time) - Number(left.create_time) + ) + const representatives = [ranked[0], pool[0], pool.at(-1)].filter( + (row, index, items): row is DbRow => Boolean(row) && items.indexOf(row) === index + ) + representativesByChunk.push(representatives.map((row) => ({ row, chunk }))) + } + // Preserve at least one representative from every time slice before + // adding a second/third. A simple first-60 cap would silently discard + // the latest slices in long conversations, which is the opposite of a + // useful "最近聊了什么" recap. + const selected: Array<{ row: DbRow; chunk: DbRow[] }> = [] + for ( + let representativeIndex = 0; + selected.length < MAX_CONVERSATION_SUMMARY_CANDIDATES; + representativeIndex += 1 + ) { + let added = false + for (const representatives of representativesByChunk) { + const representative = representatives[representativeIndex] + if (representative && selected.length < MAX_CONVERSATION_SUMMARY_CANDIDATES) { + selected.push(representative) + added = true + } + } + if (!added) break + } + const systemMessagesDeprioritized = rows.filter((row) => String(row.kind) === 'system').length + return { + evidence: selected.map(({ row, chunk }) => this.metadataEvidence(row, chunk)), + conversationRetrieval: { + conversationId: conversationIds[0], + totalMessages, + chunkCount: chunks.length, + candidateMessages: selected.length, + systemMessagesDeprioritized, + complete: totalMessages <= MAX_CONVERSATION_SUMMARY_SOURCE_MESSAGES + } + } + } + values.push(Math.max(1, Math.min(query.limit, 100))) + return { + evidence: asRows( + this.database + .prepare( + `SELECT m.conversation_id, m.message_id, m.create_time, m.searchable_text, m.sender_id, m.sender_name + FROM knowledge_messages m + WHERE ${clauses.join(' AND ')} + ORDER BY m.create_time DESC + LIMIT ?` + ) + .all(...values) + ).map((row) => this.metadataEvidence(row)) + } + } + + private metadataEvidence(row: DbRow, chunk?: DbRow[]): KnowledgeEvidence { + const messageId = String(row.message_id) + const first = chunk?.[0] + const last = chunk?.at(-1) + return { + chunkId: chunk + ? `conversation-summary:${String(row.conversation_id)}:${Number(first?.create_time || row.create_time)}` + : `metadata:${String(row.conversation_id)}:${messageId}`, + conversationId: String(row.conversation_id), + startTime: Number(first?.create_time || row.create_time), + endTime: Number(last?.create_time || row.create_time), + messageId, + senderId: row.sender_id ? String(row.sender_id) : undefined, + sender: String(row.sender_name || row.sender_id || '未知成员'), + timestamp: Number(row.create_time), + messageIds: chunk ? chunk.map((item) => String(item.message_id)) : [messageId], + text: String(row.searchable_text), + score: String(row.kind) === 'system' ? 1 : 0 + } + } + + searchWithStatus(query: KnowledgeQuery): KnowledgeSearchResult { + const status = this.getSearchStatus() + const measured = status.indexedChunkCount > 0 ? this.searchMeasured(query) : null + return { + ...status, + // A long incremental pass can already have durable chunks. Those chunks are + // safe to query and avoid falling back to a second scan of the source archive. + evidence: measured?.evidence || [], + timings: measured?.timings || emptyKnowledgeSearchTimings(), + conversationRetrieval: measured?.conversationRetrieval + } + } + + private initialize(): void { + this.database.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 5000; + CREATE TABLE IF NOT EXISTS knowledge_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) STRICT; + CREATE TABLE IF NOT EXISTS knowledge_messages ( + account_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + message_id TEXT NOT NULL, + create_time INTEGER NOT NULL, + content_hash TEXT NOT NULL, + searchable_text TEXT NOT NULL, + kind TEXT NOT NULL, + sender_id TEXT, + sender_name TEXT, + attachment_json TEXT, + voice_transcript TEXT, + PRIMARY KEY (conversation_id, message_id) + ) STRICT; + CREATE INDEX IF NOT EXISTS knowledge_messages_conversation_time + ON knowledge_messages (conversation_id, create_time); + CREATE TABLE IF NOT EXISTS knowledge_chunks ( + rowid INTEGER PRIMARY KEY, + chunk_id TEXT NOT NULL UNIQUE, + account_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + start_time INTEGER NOT NULL, + end_time INTEGER NOT NULL, + text TEXT NOT NULL, + message_ids_json TEXT NOT NULL, + participant_ids_json TEXT NOT NULL, + message_kinds_json TEXT NOT NULL, + content_hash TEXT NOT NULL, + chunker_version TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) STRICT; + CREATE INDEX IF NOT EXISTS knowledge_chunks_conversation_time + ON knowledge_chunks (conversation_id, end_time); + CREATE TABLE IF NOT EXISTS knowledge_index_state ( + conversation_id TEXT PRIMARY KEY, + account_id TEXT NOT NULL, + chunker_version TEXT NOT NULL, + state TEXT NOT NULL, + high_water_time INTEGER, + indexed_message_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + updated_at INTEGER NOT NULL + ) STRICT; + `) + this.writeMetaIfMissing('schema_version', String(KNOWLEDGE_SCHEMA_VERSION)) + const storedAccount = this.readMeta('account_id') + if (storedAccount && storedAccount !== this.accountId) { + throw new Error('Knowledge database account isolation check failed') + } + this.writeMetaIfMissing('account_id', this.accountId) + const fingerprint = ftsConfigFingerprint(this.fts) + const existingFingerprint = this.readMeta('fts_config') + if (existingFingerprint && existingFingerprint !== fingerprint) { + throw new Error('Knowledge FTS profile changed; rebuild this derived index before reuse') + } + this.writeMetaIfMissing('fts_config', fingerprint) + this.createFtsTable() + } + + private createFtsTable(): void { + const content = this.ftsExternalContent + ? ", content = 'knowledge_chunks', content_rowid = 'rowid'" + : '' + this.database.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5( + text, + tokenize = '${ftsTokenize(this.fts)}'${content}, + detail = '${this.fts.detail}', + columnsize = ${this.fts.columnsize} + ); + `) + } + + private async indexConversation( + conversation: KnowledgeCapacityPreflightRequest['conversations'][number], + chunker: KnowledgeCapacityPreflightRequest['chunker'], + signal: AbortSignal | undefined, + onProgress: (processed: number, chunks: number) => void + ): Promise<{ chunkCount: number; updatedChunks: number }> { + if (!conversation.conversationId) throw new Error('Knowledge conversation id is required') + const normalized = conversation.messages.map((message) => { + if (message.accountId !== this.accountId) + throw new Error('Knowledge source account does not match database') + if (message.conversationId !== conversation.conversationId) { + throw new Error('Knowledge source conversation does not match request') + } + return normalizeKnowledgeMessage(message) + }) + normalized.sort( + (left, right) => + left.createTime - right.createTime || left.messageId.localeCompare(right.messageId) + ) + + const existingState = this.database + .prepare('SELECT state FROM knowledge_index_state WHERE conversation_id = ?') + .get(conversation.conversationId) as DbRow | undefined + const existingMessages = new Map( + asRows( + this.database + .prepare( + 'SELECT message_id, create_time, content_hash FROM knowledge_messages WHERE conversation_id = ?' + ) + .all(conversation.conversationId) + ).map((row) => [String(row.message_id), row]) + ) + const incomingIds = new Set(normalized.map((message) => message.messageId)) + let changedAt = existingState?.state === 'ready' ? -1 : 0 + for (let index = 0; index < normalized.length; index += 1) { + const message = normalized[index] + const existing = existingMessages.get(message.messageId) + if (!existing || String(existing.content_hash) !== message.contentHash) { + changedAt = changedAt < 0 ? index : Math.min(changedAt, index) + } + } + if (conversation.completeSnapshot) { + for (const messageId of existingMessages.keys()) { + if (!incomingIds.has(messageId)) { + // A removal can change any following chunk boundary, so safely rebuild this conversation. + changedAt = 0 + break + } + } + } + if (changedAt < 0) return { chunkCount: 0, updatedChunks: 0 } + + const rebuildStart = Math.max(0, changedAt - chunker.overlapMessages) + const boundaryTime = normalized[rebuildStart]?.createTime ?? 0 + this.database.exec('BEGIN IMMEDIATE') + try { + this.upsertState( + conversation.conversationId, + chunker.version, + 'indexing', + null, + normalized.length + ) + await this.writeMessageLedger( + conversation.conversationId, + normalized, + conversation.completeSnapshot, + signal, + onProgress + ) + + const staleChunks = asRows( + this.database + .prepare( + `SELECT rowid, text FROM knowledge_chunks + WHERE conversation_id = ? AND chunker_version = ? AND end_time >= ?` + ) + .all(conversation.conversationId, chunker.version, boundaryTime) + ) + for (let index = 0; index < staleChunks.length; index += 1) { + this.assertNotAborted(signal) + this.deleteFtsRow(Number(staleChunks[index].rowid), String(staleChunks[index].text)) + if (index % YIELD_EVERY === 0) await waitForWorkerTurn() + } + this.database + .prepare( + 'DELETE FROM knowledge_chunks WHERE conversation_id = ? AND chunker_version = ? AND end_time >= ?' + ) + .run(conversation.conversationId, chunker.version, boundaryTime) + + const chunks = chunkConversation(normalized, chunker).filter( + (chunk) => chunk.endTime >= boundaryTime + ) + const now = Date.now() + for (let index = 0; index < chunks.length; index += 1) { + this.assertNotAborted(signal) + this.upsertChunk(chunks[index], now) + if (index % YIELD_EVERY === 0) { + onProgress(normalized.length, index + 1) + await waitForWorkerTurn() + } + } + const highWater = normalized.length ? normalized[normalized.length - 1].createTime : null + this.upsertState( + conversation.conversationId, + chunker.version, + 'ready', + highWater, + normalized.length + ) + this.database.exec('COMMIT') + return { chunkCount: chunks.length, updatedChunks: chunks.length } + } catch (error) { + this.database.exec('ROLLBACK') + throw error + } + } + + private async writeMessageLedger( + conversationId: string, + messages: KnowledgeNormalizedMessage[], + completeSnapshot: boolean, + signal: AbortSignal | undefined, + onProgress: (processed: number, chunks: number) => void + ): Promise { + const upsert = this.database.prepare( + `INSERT INTO knowledge_messages ( + account_id, conversation_id, message_id, create_time, content_hash, searchable_text, + kind, sender_id, sender_name, attachment_json, voice_transcript + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(conversation_id, message_id) DO UPDATE SET + create_time = excluded.create_time, + content_hash = excluded.content_hash, + searchable_text = excluded.searchable_text, + kind = excluded.kind, + sender_id = excluded.sender_id, + sender_name = excluded.sender_name, + attachment_json = excluded.attachment_json, + voice_transcript = excluded.voice_transcript` + ) + for (let index = 0; index < messages.length; index += 1) { + this.assertNotAborted(signal) + const message = messages[index] + upsert.run( + this.accountId, + conversationId, + message.messageId, + message.createTime, + message.contentHash, + message.searchableText, + message.kind, + message.senderId ?? null, + message.senderName ?? null, + message.attachment ? encodedJson(message.attachment) : null, + message.voiceTranscript ?? null + ) + if (index % YIELD_EVERY === 0) { + onProgress(index + 1, 0) + await waitForWorkerTurn() + } + } + if (!completeSnapshot) return + const incoming = new Set(messages.map((message) => message.messageId)) + const existing = asRows( + this.database + .prepare('SELECT message_id FROM knowledge_messages WHERE conversation_id = ?') + .all(conversationId) + ) + const remove = this.database.prepare( + 'DELETE FROM knowledge_messages WHERE conversation_id = ? AND message_id = ?' + ) + for (const row of existing) { + const messageId = String(row.message_id) + if (!incoming.has(messageId)) remove.run(conversationId, messageId) + } + } + + private upsertChunk(chunk: KnowledgeChunk, now: number): void { + this.database + .prepare( + `INSERT INTO knowledge_chunks ( + chunk_id, account_id, conversation_id, start_time, end_time, text, message_ids_json, + participant_ids_json, message_kinds_json, content_hash, chunker_version, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(chunk_id) DO UPDATE SET + text = excluded.text, + message_ids_json = excluded.message_ids_json, + participant_ids_json = excluded.participant_ids_json, + message_kinds_json = excluded.message_kinds_json, + content_hash = excluded.content_hash, + updated_at = excluded.updated_at` + ) + .run( + chunk.chunkId, + this.accountId, + chunk.conversationId, + chunk.startTime, + chunk.endTime, + chunk.text, + encodedJson(chunk.messageIds), + encodedJson(chunk.participantIds), + encodedJson(chunk.messageKinds), + chunk.contentHash, + chunk.chunkerVersion, + now, + now + ) + const row = this.database + .prepare('SELECT rowid FROM knowledge_chunks WHERE chunk_id = ?') + .get(chunk.chunkId) as DbRow + this.insertFtsRow(Number(row.rowid), chunk.text) + } + + private insertFtsRow(rowid: number, text: string): void { + if (this.ftsExternalContent) { + this.database.prepare('INSERT INTO knowledge_fts(rowid, text) VALUES (?, ?)').run(rowid, text) + return + } + // FTS5 virtual tables do not implement SQLite UPSERT. Replace the row in + // two statements so the internal-content benchmark profile remains valid. + this.database.prepare('DELETE FROM knowledge_fts WHERE rowid = ?').run(rowid) + this.database.prepare('INSERT INTO knowledge_fts(rowid, text) VALUES (?, ?)').run(rowid, text) + } + + private deleteFtsRow(rowid: number, text: string): void { + if (this.ftsExternalContent) { + this.database + .prepare("INSERT INTO knowledge_fts(knowledge_fts, rowid, text) VALUES ('delete', ?, ?)") + .run(rowid, text) + return + } + this.database.prepare('DELETE FROM knowledge_fts WHERE rowid = ?').run(rowid) + } + + private setRunState(state: string, error: string | null = null): void { + this.writeMeta('run_state', state) + this.writeMeta('run_error', error || '') + this.writeMeta('updated_at', String(Date.now())) + } + + private upsertState( + conversationId: string, + chunkerVersion: string, + state: string, + highWater: number | null, + messageCount: number, + error: string | null = null + ): void { + this.database + .prepare( + `INSERT INTO knowledge_index_state ( + conversation_id, account_id, chunker_version, state, high_water_time, + indexed_message_count, last_error, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(conversation_id) DO UPDATE SET + account_id = excluded.account_id, + chunker_version = excluded.chunker_version, + state = excluded.state, + high_water_time = excluded.high_water_time, + indexed_message_count = excluded.indexed_message_count, + last_error = excluded.last_error, + updated_at = excluded.updated_at` + ) + .run( + conversationId, + this.accountId, + chunkerVersion, + state, + highWater, + messageCount, + error, + Date.now() + ) + } + + private readMeta(key: string): string | null { + const row = this.database.prepare('SELECT value FROM knowledge_meta WHERE key = ?').get(key) as + | DbRow + | undefined + return row ? String(row.value) : null + } + + private writeMetaIfMissing(key: string, value: string): void { + this.database + .prepare('INSERT INTO knowledge_meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING') + .run(key, value) + } + + private writeMeta(key: string, value: string): void { + this.database + .prepare( + 'INSERT INTO knowledge_meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ) + .run(key, value) + } + + private databaseBytes(): number { + return existsSync(this.databasePath) ? statSync(this.databasePath).size : 0 + } + + private walBytes(): number { + const path = `${this.databasePath}-wal` + return existsSync(path) ? statSync(path).size : 0 + } + + private assertNotAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw new DOMException('Knowledge indexing cancelled', 'AbortError') + } +} + +/** + * Pure, read-only capacity estimate. It intentionally does not construct a + * SQLite database or open the source archive, so callers can show a preflight + * before enabling a knowledge base. + */ +export async function estimateKnowledgeCapacityPreflight( + request: KnowledgeCapacityPreflightRequest +): Promise { + const sources = request.conversations.flatMap((conversation) => conversation.messages) + if (sources.some((message) => message.accountId !== request.accountId)) { + throw new Error('Knowledge preflight received messages from another account') + } + const normalized = sources.map(normalizeKnowledgeMessage) + const indexable = normalized.filter((message) => Boolean(message.searchableText)) + const sampleSize = Math.min(indexable.length, 2_000) + const stride = sampleSize ? Math.max(1, Math.floor(indexable.length / sampleSize)) : 1 + const sampled = indexable.filter((_, index) => index % stride === 0).slice(0, sampleSize) + const sampledByConversation = new Map() + for (const message of sampled) { + const existing = sampledByConversation.get(message.conversationId) || [] + existing.push(message) + sampledByConversation.set(message.conversationId, existing) + } + const sampledChunks = Array.from(sampledByConversation.values()).flatMap((messages) => + chunkConversation(messages, request.chunker) + ) + const averageMessagesPerChunk = sampledChunks.length + ? sampled.length / sampledChunks.length + : Math.max(1, request.chunker.maxMessages) + const estimatedChunkCount = Math.ceil(indexable.length / averageMessagesPerChunk) + const textBytes = indexable.reduce( + (total, message) => total + byteLength(message.searchableText), + 0 + ) + const estimatedDatabaseBytesLow = Math.max( + 4 * 1024 * 1024, + Math.ceil(textBytes * 1.2 + estimatedChunkCount * 360) + ) + const estimatedDatabaseBytesHigh = Math.max( + estimatedDatabaseBytesLow, + Math.ceil(textBytes * 3.2 + estimatedChunkCount * 980) + ) + const estimatedBuildPeakBytesLow = Math.ceil(estimatedDatabaseBytesLow * 1.5 + 1024 * 1024 * 1024) + const estimatedBuildPeakBytesHigh = Math.ceil(estimatedDatabaseBytesHigh * 2 + 1024 * 1024 * 1024) + const warnings: string[] = [] + if (!indexable.length) warnings.push('当前样本没有可索引的文本、附件元数据或语音转写') + let hasSufficientDiskSpace: boolean | undefined + if (request.availableDiskBytes !== undefined) { + hasSufficientDiskSpace = request.availableDiskBytes >= estimatedBuildPeakBytesHigh + if (!hasSufficientDiskSpace) warnings.push('可用磁盘空间低于保守建库峰值预估,建议暂缓建立索引') + } + return { + accountId: request.accountId, + sourceMessageCount: sources.length, + indexableMessageCount: indexable.length, + indexableTextBytes: textBytes, + voiceTranscriptCount: normalized.filter((message) => Boolean(message.voiceTranscript)).length, + attachmentMetadataCount: normalized.filter((message) => Boolean(message.attachment?.name)) + .length, + sampledChunkCount: sampledChunks.length, + estimatedChunkCount, + estimatedDatabaseBytesLow, + estimatedDatabaseBytesHigh, + estimatedBuildPeakBytesLow, + estimatedBuildPeakBytesHigh, + availableDiskBytes: request.availableDiskBytes, + hasSufficientDiskSpace, + warnings + } +} diff --git a/src/main/knowledge/knowledge-worker-host.ts b/src/main/knowledge/knowledge-worker-host.ts new file mode 100644 index 0000000..319a9d3 --- /dev/null +++ b/src/main/knowledge/knowledge-worker-host.ts @@ -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() + + constructor(private readonly workerPath: string) {} + + index( + payload: KnowledgeIndexRequest, + onProgress?: (progress: KnowledgeIndexProgress) => void + ): Promise { + return this.request('index', payload, onProgress) as Promise + } + + preflight(payload: KnowledgeCapacityPreflightRequest): Promise { + return this.request('preflight', payload) as Promise + } + + search(payload: KnowledgeSearchRequest): Promise { + return this.request('search', payload) as Promise + } + + status(payload: KnowledgeStatusRequest): Promise { + return this.request('status', payload) as Promise + } + + 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 { + 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 { + 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) + } +} diff --git a/src/main/knowledge/knowledge-worker.ts b/src/main/knowledge/knowledge-worker.ts new file mode 100644 index 0000000..d95a178 --- /dev/null +++ b/src/main/knowledge/knowledge-worker.ts @@ -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() +const controllers = new Map() + +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 +): 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 { + 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 { + const result = await estimateKnowledgeCapacityPreflight(payload) + send({ version: 1, type: 'result', requestId: request.requestId, payload: result }) +} + +async function handleSearch( + request: KnowledgeWorkerRequest, + payload: KnowledgeSearchRequest +): Promise { + 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 { + 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 { + 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) +}) diff --git a/src/main/knowledge/normalizer.ts b/src/main/knowledge/normalizer.ts new file mode 100644 index 0000000..0adc727 --- /dev/null +++ b/src/main/knowledge/normalizer.ts @@ -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()) +} diff --git a/src/main/knowledge/worker-protocol.ts b/src/main/knowledge/worker-protocol.ts new file mode 100644 index 0000000..173e8d3 --- /dev/null +++ b/src/main/knowledge/worker-protocol.ts @@ -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 diff --git a/src/main/services/ai-search-agent.ts b/src/main/services/ai-search-agent.ts new file mode 100644 index 0000000..5b753c5 --- /dev/null +++ b/src/main/services/ai-search-agent.ts @@ -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 } + | { action: 'finalize'; reason: string } + +export interface AgentToolResult { + summary: Record + 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 + execute: (action: Extract) => Promise + onTrace: (item: Omit) => void +} + +export interface ControlledSearchAgentResult { + status: 'finalized' | 'exhausted' | 'invalid' + toolCalls: number + reason: string +} + +const TOOL_NAMES = new Set([ + '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 + 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 + } + } + } 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 +): Record => { + const result: Record = {} + 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 { + 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: '已达到本次检索上限' } +} diff --git a/src/main/services/ai-search-evidence.ts b/src/main/services/ai-search-evidence.ts new file mode 100644 index 0000000..e9f8dd5 --- /dev/null +++ b/src/main/services/ai-search-evidence.ts @@ -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 +): 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 + lastMessageAt: number + evidenceIds: AiSearchFinalEvidence['id'][] + } + >() + const conversations = new Map< + string, + { + id: string + name: string + type: 'user' | 'group' + messageCount: number + people: Set + 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(), + 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(), + 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() + 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() + 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> +): CitationValidationResult { + const allowed = new Set(evidence.map((item) => item.id)) + const invalidCitationIds = new Set() + 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' + } +} diff --git a/src/main/services/ai-search-pipeline-service.ts b/src/main/services/ai-search-pipeline-service.ts new file mode 100644 index 0000000..c50853a --- /dev/null +++ b/src/main/services/ai-search-pipeline-service.ts @@ -0,0 +1,1240 @@ +import { + aiSearchIntentLabel, + aiSearchScopeLabel, + buildLocalAiSearchPlan, + inferAiSearchTimeRange, + mergeAiSearchPlans, + parseAiSearchPlan, + type AiSearchAgentRun, + type AiSearchAgentTraceItem, + type AiSearchAggregation, + type AiSearchFinalEvidence, + type AiSearchPipelineEvidence, + type AiSearchPipelineRequest, + type AiSearchPipelineResult, + type AiSearchPipelineTimings, + type AiSearchPlan, + type AiSearchRetrievalContract, + type AiSearchProgressEvent +} from '../../shared/ai-search' +import { emptyKnowledgeSearchTimings, type KnowledgeSearchIpcResult } from '../../shared/knowledge' +import type { Contact } from '../../shared/types' +import * as chat from './chat-service' +import { buildFinalEvidence, sanitizeAnswerCitations } from './ai-search-evidence' +import { runControlledSearchAgent, type AgentAction, type AgentToolResult } from './ai-search-agent' +import { AIProviderService } from './ai-provider-service' +import { KnowledgeSearchService } from '../knowledge/knowledge-search-service' +import { resolveContact, type ContactResolutionScope } from './contact-resolution-service' + +const DISPLAY_EVIDENCE_LIMIT = 8 +const AGENT_MESSAGE_LIMIT = 100 +const AGENT_SEARCH_LIMIT = 50 +const MAX_AGENT_CANDIDATES = 240 + +type AgentSearchOutcome = { + invalid?: boolean + candidateEvidence: AiSearchPipelineEvidence[] + searchResult: KnowledgeSearchIpcResult + plan: AiSearchPlan + agent: AiSearchAgentRun + searchTimings: ReturnType + knowledgeSearchMs: number +} + +const contactScopeForIntent = (intent: AiSearchPlan['intent']): ContactResolutionScope => + intent === 'conversation_name_search' ? 'group' : 'person' + +const isIdentityIntent = (intent: AiSearchPlan['intent']): boolean => + intent === 'conversation_recall' || + intent === 'conversation_topic_search' || + intent === 'conversation_name_search' + +const retrievalModeForIntent = ( + intent: AiSearchPlan['intent'] +): AiSearchRetrievalContract['retrievalMode'] => + intent === 'conversation_recall' + ? 'conversation_metadata' + : intent === 'conversation_topic_search' + ? 'conversation_topic_fts' + : intent === 'conversation_name_search' + ? 'conversation_name' + : intent === 'global_topic_search' + ? 'global_fts' + : 'global_fts' + +const contactLabel = (contact: Contact | undefined): string => + contact?.m_nsNickName || + contact?.remark || + contact?.wechatNickname || + contact?.m_nsUsrName || + '当前会话' + +const messageTime = (timestamp: number): string => + new Date(timestamp).toLocaleString('zh-CN', { hour12: false }) + +const estimateTokens = (value: string): number => Math.ceil(value.length / 2) + +const emptyAggregation = (): AiSearchAggregation => ({ + messageCount: 0, + peopleCount: 0, + conversationCount: 0, + people: [], + conversations: [] +}) + +const emptyTimings = (): AiSearchPipelineTimings => ({ + queryUnderstandingMs: 0, + contactResolutionMs: 0, + knowledgeSearchMs: 0, + workerIpcMs: 0, + workerBootMs: 0, + dispatchMs: 0, + workerSqlMs: 0, + responseSerializeMs: 0, + responseTransferMs: 0, + ftsMs: 0, + chunkExpandMs: 0, + messageLoadMs: 0, + rankingMs: 0, + candidateRankingMs: 0, + evidenceBuildMs: 0, + aggregationMs: 0, + contextPreparationMs: 0, + agentDecisionMs: 0, + agentToolMs: 0, + aiGenerationMs: 0, + totalMs: 0 +}) + +/** + * Main-process search orchestrator. It owns the only transition from raw + * candidates to Final Evidence; the AI and Renderer never receive a wider + * candidate context than the final program-generated citations. + */ +export class AiSearchPipelineService { + constructor( + private readonly knowledge: KnowledgeSearchService, + private readonly aiProvider: AIProviderService + ) {} + + async run( + request: AiSearchPipelineRequest, + publish: (event: AiSearchProgressEvent) => void + ): Promise { + const startedAt = Date.now() + const timings = emptyTimings() + let activeStage: AiSearchProgressEvent['stage'] = 'query_understanding' + const initialTimeRange = inferAiSearchTimeRange( + request.text, + request.range, + new Date(), + request.timeRangeOverride + ) + let plan: AiSearchPlan = { + ...buildLocalAiSearchPlan(request.text), + scopeLabel: aiSearchScopeLabel(request.scope), + timeRange: initialTimeRange, + rangeLabel: initialTimeRange.label, + contactNames: [] + } + const snapshotTimings = (): AiSearchPipelineTimings => ({ + ...timings, + totalMs: Date.now() - startedAt + }) + const emit = (event: Omit): void => + publish({ requestId: request.requestId, ...event }) + + try { + emit({ + stage: 'query_understanding', + status: 'running', + message: '正在理解你的问题' + }) + const queryUnderstandingStartedAt = Date.now() + const aiConfig = this.aiProvider.getRuntimeConfig() + const contactResolutionStartedAt = Date.now() + const contacts = chat.isReady() ? await chat.listContactsAsync() : [] + const selectedContact = request.conversationId + ? contacts.find((contact) => contact.md5 === request.conversationId) + : undefined + const sourceContacts = this.scopeContacts(contacts, request, selectedContact) + if (!sourceContacts.length) throw new Error('当前搜索范围没有可用会话') + const contactResolution = plan.contactQuery + ? resolveContact(plan.contactQuery, sourceContacts, contactScopeForIntent(plan.intent)) + : undefined + const resolvedContact = contactResolution?.matched + ? sourceContacts.find((contact) => contact.md5 === contactResolution.conversationId) + : selectedContact + plan = { + ...plan, + scopeLabel: aiSearchScopeLabel(request.scope, contactLabel(selectedContact)), + contactNames: resolvedContact ? [contactLabel(resolvedContact)] : [] + } + const conversationIds = + isIdentityIntent(plan.intent) && resolvedContact + ? [resolvedContact.md5] + : request.scope === 'global' + ? undefined + : sourceContacts.map((contact) => contact.md5) + timings.contactResolutionMs = Date.now() - contactResolutionStartedAt + + let agent: AiSearchAgentRun = { mode: 'fallback', toolCalls: 0, trace: [] } + let candidateEvidence: AiSearchPipelineEvidence[] + let searchResult: KnowledgeSearchIpcResult + const agentOutcome = aiConfig.configured + ? await this.runAgentSearch( + request, + plan, + contacts, + sourceContacts, + selectedContact, + resolvedContact, + (trace) => { + agent.trace.push(trace) + if (trace.event === 'agentDecision') timings.agentDecisionMs += trace.elapsedMs || 0 + if (trace.event === 'toolCallEnd') timings.agentToolMs += trace.elapsedMs || 0 + emit({ + stage: + trace.event === 'agentStart' + ? 'agent_start' + : trace.event === 'agentDecision' + ? 'agent_decision' + : 'agent_tool', + status: 'completed', + message: trace.label, + plan, + agentTrace: trace, + timings: snapshotTimings() + }) + } + ) + : null + + if (agentOutcome && !agentOutcome.invalid) { + plan = agentOutcome.plan + agent = agentOutcome.agent + candidateEvidence = agentOutcome.candidateEvidence + searchResult = agentOutcome.searchResult + timings.knowledgeSearchMs += agentOutcome.knowledgeSearchMs + timings.workerIpcMs += agentOutcome.searchTimings.workerIpcMs + timings.workerBootMs += agentOutcome.searchTimings.workerBootMs + timings.dispatchMs += agentOutcome.searchTimings.dispatchMs + timings.workerSqlMs += agentOutcome.searchTimings.workerSqlMs + timings.responseSerializeMs += agentOutcome.searchTimings.responseSerializeMs + timings.responseTransferMs += agentOutcome.searchTimings.responseTransferMs + timings.ftsMs += agentOutcome.searchTimings.ftsMs + timings.chunkExpandMs += agentOutcome.searchTimings.chunkExpandMs + timings.messageLoadMs += agentOutcome.searchTimings.messageLoadMs + timings.rankingMs += agentOutcome.searchTimings.rankingMs + } else { + const deterministicIdentityRetrieval = + isIdentityIntent(plan.intent) && Boolean(resolvedContact) + const unresolvedIdentity = isIdentityIntent(plan.intent) && !resolvedContact + const fallbackReason = deterministicIdentityRetrieval + ? '受控搜索 Agent 未返回有效控制指令,已按相同检索意图的本地确定性策略继续' + : unresolvedIdentity + ? '未能唯一确认目标联系人或群聊,未执行消息关键词搜索' + : aiConfig.configured + ? '受控搜索 Agent 暂时不可用,已改用原有检索方式' + : '尚未配置可用 AI 模型,已改用原有检索方式' + agent = { + mode: 'fallback', + toolCalls: agentOutcome?.agent.toolCalls || 0, + fallbackReason, + trace: [ + ...(agentOutcome?.agent.trace || []), + { + sequence: (agentOutcome?.agent.trace.length || 0) + 1, + event: 'fallback', + label: fallbackReason + } + ] + } + emit({ + stage: 'agent_start', + status: 'completed', + message: fallbackReason, + plan, + agentTrace: agent.trace[0], + timings: snapshotTimings() + }) + if (aiConfig.configured && !deterministicIdentityRetrieval && !unresolvedIdentity) { + const planningStartedAt = Date.now() + const planning = await this.aiProvider.chat([ + { + role: 'system', + content: + '你是本地聊天检索规划器,不回答用户问题。请从用户问题中提取用于本地数据库检索的主题词和同义短语,只输出 JSON:{"intent":"global_topic_search|general","keywords":["..."],"variants":["..."],"topicQuery":"..."}。不要编造人名或聊天内容;联系人身份和会话回顾由程序决定。' + }, + { role: 'user', content: `用户问题:${request.text}` } + ]) + timings.queryUnderstandingMs += Date.now() - planningStartedAt + if (planning.success && planning.data) { + plan = { + ...mergeAiSearchPlans( + buildLocalAiSearchPlan(request.text), + parseAiSearchPlan(planning.data) + ), + scopeLabel: plan.scopeLabel, + rangeLabel: plan.rangeLabel, + timeRange: plan.timeRange, + contactNames: resolvedContact ? [contactLabel(resolvedContact)] : [] + } + } + } + + activeStage = 'knowledge_searching' + emit({ + stage: 'knowledge_searching', + status: 'running', + message: '正在本地知识库中查找', + plan, + timings: snapshotTimings() + }) + if (unresolvedIdentity) { + searchResult = { + source: 'knowledge', + state: 'ready', + indexedMessageCount: 0, + indexedChunkCount: 0, + totalMessages: 0, + evidence: [], + timings: emptyKnowledgeSearchTimings() + } + candidateEvidence = [] + } else { + const knowledgeSearchStartedAt = Date.now() + const deterministicTerms = + plan.intent === 'conversation_recall' || plan.intent === 'conversation_name_search' + ? [] + : plan.intent === 'conversation_topic_search' + ? plan.topicQuery + ? [plan.topicQuery] + : [] + : Array.from(new Set([...plan.keywords, ...plan.variants])) + searchResult = await this.knowledge.search({ + text: request.text, + terms: deterministicTerms, + conversationIds, + startTime: plan.timeRange.startTime, + endTime: plan.timeRange.endTime, + limit: 240 + }) + timings.knowledgeSearchMs += Date.now() - knowledgeSearchStartedAt + candidateEvidence = this.toPipelineEvidence(searchResult, contacts) + } + } + timings.queryUnderstandingMs += + Date.now() - + queryUnderstandingStartedAt - + timings.contactResolutionMs - + timings.agentDecisionMs - + timings.agentToolMs + timings.queryUnderstandingMs = Math.max(0, timings.queryUnderstandingMs) + + emit({ + stage: 'query_understanding', + status: 'completed', + message: '已理解搜索条件', + plan, + timings: snapshotTimings() + }) + activeStage = 'search_plan_ready' + emit({ + stage: 'search_plan_ready', + status: 'completed', + message: `将${aiSearchIntentLabel(plan.intent)}`, + plan, + timings: snapshotTimings() + }) + + const knowledgeTimings = searchResult.timings || emptyKnowledgeSearchTimings() + if (!agentOutcome) { + timings.workerIpcMs = knowledgeTimings.workerIpcMs + timings.workerBootMs = knowledgeTimings.workerBootMs + timings.dispatchMs = knowledgeTimings.dispatchMs + timings.workerSqlMs = knowledgeTimings.workerSqlMs + timings.responseSerializeMs = knowledgeTimings.responseSerializeMs + timings.responseTransferMs = knowledgeTimings.responseTransferMs + timings.ftsMs = knowledgeTimings.ftsMs + timings.chunkExpandMs = knowledgeTimings.chunkExpandMs + timings.messageLoadMs = knowledgeTimings.messageLoadMs + timings.rankingMs = knowledgeTimings.rankingMs + } + + const usedKnowledge = searchResult.source === 'knowledge' + const knowledgeMessageCount = usedKnowledge ? searchResult.indexedMessageCount : undefined + const searchMessage = usedKnowledge + ? candidateEvidence.length + ? `找到 ${candidateEvidence.length} 条相关消息` + : '没有找到相关消息' + : searchResult.fallbackReason === 'error' + ? '本地知识库暂时不可用,已改用聊天记录查找' + : '本地知识库尚未就绪,已改用聊天记录查找' + emit({ + stage: 'knowledge_searching', + status: 'completed', + message: searchMessage, + plan, + stats: { + knowledgeMessageCount, + matchedMessages: candidateEvidence.length, + elapsedMs: timings.knowledgeSearchMs + }, + timings: snapshotTimings() + }) + + let retrieval = this.buildRetrievalContract( + plan, + resolvedContact, + searchResult, + candidateEvidence, + agent + ) + if (retrieval.suspicious && resolvedContact) { + // An identity route that somehow yielded 0/1 records is never allowed + // to masquerade as a complete chat recap. Retry the safe metadata read. + const retryStartedAt = Date.now() + searchResult = await this.knowledge.search({ + text: request.text, + terms: [], + conversationIds: [resolvedContact.md5], + startTime: plan.timeRange.startTime, + endTime: plan.timeRange.endTime, + limit: 240 + }) + timings.knowledgeSearchMs += Date.now() - retryStartedAt + candidateEvidence = this.toPipelineEvidence(searchResult, contacts) + retrieval = this.buildRetrievalContract( + plan, + resolvedContact, + searchResult, + candidateEvidence, + agent + ) + } + + activeStage = 'evidence_ranking' + emit({ + stage: 'evidence_ranking', + status: 'running', + message: '正在整理最相关的原始消息', + plan, + stats: { knowledgeMessageCount, matchedMessages: candidateEvidence.length }, + timings: snapshotTimings() + }) + const evidenceBuild = buildFinalEvidence(candidateEvidence, DISPLAY_EVIDENCE_LIMIT, { + strategy: plan.intent === 'conversation_recall' ? 'conversation_coverage' : 'ranked' + }) + const evidence = evidenceBuild.evidence + timings.candidateRankingMs = evidenceBuild.candidateRankingMs + timings.evidenceBuildMs = evidenceBuild.evidenceBuildMs + timings.aggregationMs = evidenceBuild.aggregationMs + const evidenceTrace: AiSearchAgentTraceItem = { + sequence: agent.trace.length + 1, + event: 'evidenceBuild', + label: '已从候选消息整理可引用证据', + resultCount: evidence.length, + elapsedMs: + evidenceBuild.candidateRankingMs + + evidenceBuild.evidenceBuildMs + + evidenceBuild.aggregationMs + } + agent.trace.push(evidenceTrace) + emit({ + stage: 'evidence_ranking', + status: 'completed', + message: `已从 ${evidenceBuild.candidateCount} 条候选消息整理出 ${evidence.length} 条可引用证据`, + plan, + stats: { + knowledgeMessageCount, + matchedMessages: evidenceBuild.candidateCount, + evidenceCount: evidence.length, + contextEvidenceCount: evidence.length, + deduplicatedMessages: evidenceBuild.deduplicatedCount + }, + agentTrace: evidenceTrace, + timings: snapshotTimings() + }) + activeStage = 'evidence_ready' + emit({ + stage: 'evidence_ready', + status: 'completed', + message: evidence.length ? '已保留可跳转的原始消息引用' : '没有可引用的原始消息', + plan, + stats: { + knowledgeMessageCount, + matchedMessages: evidenceBuild.candidateCount, + evidenceCount: evidence.length, + contextEvidenceCount: evidence.length, + deduplicatedMessages: evidenceBuild.deduplicatedCount + }, + timings: snapshotTimings() + }) + + activeStage = 'aggregation' + emit({ + stage: 'aggregation', + status: 'running', + message: '正在按人物和会话整理证据', + plan, + stats: { evidenceCount: evidence.length }, + timings: snapshotTimings() + }) + emit({ + stage: 'aggregation', + status: 'completed', + message: `已整理 ${evidenceBuild.aggregation.peopleCount} 人、${evidenceBuild.aggregation.conversationCount} 个会话`, + plan, + stats: { + evidenceCount: evidence.length, + peopleCount: evidenceBuild.aggregation.peopleCount, + conversationCount: evidenceBuild.aggregation.conversationCount + }, + timings: snapshotTimings() + }) + + const baseResult = { + requestId: request.requestId, + plan, + agent, + knowledge: { + source: searchResult.source, + state: searchResult.state, + fallbackReason: searchResult.fallbackReason, + indexedMessageCount: searchResult.indexedMessageCount, + indexedChunkCount: searchResult.indexedChunkCount, + totalMessages: searchResult.totalMessages + }, + candidateEvidenceCount: evidenceBuild.candidateCount, + retrieval, + evidence, + contextEvidenceCount: evidence.length, + aggregation: evidenceBuild.aggregation, + timings: snapshotTimings(), + elapsedMs: Date.now() - startedAt + } + if (!evidence.length) { + emit({ + stage: 'completed', + status: 'completed', + message: '搜索完成,当前条件下没有找到相关消息', + plan, + stats: { knowledgeMessageCount, matchedMessages: 0, evidenceCount: 0 }, + timings: snapshotTimings() + }) + return { + ...baseResult, + status: 'no_evidence', + timings: snapshotTimings(), + elapsedMs: Date.now() - startedAt + } + } + + if (retrieval.suspicious) { + const error = '已找到目标会话,但当前检索未完整覆盖聊天记录,未生成总结。' + emit({ + stage: 'completed', + status: 'completed', + message: error, + plan, + stats: { + knowledgeMessageCount, + matchedMessages: evidenceBuild.candidateCount, + evidenceCount: evidence.length + }, + timings: snapshotTimings(), + error + }) + return { + ...baseResult, + status: 'retrieval_incomplete', + error, + timings: snapshotTimings(), + elapsedMs: Date.now() - startedAt + } + } + + activeStage = 'ai_generating' + const summaryTrace: AiSearchAgentTraceItem = { + sequence: agent.trace.length + 1, + event: 'summaryStart', + label: '开始生成带来源的回答' + } + agent.trace.push(summaryTrace) + const contextPreparationStartedAt = Date.now() + const prompt = this.answerPrompt( + request.text, + plan, + searchResult.totalMessages, + evidence, + evidenceBuild.aggregation, + retrieval + ) + const tokenEstimate = estimateTokens(prompt) + timings.contextPreparationMs = Date.now() - contextPreparationStartedAt + if (!aiConfig.configured) { + const error = '尚未配置可用 AI 模型' + emit({ + stage: 'ai_generating', + status: 'error', + message: '证据已找到,但无法生成回答', + plan, + stats: { + matchedMessages: evidenceBuild.candidateCount, + evidenceCount: evidence.length, + contextEvidenceCount: evidence.length, + tokenEstimate + }, + timings: snapshotTimings(), + error + }) + return { + ...baseResult, + status: 'ai_failed', + error, + errorStage: 'ai_generating', + timings: snapshotTimings(), + elapsedMs: Date.now() - startedAt + } + } + emit({ + stage: 'ai_generating', + status: 'running', + message: '正在生成带来源的回答', + plan, + modelName: aiConfig.modelName, + agentTrace: summaryTrace, + stats: { + matchedMessages: evidenceBuild.candidateCount, + evidenceCount: evidence.length, + contextEvidenceCount: evidence.length, + tokenEstimate + }, + timings: snapshotTimings() + }) + const aiGenerationStartedAt = Date.now() + const answer = await this.aiProvider.chat([ + { + role: 'system', + content: + '你是 WechatExplorer 的本地聊天记录分析助手。只能基于提供的程序化事实和 Evidence 回答,不得编造事实。请用中文回答,先给出简短摘要,再列出关键主题、结论和不确定性。引用关键事实时,只能使用 Evidence 原文中存在的 [E#],不要创建、猜测或改写 Evidence ID。对人物问题只能描述聊天中的发言主题和可能角色,不做人格或敏感属性判断。' + }, + { role: 'user', content: prompt } + ]) + timings.aiGenerationMs = Date.now() - aiGenerationStartedAt + if (!answer.success || !answer.data) { + const error = answer.error || 'AI 没有返回可用回答' + emit({ + stage: 'ai_generating', + status: 'error', + message: '证据已找到,但 AI 暂时无法生成回答', + plan, + modelName: aiConfig.modelName, + stats: { + matchedMessages: evidenceBuild.candidateCount, + evidenceCount: evidence.length, + contextEvidenceCount: evidence.length, + tokenEstimate + }, + timings: snapshotTimings(), + error + }) + return { + ...baseResult, + status: 'ai_failed', + error, + errorStage: 'ai_generating', + timings: snapshotTimings(), + elapsedMs: Date.now() - startedAt + } + } + + const citationValidation = sanitizeAnswerCitations(answer.data, evidence) + const summaryEndTrace: AiSearchAgentTraceItem = { + sequence: agent.trace.length + 1, + event: 'summaryEnd', + label: '已生成带来源的回答', + elapsedMs: timings.aiGenerationMs + } + agent.trace.push(summaryEndTrace) + const inputTokens = answer.usage?.input || tokenEstimate + const inputTokensEstimated = !answer.usage?.input || Boolean(answer.usage?.estimated) + const ai = { + providerName: aiConfig.providerName, + modelName: aiConfig.modelName, + inputTokens, + inputTokensEstimated + } + const completedMessage = citationValidation.invalidCitationIds.length + ? '已生成回答;已移除无法对应原始消息的引用' + : '已生成带来源的回答' + emit({ + stage: 'ai_generating', + status: 'completed', + message: completedMessage, + plan, + modelName: aiConfig.modelName, + stats: { + knowledgeMessageCount, + matchedMessages: evidenceBuild.candidateCount, + evidenceCount: evidence.length, + contextEvidenceCount: evidence.length, + inputTokens, + inputTokensEstimated, + peopleCount: evidenceBuild.aggregation.peopleCount, + conversationCount: evidenceBuild.aggregation.conversationCount, + elapsedMs: timings.aiGenerationMs + }, + timings: snapshotTimings(), + agentTrace: summaryEndTrace + }) + emit({ + stage: 'completed', + status: 'completed', + message: '已完成', + plan, + stats: { + knowledgeMessageCount, + matchedMessages: evidenceBuild.candidateCount, + evidenceCount: evidence.length, + contextEvidenceCount: evidence.length, + inputTokens, + inputTokensEstimated, + peopleCount: evidenceBuild.aggregation.peopleCount, + conversationCount: evidenceBuild.aggregation.conversationCount, + elapsedMs: Date.now() - startedAt + }, + timings: snapshotTimings(), + modelName: aiConfig.modelName + }) + return { + ...baseResult, + status: 'completed', + answer: citationValidation.answer, + ai, + citationValidation: { + status: citationValidation.status, + invalidCitationIds: citationValidation.invalidCitationIds + }, + timings: snapshotTimings(), + elapsedMs: Date.now() - startedAt + } + } catch (caught) { + const error = caught instanceof Error ? caught.message : '搜索过程发生未知错误' + emit({ + stage: 'error', + status: 'error', + message: this.errorMessage(activeStage), + plan, + timings: snapshotTimings(), + error + }) + return { + requestId: request.requestId, + status: 'failed', + plan, + knowledge: { + source: 'fallback', + state: 'unavailable', + indexedMessageCount: 0, + indexedChunkCount: 0, + totalMessages: 0 + }, + candidateEvidenceCount: 0, + evidence: [], + contextEvidenceCount: 0, + retrieval: { + intent: plan.intent, + timeRange: plan.timeRange, + retrievalMode: 'global_fts', + candidateCount: 0, + sourceCoverage: 'unknown', + isComplete: false, + fallbackUsed: true, + suspicious: false + }, + aggregation: emptyAggregation(), + agent: { mode: 'fallback', toolCalls: 0, trace: [] }, + timings: snapshotTimings(), + error, + errorStage: activeStage, + elapsedMs: Date.now() - startedAt + } + } + } + + private scopeContacts( + contacts: Contact[], + request: AiSearchPipelineRequest, + selectedContact: Contact | undefined + ): Contact[] { + if (request.scope === 'groups') return contacts.filter((contact) => contact.type === 'group') + if (request.scope === 'contacts') return contacts.filter((contact) => contact.type !== 'group') + if (request.scope === 'conversation') return selectedContact ? [selectedContact] : [] + return contacts + } + + private toPipelineEvidence( + result: KnowledgeSearchIpcResult, + contacts: Contact[] + ): AiSearchPipelineEvidence[] { + const contactsById = new Map(contacts.map((contact) => [contact.md5, contact])) + return result.evidence.map((item): AiSearchPipelineEvidence => { + const contact = contactsById.get(item.conversationId) + return { + ...item, + conversationName: contactLabel(contact), + conversationType: + contact?.type || (item.conversationId.endsWith('@chatroom') ? 'group' : 'user') + } + }) + } + + /** + * The model never receives source IDs. It only receives per-request refs + * created from Tool results; every ref is checked again before a read. + */ + private async runAgentSearch( + request: AiSearchPipelineRequest, + initialPlan: AiSearchPlan, + contacts: Contact[], + sourceContacts: Contact[], + selectedContact: Contact | undefined, + resolvedContact: Contact | undefined, + onTrace: (item: AiSearchAgentTraceItem) => void + ): Promise { + const contactsInScope = new Map(sourceContacts.map((contact) => [contact.md5, contact])) + const conversationRefs = new Map() + const refsByConversation = new Map() + const messageRefs = new Map() + const candidates: AiSearchPipelineEvidence[] = [] + const trace: AiSearchAgentTraceItem[] = [] + let traceSequence = 0 + let lastSearchResult: KnowledgeSearchIpcResult = { + source: 'knowledge', + state: 'unavailable', + indexedMessageCount: 0, + indexedChunkCount: 0, + totalMessages: 0, + evidence: [], + timings: emptyKnowledgeSearchTimings() + } + let plan = initialPlan + const searchTimings = emptyKnowledgeSearchTimings() + let knowledgeSearchMs = 0 + + const recordTrace = (item: Omit): void => { + const next = { sequence: ++traceSequence, ...item } + trace.push(next) + onTrace(next) + } + const addConversationRef = (contact: Contact): string => { + const existing = refsByConversation.get(contact.md5) + if (existing) return existing + const ref = `conversation-${conversationRefs.size + 1}` + refsByConversation.set(contact.md5, ref) + conversationRefs.set(ref, contact) + return ref + } + if (selectedContact && contactsInScope.has(selectedContact.md5)) + addConversationRef(selectedContact) + if (resolvedContact && contactsInScope.has(resolvedContact.md5)) + addConversationRef(resolvedContact) + + const boundedQuery = (value: unknown): string => { + if (typeof value !== 'string') throw new Error('查询内容无效') + const query = value.trim() + if (query.length < 2 || query.length > 64) throw new Error('查询内容长度不符合限制') + return query + } + const boundedLimit = (value: unknown, fallback: number, maximum: number): number => { + if (value === undefined) return fallback + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > maximum) { + throw new Error('读取数量不符合限制') + } + return value + } + const resolveConversation = (value: unknown): Contact => { + if (typeof value !== 'string') throw new Error('必须先通过会话搜索取得目标') + const contact = conversationRefs.get(value) + if (!contact || !contactsInScope.has(contact.md5)) + throw new Error('目标会话不在本次允许范围内') + return contact + } + const matchingContacts = (query: string, peopleOnly: boolean): Contact[] => { + const result = resolveContact(query, sourceContacts, peopleOnly ? 'person' : 'group') + if (!result.matched || !result.conversationId || result.ambiguous) return [] + const contact = sourceContacts.find((item) => item.md5 === result.conversationId) + return contact ? [contact] : [] + } + const rejectForbiddenAction = (action: Extract): void => { + const contactBound = Boolean(resolvedContact || selectedContact) + const requiresConversationRef = + action.tool === 'get_conversation_messages' || + action.tool === 'get_message_context' || + (action.tool === 'search_messages' && plan.intent === 'conversation_topic_search') + if (requiresConversationRef && typeof action.arguments.conversationRef !== 'string') { + throw new Error('当前检索意图要求先确认目标会话') + } + if (plan.intent === 'conversation_recall') { + if (action.tool !== 'search_people' && action.tool !== 'get_conversation_messages') { + throw new Error('联系人回顾只允许定位联系人后读取该会话消息') + } + if (action.tool === 'search_people' && contactBound && plan.contactQuery) { + const actionResolution = resolveContact( + action.arguments.query as string, + sourceContacts, + 'person' + ) + if ( + !actionResolution.matched || + actionResolution.conversationId !== resolvedContact?.md5 + ) { + throw new Error('联系人回顾只能使用已解析的目标联系人') + } + } + } + if (plan.intent === 'conversation_topic_search') { + if (action.tool !== 'search_people' && action.tool !== 'search_messages') { + throw new Error('联系人话题查询只允许定位联系人后在该会话内查找话题') + } + if ( + action.tool === 'search_messages' && + typeof action.arguments.conversationRef !== 'string' + ) { + throw new Error('联系人话题查询不能执行全局消息搜索') + } + } + if (plan.intent === 'global_topic_search' && action.tool !== 'search_messages') { + throw new Error('全局话题查询只允许查找消息内容') + } + if (plan.intent === 'conversation_name_search') { + if (action.tool !== 'search_conversations' && action.tool !== 'get_conversation_messages') { + throw new Error('聊天名称查询只允许定位聊天后读取该会话消息') + } + } + } + const addSearchCandidates = (result: KnowledgeSearchIpcResult): AiSearchPipelineEvidence[] => { + lastSearchResult = result + const evidence = this.toPipelineEvidence(result, contacts) + evidence.forEach((item) => { + if (candidates.length < MAX_AGENT_CANDIDATES) candidates.push(item) + const key = `${item.conversationId}\u0000${item.messageId}` + if ( + !Array.from(messageRefs.values()).some( + (value) => `${value.conversationId}\u0000${value.messageId}` === key + ) + ) { + messageRefs.set(`message-${messageRefs.size + 1}`, item) + } + }) + return evidence + } + const summarizeMessages = ( + evidence: AiSearchPipelineEvidence[] + ): Array> => + evidence.slice(0, 12).map((item) => { + const messageRef = Array.from(messageRefs.entries()).find( + ([, value]) => + value.conversationId === item.conversationId && value.messageId === item.messageId + )?.[0] + const conversationRef = refsByConversation.get(item.conversationId) + return { + messageRef: messageRef || '', + conversationRef: conversationRef || '', + sender: item.sender, + time: messageTime(item.timestamp), + preview: item.text.replace(/\s+/g, ' ').slice(0, 180) + } + }) + const search = async ( + terms: string[], + conversationIds: string[] | undefined, + limit: number, + startTime = initialPlan.timeRange.startTime, + endTime?: number + ): Promise => { + const startedAt = Date.now() + const result = await this.knowledge.search({ + text: request.text, + terms, + conversationIds, + startTime, + endTime, + limit + }) + knowledgeSearchMs += Date.now() - startedAt + const resultTimings = result.timings || emptyKnowledgeSearchTimings() + // A previously running Worker may return an older timing shape during a + // desktop hot reload. Missing diagnostic fields must remain zero rather + // than turning the whole search trace into NaN. + searchTimings.workerIpcMs += resultTimings.workerIpcMs || 0 + searchTimings.workerBootMs += resultTimings.workerBootMs || 0 + searchTimings.dispatchMs += resultTimings.dispatchMs || 0 + searchTimings.workerSqlMs += resultTimings.workerSqlMs || 0 + searchTimings.responseSerializeMs += resultTimings.responseSerializeMs || 0 + searchTimings.responseTransferMs += resultTimings.responseTransferMs || 0 + searchTimings.ftsMs += resultTimings.ftsMs || 0 + searchTimings.chunkExpandMs += resultTimings.chunkExpandMs || 0 + searchTimings.messageLoadMs += resultTimings.messageLoadMs || 0 + searchTimings.rankingMs += resultTimings.rankingMs || 0 + searchTimings.totalMs += resultTimings.totalMs || 0 + return addSearchCandidates(result) + } + const execute = async ( + action: Extract + ): Promise => { + rejectForbiddenAction(action) + if (action.tool === 'search_people' || action.tool === 'search_conversations') { + const query = boundedQuery(action.arguments.query) + const limit = boundedLimit(action.arguments.limit, 10, 20) + const peopleOnly = action.tool === 'search_people' + const results = matchingContacts(query, peopleOnly) + .slice(0, limit) + .map((contact) => ({ + conversationRef: addConversationRef(contact), + name: contactLabel(contact), + type: contact.type, + matchReason: + contactLabel(contact).toLocaleLowerCase() === query.toLocaleLowerCase() + ? '名称匹配' + : '名称相近' + })) + if (results.length && peopleOnly) { + plan = { ...plan, contactNames: results.map((result) => result.name) } + } + return { summary: { total: results.length, results }, candidateCount: results.length } + } + + if (action.tool === 'search_messages') { + const query = boundedQuery(action.arguments.query) + const limit = boundedLimit(action.arguments.limit, AGENT_SEARCH_LIMIT, AGENT_SEARCH_LIMIT) + const contact = action.arguments.conversationRef + ? resolveConversation(action.arguments.conversationRef) + : undefined + const evidence = await search( + [query], + contact ? [contact.md5] : sourceContacts.map((item) => item.md5), + limit + ) + plan = { + ...plan, + keywords: [query], + variants: [], + intent: + plan.intent === 'conversation_topic_search' || contact + ? 'conversation_topic_search' + : 'global_topic_search', + source: 'ai' + } + return { + summary: { total: evidence.length, messages: summarizeMessages(evidence) }, + candidateCount: evidence.length + } + } + + if (action.tool === 'get_conversation_messages' || action.tool === 'get_messages_by_time') { + const limit = boundedLimit(action.arguments.limit, AGENT_MESSAGE_LIMIT, AGENT_MESSAGE_LIMIT) + const contact = action.arguments.conversationRef + ? resolveConversation(action.arguments.conversationRef) + : undefined + const startTime = + typeof action.arguments.startTime === 'number' && + Number.isInteger(action.arguments.startTime) + ? action.arguments.startTime + : initialPlan.timeRange.startTime + const endTime = + typeof action.arguments.endTime === 'number' && Number.isInteger(action.arguments.endTime) + ? action.arguments.endTime + : undefined + const minimumStartTime = initialPlan.timeRange.startTime + const now = Math.floor(Date.now() / 1000) + if ( + (startTime !== undefined && (startTime < (minimumStartTime || 0) || startTime > now)) || + (endTime !== undefined && (endTime > now || endTime < (minimumStartTime || 0))) || + (endTime !== undefined && startTime !== undefined && endTime < startTime) + ) { + throw new Error('时间范围无效') + } + if (action.tool === 'get_conversation_messages' && !contact) { + throw new Error('读取会话消息前必须先定位会话') + } + const evidence = await search( + [], + contact ? [contact.md5] : sourceContacts.map((item) => item.md5), + limit, + startTime, + endTime + ) + const retrieval = lastSearchResult.conversationRetrieval + return { + summary: { + totalMessages: retrieval?.totalMessages || evidence.length, + chunks: retrieval?.chunkCount, + candidateMessages: retrieval?.candidateMessages || evidence.length, + systemMessagesDeprioritized: retrieval?.systemMessagesDeprioritized || 0, + truncated: retrieval ? !retrieval.complete : false, + messages: summarizeMessages(evidence) + }, + candidateCount: evidence.length, + finalizeReason: + action.tool === 'get_conversation_messages' && + plan.intent === 'conversation_recall' && + retrieval?.complete + ? `已覆盖所选时间范围内 ${retrieval.totalMessages} 条消息,并整理为 ${retrieval.chunkCount} 个本地对话片段` + : undefined + } + } + + const messageRef = action.arguments.messageRef + if (typeof messageRef !== 'string') throw new Error('必须先通过消息检索取得上下文目标') + const target = messageRefs.get(messageRef) + if (!target || !contactsInScope.has(target.conversationId)) + throw new Error('上下文目标不在本次允许范围内') + const evidence = await search( + [], + [target.conversationId], + boundedLimit(action.arguments.limit, 30, 50), + Math.max(0, Math.floor(target.timestamp / 1000) - 15 * 60), + Math.floor(target.timestamp / 1000) + 15 * 60 + ) + return { + summary: { total: evidence.length, messages: summarizeMessages(evidence) }, + candidateCount: evidence.length + } + } + + const outcome = await runControlledSearchAgent({ + question: request.text, + scopeLabel: initialPlan.scopeLabel, + rangeLabel: initialPlan.rangeLabel, + maxToolCalls: initialPlan.intent === 'conversation_recall' ? 2 : undefined, + decide: async (prompt) => { + const response = await this.aiProvider.chat([ + { role: 'system', content: prompt }, + { role: 'user', content: '请输出下一步受控检索 JSON。' } + ]) + return response.success ? response.data : undefined + }, + execute, + onTrace: recordTrace + }) + if (outcome.status === 'invalid') { + return { + invalid: true, + candidateEvidence: candidates, + searchResult: lastSearchResult, + plan, + agent: { + mode: 'fallback', + toolCalls: outcome.toolCalls, + trace, + fallbackReason: outcome.reason + }, + searchTimings, + knowledgeSearchMs + } + } + return { + candidateEvidence: candidates, + searchResult: lastSearchResult, + plan, + agent: { mode: 'agent', toolCalls: outcome.toolCalls, trace }, + searchTimings, + knowledgeSearchMs + } + } + + private answerPrompt( + query: string, + plan: AiSearchPlan, + totalMessages: number, + evidence: AiSearchFinalEvidence[], + aggregation: AiSearchAggregation, + retrieval: AiSearchRetrievalContract + ): string { + const context = evidence + .map( + (item) => + `[${item.id}]\nconversationId: ${item.conversationId}\nmessageId: ${item.messageId}\nsender: ${item.sender}\ntimestamp: ${messageTime(item.timestamp)}\ncontent: ${item.text}` + ) + .join('\n\n') + const people = aggregation.people + .map( + (person) => + `- ${person.name}:${person.messageCount} 条,${person.conversationCount} 个会话,最近 ${messageTime(person.lastMessageAt)},Evidence ${person.evidenceIds.join('、')}` + ) + .join('\n') + const conversations = aggregation.conversations + .map( + (conversation) => + `- ${conversation.name}:${conversation.messageCount} 条,${conversation.peopleCount} 人,Evidence ${conversation.evidenceIds.join('、')}` + ) + .join('\n') + return `检索范围:${plan.scopeLabel},时间:${plan.rangeLabel} +用户问题:${query} +检索意图:${aiSearchIntentLabel(plan.intent)} +检索关键词:${plan.keywords.join('、') || '未提取到主题关键词'} +检索范围消息总数:${totalMessages} +程序已确认的事实:最终 Evidence ${aggregation.messageCount} 条,涉及 ${aggregation.peopleCount} 人、${aggregation.conversationCount} 个会话。 +检索覆盖:来源消息 ${retrieval.sourceMessageCount ?? '未知'} 条;候选 ${retrieval.candidateCount} 条;覆盖状态 ${retrieval.sourceCoverage};完整=${retrieval.isComplete}。候选数不等于真实聊天总数,不能据此推断用户只聊了这些消息。 +${plan.intent === 'global_topic_search' ? `这是“按人物查找”问题。优先按以下人物统计作答,不要自行统计人数、会话数或消息数:\n${people || '无'}\n会话统计:\n${conversations || '无'}\n` : ''}以下是唯一允许引用的 Final Evidence。只能引用它们原样给出的 ID;不能使用其他编号: +${context}` + } + + private buildRetrievalContract( + plan: AiSearchPlan, + resolvedContact: Contact | undefined, + result: KnowledgeSearchIpcResult, + candidates: AiSearchPipelineEvidence[], + agent: AiSearchAgentRun + ): AiSearchRetrievalContract { + const identity = isIdentityIntent(plan.intent) + const conversationRetrieval = result.conversationRetrieval + const sourceMessageCount = + conversationRetrieval?.totalMessages ?? + (identity && resolvedContact ? result.totalMessages : undefined) + const sourceCoverage = identity + ? conversationRetrieval?.complete || + (result.source === 'fallback' && Boolean(resolvedContact)) + ? 'complete' + : sourceMessageCount !== undefined + ? 'partial' + : 'unknown' + : plan.intent === 'global_topic_search' || plan.intent === 'conversation_topic_search' + ? 'keyword_match' + : 'unknown' + const isComplete = sourceCoverage === 'complete' + return { + intent: plan.intent, + conversationId: resolvedContact?.md5, + timeRange: plan.timeRange, + retrievalMode: resolvedContact + ? retrievalModeForIntent(plan.intent) + : identity + ? 'unresolved_identity' + : retrievalModeForIntent(plan.intent), + candidateCount: candidates.length, + sourceMessageCount, + sourceCoverage, + isComplete, + fallbackUsed: agent.mode === 'fallback' || result.source === 'fallback', + fallbackReason: agent.fallbackReason || result.fallbackReason, + suspicious: + plan.intent === 'conversation_recall' && + Boolean(resolvedContact) && + Boolean(sourceMessageCount && sourceMessageCount > 1) && + candidates.length <= 1 + } + } + + private errorMessage(stage: AiSearchProgressEvent['stage']): string { + if (stage === 'query_understanding' || stage === 'search_plan_ready') return '无法理解搜索条件' + if (stage === 'knowledge_searching') return '本地知识库暂时无法搜索' + if (stage === 'evidence_ranking' || stage === 'evidence_ready' || stage === 'aggregation') + return '无法整理原始消息证据' + if (stage === 'ai_generating') return '证据已找到,但 AI 暂时无法生成回答' + return '搜索暂时无法完成' + } +} diff --git a/src/main/services/cache-service.ts b/src/main/services/cache-service.ts index be2de12..337607a 100644 --- a/src/main/services/cache-service.ts +++ b/src/main/services/cache-service.ts @@ -7,6 +7,11 @@ import type { CacheClearScope, CacheSummary, CacheSummaryItem } from '../../shar export type { CacheClearScope } from '../../shared/cache' 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 +} function inspectDirectory(directory: string): { sizeBytes: number; fileCount: number } { 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 { const bootstrap = inspectDirectory(BOOTSTRAP_CACHE_DIR) const electron = inspectDirectory(path.join(app.getPath('userData'), 'Cache')) + const knowledge = inspectDirectory(KNOWLEDGE_CACHE_DIR) const items: CacheSummaryItem[] = [ { id: 'bootstrap', @@ -52,6 +58,12 @@ export function getCacheSummary(): CacheSummary { label: '应用临时缓存', description: 'Electron 页面资源缓存,清理后会自动重新生成。', ...electron + }, + { + id: 'knowledge', + label: '本地知识库索引', + description: '为问问微信建立的所有账号本地检索索引。清理后需手动重新建立,不影响微信原始数据。', + ...knowledge } ] return { @@ -61,7 +73,10 @@ export function getCacheSummary(): CacheSummary { } } -export async function clearCache(scope: CacheClearScope): Promise { +export async function clearCache( + scope: CacheClearScope, + options: CacheClearOptions = {} +): Promise { if (scope === 'bootstrap' || scope === 'all') { clearBootstrapCache() await fs.remove(BOOTSTRAP_CACHE_DIR) @@ -69,5 +84,9 @@ export async function clearCache(scope: CacheClearScope): Promise if (scope === 'electron' || scope === 'all') { await session.defaultSession.clearCache() } + if (scope === 'knowledge' || scope === 'all') { + await options.beforeClearKnowledge?.() + await fs.remove(KNOWLEDGE_CACHE_DIR) + } return getCacheSummary() } diff --git a/src/main/services/contact-resolution-service.ts b/src/main/services/contact-resolution-service.ts new file mode 100644 index 0000000..d7084ee --- /dev/null +++ b/src/main/services/contact-resolution-service.ts @@ -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() + + 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 + } +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index f771725..cddbe28 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -52,6 +52,16 @@ import type { VoiceModelStatus, VoiceRecognitionResult } from '../shared/voice-recognition' +import type { + AiSearchPipelineRequest, + AiSearchPipelineResult, + AiSearchProgressEvent +} from '../shared/ai-search' +import type { + KnowledgeRuntimeStatus, + KnowledgeSearchIpcRequest, + KnowledgeSearchIpcResult +} from '../shared/knowledge' export type ParsedContent = | { type: 'text'; content: string } @@ -121,7 +131,7 @@ declare global { installAppUpdate: () => Promise<{ success: boolean; error?: string }> onAppUpdateState: (callback: (state: AppUpdateState) => void) => () => void getCacheSummary: () => Promise - clearCache: (scope: 'bootstrap' | 'electron' | 'all') => Promise + clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all') => Promise initDb: ( key: string, accountRoot: string @@ -183,6 +193,12 @@ declare global { }[] } | null> search: (keyword: string) => Promise + searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise + runAiSearch: (request: AiSearchPipelineRequest) => Promise + onAiSearchProgress: (callback: (progress: AiSearchProgressEvent) => void) => () => void + getKnowledgeStatus: () => Promise + startKnowledgeIndex: () => Promise + onKnowledgeStatus: (callback: (status: KnowledgeRuntimeStatus) => void) => () => void aiChat: ( messages: { role: string; content: string }[], options?: AIChatRequestOptions diff --git a/src/preload/index.ts b/src/preload/index.ts index 49607cb..2a069f4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -29,6 +29,16 @@ import type { VoiceModelStatus, VoiceRecognitionResult } from '../shared/voice-recognition' +import type { + AiSearchPipelineRequest, + AiSearchPipelineResult, + AiSearchProgressEvent +} from '../shared/ai-search' +import type { + KnowledgeRuntimeStatus, + KnowledgeSearchIpcRequest, + KnowledgeSearchIpcResult +} from '../shared/knowledge' // 渲染器的自定义 API const api = { @@ -46,7 +56,7 @@ const api = { return () => ipcRenderer.removeListener('app-update:state', listener) }, getCacheSummary: (): Promise => ipcRenderer.invoke('cache:getSummary'), - clearCache: (scope: 'bootstrap' | 'electron' | 'all'): Promise => + clearCache: (scope: 'bootstrap' | 'electron' | 'knowledge' | 'all'): Promise => ipcRenderer.invoke('cache:clear', scope), initDb: (key: string, accountRoot: string) => ipcRenderer.invoke('db:init', key, accountRoot), discoverAccounts: (inputPath: string): Promise => @@ -67,6 +77,26 @@ const api = { ) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options), getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5), search: (keyword: string) => ipcRenderer.invoke('db:search', keyword), + searchKnowledge: (request: KnowledgeSearchIpcRequest): Promise => + ipcRenderer.invoke('knowledge:search', request), + runAiSearch: (request: AiSearchPipelineRequest): Promise => + 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 => + ipcRenderer.invoke('knowledge:getStatus'), + startKnowledgeIndex: (): Promise => + 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) => ipcRenderer.invoke('ai:chat', messages, options), listAIProviders: () => ipcRenderer.invoke('ai:listProviders'), diff --git a/src/renderer/src/components/search/AISearchWorkspace.tsx b/src/renderer/src/components/search/AISearchWorkspace.tsx index c5ccdbb..2b9b9cf 100644 --- a/src/renderer/src/components/search/AISearchWorkspace.tsx +++ b/src/renderer/src/components/search/AISearchWorkspace.tsx @@ -1,48 +1,93 @@ import React, { useMemo, useRef, useState } from 'react' -import type { Contact, Message } from '../../../../shared/types' -import { SearchIcon } from '../chat/icons' +import * as Popover from '@radix-ui/react-popover' +import { aiSearchIntentLabel } from '../../../../shared/ai-search' +import type { + AiSearchAggregation, + AiSearchAgentRun, + AiSearchPipelineTimings, + AiSearchProgressEvent, + AiSearchProgressStage, + AiSearchTimeRange +} from '../../../../shared/ai-search' +import type { Contact } from '../../../../shared/types' import type { AISearchCacheRecord, AISearchWorkspaceProps, EvidenceItem, - GroupMemberName, - SearchPassSummary, SearchRange, SearchScope, - SearchStage, - SenderDirectory + SearchStage } from './searchTypes' +import type { KnowledgeRuntimeStatus } from '../../../../shared/knowledge' import { RANGE_LABELS, SEARCH_CACHE_KEY, SEARCH_HISTORY_KEY, - buildLocalSearchPlan, buildSearchCacheKey, compactCacheItem, currentTimestamp, - evidenceIdentity, - formatMemberName, - formatMessageDate, formatMessageTime, - getRangeStart, - includesSearchAlias, - messageDateKey, messageIdentity, messageText, - mergeSearchPlans, - normalizeSearchText, parseSearchCacheKey, - parseSearchPlanResponse, readSearchCache, readSearchCacheByQuery, - selectEvenly, - selectEvidenceByDate, senderName, writeSearchCache } from './searchUtils' import { renderMarkdown } from './searchMarkdown' +type SearchTrace = { + knowledgeMessages: number + retrievedEvidence: number + finalEvidence: number + timings: AiSearchPipelineTimings + contextEvidence: number + inputTokens?: number + inputTokensEstimated: boolean + aggregation: AiSearchAggregation + invalidCitationIds: string[] + agent: AiSearchAgentRun +} + +type SearchProgressByStage = Partial> + +const formatBytes = (bytes: number): string => { + if (!bytes) return '0 B' + const units = ['B', 'KB', 'MB', 'GB'] + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + return `${(bytes / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}` +} + +const formatDuration = (milliseconds: number): string => + milliseconds >= 1000 ? `${(milliseconds / 1000).toFixed(1)}s` : `${milliseconds}ms` + +const knowledgeStateLabel = (status: KnowledgeRuntimeStatus | null): string => { + if (!status) return '读取中' + return { + unavailable: '未建立', + building: '建立中', + syncing: '增量同步', + ready: '已同步', + error: '异常' + }[status.state] +} + +const contactLabel = (contact: Contact | null | undefined): string => + contact?.m_nsNickName || + contact?.remark || + contact?.wechatNickname || + contact?.m_nsUsrName || + '未选择会话' + +const fallbackEvidenceContact = (conversationId: string): Contact => ({ + md5: conversationId, + m_nsUsrName: conversationId, + m_nsNickName: '未加载的会话', + type: conversationId.endsWith('@chatroom') ? 'group' : 'user' +}) + export function AISearchWorkspace({ contacts, selectedContact, @@ -54,11 +99,10 @@ export function AISearchWorkspace({ onNotice }: AISearchWorkspaceProps): React.ReactElement { const allContacts = useMemo(() => contacts.filter((contact) => contact.md5), [contacts]) - const availableContacts = allContacts.slice(0, 80) const [scope, setScope] = useState('global') const [scopeContactMd5, setScopeContactMd5] = useState(selectedContact?.md5 || '') const [range, setRange] = useState('7d') - const [contactFilter, setContactFilter] = useState('') + const [timeRangeOverride, setTimeRangeOverride] = useState() const [query, setQuery] = useState('') const [stage, setStage] = useState('idle') const [answer, setAnswer] = useState('') @@ -78,11 +122,19 @@ export function AISearchWorkspace({ }) const [senderNames, setSenderNames] = useState>({}) const [cachedAt, setCachedAt] = useState(0) + const [knowledgeStatus, setKnowledgeStatus] = useState(null) + const [syncStarting, setSyncStarting] = useState(false) + const [searchTrace, setSearchTrace] = useState(null) + const [searchProgress, setSearchProgress] = useState({}) + const [agentTrace, setAgentTrace] = useState([]) + const [searchDetailsOpen, setSearchDetailsOpen] = useState(false) + const [historyOpen, setHistoryOpen] = useState(false) const [debugEnabled, setDebugEnabled] = useState(false) const [debugPanelOpen, setDebugPanelOpen] = useState(false) const [debugEntries, setDebugEntries] = useState([]) const [appLogPath, setAppLogPath] = useState('') const bypassCacheRef = useRef(false) + const searchRequestIdRef = useRef('') React.useEffect(() => { void Promise.all([window.api.getSettings(), window.api.getAppLogPath()]).then( @@ -93,6 +145,39 @@ export function AISearchWorkspace({ ) }, []) + React.useEffect(() => { + let active = true + void window.api + .getKnowledgeStatus() + .then((status) => { + if (active) setKnowledgeStatus(status) + }) + .catch(() => undefined) + const unsubscribe = window.api.onKnowledgeStatus((status) => { + if (active) setKnowledgeStatus(status) + }) + return () => { + active = false + unsubscribe() + } + }, []) + + React.useEffect( + () => + window.api.onAiSearchProgress((progress) => { + if (progress.requestId !== searchRequestIdRef.current) return + setSearchProgress((current) => ({ ...current, [progress.stage]: progress })) + if (progress.agentTrace) { + setAgentTrace((current) => + current.some((item) => item.sequence === progress.agentTrace?.sequence) + ? current + : [...current, progress.agentTrace as AiSearchAgentRun['trace'][number]] + ) + } + }), + [] + ) + const addDebugEntry = (message: string, details: Record = {}): void => { const entry = `${new Date().toLocaleTimeString('zh-CN')} ${message} ${JSON.stringify(details)}` setDebugEntries((current) => [entry, ...current].slice(0, 80)) @@ -104,48 +189,42 @@ export function AISearchWorkspace({ } const activeContact = - availableContacts.find( - (contact) => contact.md5 === (scopeContactMd5 || selectedContact?.md5) - ) || selectedContact - const visibleContacts = availableContacts.filter((contact) => { - const keyword = contactFilter.trim().toLowerCase() - if (!keyword) return true - return ( - contact.m_nsNickName.toLowerCase().includes(keyword) || - contact.m_nsUsrName.toLowerCase().includes(keyword) - ) - }) - const sourceLabel = - scope === 'conversation' ? activeContact?.m_nsNickName || '未选择会话' : '全局搜索' + allContacts.find((contact) => contact.md5 === (scopeContactMd5 || selectedContact?.md5)) || + selectedContact + const sourceLabel = { + global: '所有聊天记录', + groups: '群聊专属', + contacts: '联系人专属', + conversation: contactLabel(activeContact) + }[scope] + const currentSyncConversation = knowledgeStatus?.currentConversationId + ? contactLabel( + allContacts.find((contact) => contact.md5 === knowledgeStatus.currentConversationId) + ) + : '' const modelLabel = aiModelConfig.configured ? `${aiModelConfig.providerName} · ${aiModelConfig.modelName}` : '尚未配置 AI 模型' - const buildSourceContacts = (): Contact[] => { - if (scope === 'conversation') return activeContact ? [activeContact] : [] - return allContacts - } - - const loadSourcePages = async ( - sourceContacts: Contact[], - startTime: number | undefined - ): Promise<{ contact: Contact; messages: Message[] }[]> => { - const pages = new Array<{ contact: Contact; messages: Message[] }>(sourceContacts.length) - let nextIndex = 0 - const worker = async (): Promise => { - while (nextIndex < sourceContacts.length) { - const index = nextIndex - nextIndex += 1 - const contact = sourceContacts[index] - pages[index] = { - contact, - messages: await window.api.getMessages(contact.md5, startTime) - } - } + const startKnowledgeSync = async (): Promise => { + if (!dbReady) { + onNotice('请先连接微信数据后再建立本地知识库') + return + } + setSyncStarting(true) + try { + const status = await window.api.startKnowledgeIndex() + setKnowledgeStatus(status) + onNotice( + status.state === 'syncing' + ? '已开始同步最新聊天记录' + : '已开始建立本地知识库,可继续使用软件' + ) + } catch (error) { + onNotice(error instanceof Error ? error.message : '启动知识库同步失败') + } finally { + setSyncStarting(false) } - const workerCount = Math.min(6, sourceContacts.length) - await Promise.all(Array.from({ length: workerCount }, () => worker())) - return pages } const rememberQuery = (value: string): void => { @@ -175,23 +254,26 @@ export function AISearchWorkspace({ localStorage.getItem(SEARCH_CACHE_KEY) || '[]' ) as AISearchCacheRecord[] const queryKey = historyQuery.trim().toLowerCase() - const nextRecords = records.filter((item) => { - try { - const keyParts = JSON.parse(item.key) as unknown - return !( - Array.isArray(keyParts) && - typeof keyParts[3] === 'string' && - keyParts[3] === queryKey - ) - } catch { - return true - } - }) - localStorage.setItem(SEARCH_CACHE_KEY, JSON.stringify(nextRecords)) + localStorage.setItem( + SEARCH_CACHE_KEY, + JSON.stringify( + records.filter((item) => { + try { + const keyParts = JSON.parse(item.key) as unknown + return !( + Array.isArray(keyParts) && + typeof keyParts[3] === 'string' && + keyParts[3] === queryKey + ) + } catch { + return true + } + }) + ) + ) } catch { // Cache cleanup is optional and must not interrupt the current workspace. } - onNotice('已删除这条最近提问') } const applyCachedResult = (cached: AISearchCacheRecord, queryValue = query.trim()): void => { @@ -206,6 +288,7 @@ export function AISearchWorkspace({ const restoreHistoryQuery = (historyQuery: string): void => { setQuery(historyQuery) setSelectedEvidence(0) + setHistoryOpen(false) const cacheKey = buildSearchCacheKey( scope, scope === 'conversation' ? activeContact?.md5 || '' : '', @@ -218,7 +301,7 @@ export function AISearchWorkspace({ setEvidence([]) setCachedAt(0) setStage('idle') - onNotice('这条提问没有可恢复的缓存,请点击开始分析重新读取消息') + onNotice('已填入历史问题,点击开始分析可重新查询最新消息') return } const cachedLocation = parseSearchCacheKey(cached.key) @@ -230,36 +313,13 @@ export function AISearchWorkspace({ setAnalysisError('') applyCachedResult(cached, historyQuery) setStage('result') - onNotice('已恢复这条提问的检索结果') + onNotice('已恢复这条历史问题的最近结果') } - const loadSenderDirectory = async (sourceContacts: Contact[]): Promise => { - const groupContacts = sourceContacts.filter((contact) => contact.type === 'group') - const snapshots = await Promise.all( - groupContacts.map(async (contact) => { - const snapshot = (await window.api.getGroupSnapshot(contact.md5)) as { - members?: GroupMemberName[] - } | null - return snapshot?.members || [] - }) - ) - const displayNames: Record = {} - const aliases: Record = {} - snapshots.flat().forEach((member) => { - if (!member.wxid) return - displayNames[member.wxid] = formatMemberName(member) - aliases[member.wxid] = [ - member.groupNickname, - member.nickname, - member.remark, - member.wechatNickname, - member.wxid - ].filter((name): name is string => Boolean(name?.trim())) - }) - return { displayNames, aliases } - } - - const runAnalysis = async (event?: React.FormEvent): Promise => { + const runAnalysis = async ( + event?: React.FormEvent, + retry?: { range: SearchRange; timeRangeOverride?: AiSearchTimeRange } + ): Promise => { event?.preventDefault() const normalizedQuery = query.trim() if (!normalizedQuery) { @@ -272,259 +332,138 @@ export function AISearchWorkspace({ setStage('insufficient') return } - if (!aiModelConfig.configured) { - setAnalysisError('尚未配置 AI 模型,请先在设置中添加可用的模型供应商') - setStage('insufficient') - return - } - - const sourceContacts = buildSourceContacts() - if (!sourceContacts.length) { - setAnalysisError('没有可检索的聊天范围') - setStage('insufficient') - return - } - setStage('loading') setAnalysisError('') setAnswer('') setEvidence([]) setSelectedEvidence(0) setCachedAt(0) + setSearchTrace(null) + setSearchProgress({}) + setAgentTrace([]) + setSearchDetailsOpen(false) + const effectiveRange = retry?.range || range + const effectiveTimeRangeOverride = retry?.timeRangeOverride || timeRangeOverride const cacheKey = buildSearchCacheKey( scope, scope === 'conversation' ? activeContact?.md5 || '' : '', - range, + effectiveRange, normalizedQuery ) try { const cached = bypassCacheRef.current ? null : readSearchCache(cacheKey) bypassCacheRef.current = false if (cached) { - addDebugEntry('检索命中缓存', { scope, range, messageCount: cached.messageCount }) + addDebugEntry('检索命中缓存', { + scope, + range: effectiveRange, + messageCount: cached.messageCount + }) applyCachedResult(cached, normalizedQuery) setStage('result') onNotice('已使用最近的检索缓存,可点击刷新数据读取最新消息') return } - const localSearchPlan = buildLocalSearchPlan(normalizedQuery) - let searchPlan = localSearchPlan - try { - const planResult = await window.api.aiChat([ - { - role: 'system', - content: - '你是本地聊天检索规划器,不回答用户问题。请从用户问题中提取用于本地数据库检索的主题词和同义短语,只输出 JSON:{"intent":"general|topic|participants|mixed","keywords":["..."],"variants":["..."]}。删除“全局搜索、我和谁聊过、这个话题”等意图词,不要编造人名或聊天内容。' - }, - { - role: 'user', - content: `用户问题:${normalizedQuery}` - } - ]) - const aiPlan = - planResult.success && planResult.data ? parseSearchPlanResponse(planResult.data) : null - searchPlan = mergeSearchPlans(localSearchPlan, aiPlan) - addDebugEntry('AI 检索规划完成', { - source: searchPlan.source, - intent: searchPlan.intent, - keywords: searchPlan.keywords, - variants: searchPlan.variants, - aiPlanSuccess: Boolean(aiPlan) - }) - } catch (error) { - addDebugEntry('AI 检索规划失败,使用本地规划', { - error: error instanceof Error ? error.message : '未知错误', - keywords: localSearchPlan.keywords - }) - } - const startTime = getRangeStart(range) - const pages = await loadSourcePages(sourceContacts, startTime) - const sourceMessages = pages.flatMap((page) => - page.messages.map((message) => ({ contact: page.contact, message })) - ) - const uniqueMessages = Array.from( - new Map( - sourceMessages.map((item) => [ - `${item.contact.md5}:${messageIdentity(item.message)}`, - item - ]) - ).values() - ).sort((left, right) => (left.message.createTime || 0) - (right.message.createTime || 0)) - if (!uniqueMessages.length) { - addDebugEntry('检索没有消息', { contactCount: sourceContacts.length, scope, range }) - setAnalysisError('当前范围内没有找到可分析的消息,请扩大时间范围或更换会话') - setStage('insufficient') - return - } - - const queryKeywords = searchPlan.keywords - const fuzzySearchKeywords = searchPlan.variants - const contactNamesInQuery = sourceContacts.filter((contact) => - [contact.m_nsNickName, contact.m_nsUsrName, contact.remark, contact.wechatNickname] - .filter((name): name is string => Boolean(name?.trim())) - .some((name) => includesSearchAlias(normalizedQuery, name)) - ) - const senderDirectoryContacts = - scope === 'conversation' ? sourceContacts : contactNamesInQuery - const querySenderDirectory = await loadSenderDirectory(senderDirectoryContacts) - const matchedSenderIds = new Set( - Object.entries(querySenderDirectory.aliases) - .filter(([, aliases]) => - aliases.some((name) => includesSearchAlias(normalizedQuery, name)) - ) - .map(([senderId]) => senderId) - ) - const matchedContactIds = new Set(contactNamesInQuery.map((contact) => contact.md5)) - const senderMatchedMessages = uniqueMessages.filter(({ message }) => { - const senderFields = [message.name, message.senderId, message.from].filter( - (value): value is string => Boolean(value?.trim()) - ) - return ( - senderFields.some((value) => matchedSenderIds.has(value)) || - senderFields.some((value) => includesSearchAlias(normalizedQuery, value)) - ) + const requestId = globalThis.crypto?.randomUUID?.() || `search-${Date.now()}` + searchRequestIdRef.current = requestId + const searchResult = await window.api.runAiSearch({ + requestId, + text: normalizedQuery, + scope, + range: effectiveRange, + conversationId: scope === 'conversation' ? activeContact?.md5 : undefined, + timeRangeOverride: effectiveTimeRangeOverride }) - const senderMessageIds = new Set(senderMatchedMessages.map(evidenceIdentity)) - const searchPasses = [ - { label: '主题精确匹配', keywords: queryKeywords }, - { label: 'AI 变体匹配', keywords: fuzzySearchKeywords } - ] - const keywordMatchedMap = new Map() - const passSummaries: SearchPassSummary[] = [] - for (const pass of searchPasses) { - const passMatches = uniqueMessages.filter(({ message }) => { - const text = normalizeSearchText(messageText(message)) - return pass.keywords.some((keyword) => text.includes(normalizeSearchText(keyword))) - }) - passMatches.forEach((item) => keywordMatchedMap.set(evidenceIdentity(item), item)) - passSummaries.push({ - label: pass.label, - keywords: pass.keywords, - messageCount: passMatches.length - }) - } - const keywordMatchedMessages = Array.from(keywordMatchedMap.values()) - const keywordMessageIds = new Set(keywordMatchedMessages.map(evidenceIdentity)) - const relevantMessages = uniqueMessages.filter(({ contact, message }) => { - if (matchedContactIds.has(contact.md5)) return true - const itemKey = evidenceIdentity({ contact, message }) - return senderMessageIds.has(itemKey) || keywordMessageIds.has(itemKey) + addDebugEntry('主进程搜索任务完成', { + status: searchResult.status, + candidateEvidenceCount: searchResult.candidateEvidenceCount, + finalEvidenceCount: searchResult.evidence.length, + elapsedMs: searchResult.elapsedMs, + errorStage: searchResult.errorStage }) - const hasSearchConstraint = - queryKeywords.length > 0 || fuzzySearchKeywords.length > 0 || matchedContactIds.size > 0 - if (!relevantMessages.length && hasSearchConstraint) { - const attemptedTerms = Array.from(new Set([...queryKeywords, ...fuzzySearchKeywords])).join( - '、' - ) - const noResultMessage = `${RANGE_LABELS[range]}内没有找到包含“${attemptedTerms || '主题关键词'}”的聊天消息。已完成精确匹配和智能变体匹配,未回退到全量消息;可以扩大时间范围或换一个更具体的词。` - addDebugEntry('检索未找到相关消息', { - contactCount: sourceContacts.length, - uniqueMessageCount: uniqueMessages.length, - passSummaries, - fallbackToAllMessages: false - }) - setAnalysisError(noResultMessage) - setStage('insufficient') - return - } - const analysisMessages = relevantMessages.length ? relevantMessages : uniqueMessages - addDebugEntry('检索消息匹配完成', { - contactCount: sourceContacts.length, - uniqueMessageCount: uniqueMessages.length, - contactNameMatchCount: contactNamesInQuery.length, - searchIntent: searchPlan.intent, - searchPlanSource: searchPlan.source, - queryKeywords, - fuzzyKeywordCount: fuzzySearchKeywords.length, - passSummaries, - queryAliasCount: Object.keys(querySenderDirectory.aliases).length, - senderMatchCount: matchedSenderIds.size, - senderMessageCount: senderMatchedMessages.length, - keywordMessageCount: keywordMatchedMessages.length, - relevantMessageCount: relevantMessages.length, - fallbackToAllMessages: relevantMessages.length === 0 - }) - const analysisContactIds = new Set(analysisMessages.map(({ contact }) => contact.md5)) - const analysisContacts = sourceContacts.filter((contact) => - analysisContactIds.has(contact.md5) - ) - const resolvedSenderNames = { - ...querySenderDirectory.displayNames, - ...( - await loadSenderDirectory( - analysisContacts.filter((contact) => !matchedContactIds.has(contact.md5)) - ) - ).displayNames - } - const primaryMessages = senderMatchedMessages.length - ? senderMatchedMessages - : keywordMatchedMessages - const primaryMessageIds = new Set(primaryMessages.map(evidenceIdentity)) - const selectedEvidenceItems = primaryMessages.length - ? selectEvenly(primaryMessages, 8) - : selectEvidenceByDate(analysisMessages, 8) - const contextItems = primaryMessages.length - ? [ - ...selectEvenly(primaryMessages, Math.min(160, primaryMessages.length)), - ...selectEvidenceByDate( - analysisMessages.filter((item) => !primaryMessageIds.has(evidenceIdentity(item))), - 80 - ) - ] - : selectEvidenceByDate(analysisMessages, 240) - const dateCounts = new Map() - const senderCounts = new Map() - analysisMessages.forEach(({ contact, message }) => { - const date = messageDateKey(message) - dateCounts.set(date, (dateCounts.get(date) || 0) + 1) - const name = senderName(message, contact, resolvedSenderNames) - senderCounts.set(name, (senderCounts.get(name) || 0) + 1) - }) - const dateSummary = Array.from(dateCounts.entries()) - .map(([date, count]) => `${formatMessageDate(date)} ${count} 条`) - .join('、') - const senderSummary = Array.from(senderCounts.entries()) - .sort(([, left], [, right]) => right - left) - .slice(0, 12) - .map(([name, count]) => `${name} ${count} 条`) - .join('、') - const asksConversationParticipants = - searchPlan.intent === 'participants' || searchPlan.intent === 'mixed' - const context = contextItems - .map( - ({ contact, message }) => - `[${formatMessageTime(message)}] ${contact.m_nsNickName} / ${senderName(message, contact, resolvedSenderNames)}: ${messageText(message)}` - ) - .join('\n') - const aiResult = await window.api.aiChat([ - { - role: 'system', - content: - '你是 WechatExplorer 的本地聊天记录分析助手。只能基于提供的消息回答,不得编造事实。请用中文回答,先给出简短摘要,再列出关键主题、结论和不确定性。对人物问题只能描述群聊中的发言主题和可能角色,不做人格或敏感属性判断。' - }, - { - role: 'user', - content: `检索范围:${sourceLabel},时间:${RANGE_LABELS[range]}\n用户问题:${normalizedQuery}\n检索意图:${searchPlan.intent}\n检索关键词:${queryKeywords.join('、') || '未提取到主题关键词'}\n检索变体:${fuzzySearchKeywords.join('、') || '无'}\n相关消息数:${analysisMessages.length}\n目标成员消息数:${senderMatchedMessages.length}\n检索范围消息总数:${uniqueMessages.length}\n覆盖日期:${new Set(analysisMessages.map((item) => messageDateKey(item.message))).size} 天\n按日期统计:${dateSummary}\n主要发言者统计:${senderSummary}\n${asksConversationParticipants ? '\n这是一个“我和谁聊过”的问题,请按聊天会话和联系人归纳,优先列出实际出现主题关键词的会话,不要根据全量消息猜测。' : ''}\n以下是按检索轮次命中的原始消息,优先级最高的消息排在前面,不代表全部消息:\n${context}` + const contactsById = new Map(allContacts.map((contact) => [contact.md5, contact])) + const evidenceItems: EvidenceItem[] = searchResult.evidence.map((item): EvidenceItem => { + // Contacts may still be paging in while the derived database already + // has a valid conversation id. Evidence must never be discarded just + // because the renderer directory is temporarily incomplete. + const contact = contactsById.get(item.conversationId) || { + ...fallbackEvidenceContact(item.conversationId), + m_nsNickName: item.conversationName, + type: item.conversationType } - ]) - if (!aiResult.success || !aiResult.data) { - setAnalysisError(aiResult.error || 'AI 分析失败,请稍后重试') + return { + evidenceId: item.id, + contact, + message: { + id: item.messageId, + from: item.senderId || 'user', + type: '检索消息', + datetime: new Date(item.timestamp).toLocaleString('zh-CN', { hour12: false }), + content: item.text, + isSender: item.sender === '我', + name: item.sender, + senderId: item.senderId, + createTime: Math.floor(item.timestamp / 1000) + } + } + }) + setSearchTrace({ + knowledgeMessages: searchResult.knowledge.indexedMessageCount, + retrievedEvidence: searchResult.candidateEvidenceCount, + finalEvidence: evidenceItems.length, + timings: searchResult.timings, + contextEvidence: searchResult.contextEvidenceCount, + inputTokens: searchResult.ai?.inputTokens, + inputTokensEstimated: searchResult.ai?.inputTokensEstimated || false, + aggregation: searchResult.aggregation, + invalidCitationIds: searchResult.citationValidation?.invalidCitationIds || [], + agent: searchResult.agent + }) + setAgentTrace(searchResult.agent.trace) + setEvidence(evidenceItems) + setSenderNames( + Object.fromEntries( + evidenceItems + .filter(({ message }) => Boolean(message.senderId && message.name)) + .map(({ message }) => [message.senderId as string, message.name as string]) + ) + ) + setMessageCount(searchResult.knowledge.totalMessages) + if (searchResult.status === 'no_evidence') { + setAnalysisError(`${RANGE_LABELS[effectiveRange]}内没有找到与问题相关的聊天消息。`) setStage('insufficient') return } - setAnswer(aiResult.data) - setEvidence(selectedEvidenceItems) - setSenderNames(resolvedSenderNames) - setMessageCount(uniqueMessages.length) + if (searchResult.status === 'retrieval_incomplete') { + setAnalysisError(searchResult.error || '当前检索未完整覆盖聊天记录,未生成总结。') + setStage('partial') + return + } + if (searchResult.status === 'failed') { + setAnalysisError(searchResult.error || '本地搜索暂时无法完成') + setStage('insufficient') + return + } + if (searchResult.status === 'ai_failed') { + setAnalysisError(searchResult.error || '证据已找到,但 AI 暂时无法生成回答') + setStage('partial') + return + } + if (!searchResult.answer) throw new Error('搜索任务未返回回答') + setAnswer(searchResult.answer) rememberQuery(normalizedQuery) writeSearchCache({ version: 1, key: cacheKey, createdAt: currentTimestamp(), - answer: aiResult.data, - evidence: selectedEvidenceItems.map(compactCacheItem), - senderNames: resolvedSenderNames, - messageCount: uniqueMessages.length + answer: searchResult.answer, + evidence: evidenceItems.map(compactCacheItem), + senderNames: Object.fromEntries( + evidenceItems + .filter(({ message }) => Boolean(message.senderId && message.name)) + .map(({ message }) => [message.senderId as string, message.name as string]) + ), + messageCount: searchResult.knowledge.totalMessages }) setStage('result') } catch (error) { @@ -564,32 +503,250 @@ export function AISearchWorkspace({ ) - const renderLoading = (): React.ReactElement => ( -
-
- AI 正在分析 -

正在读取本地消息并提取证据

-

- 范围:{sourceLabel} · {RANGE_LABELS[range]} -

-
- ● 建立本地数据范围 - ● 提取关键消息 - ○ 生成带证据的摘要 + const renderLoading = (): React.ReactElement => { + const plan = searchProgress.search_plan_ready?.plan || searchProgress.query_understanding?.plan + const understanding = searchProgress.search_plan_ready || searchProgress.query_understanding + const knowledge = searchProgress.knowledge_searching + const evidenceProgress = searchProgress.evidence_ready || searchProgress.evidence_ranking + const aggregation = searchProgress.aggregation + const ai = searchProgress.ai_generating + const stepClass = (progress?: AiSearchProgressEvent): string => + progress?.status === 'completed' + ? 'done' + : progress?.status === 'error' + ? 'error' + : progress + ? 'active' + : '' + const mark = (progress?: AiSearchProgressEvent): string => + progress?.status === 'completed' + ? '✓' + : progress?.status === 'error' + ? '!' + : progress + ? '◉' + : '○' + return ( +
+ 本地检索进行中 +

{ai?.status === 'running' ? '正在生成带来源的回答' : '正在理解并查找相关消息'}

+

+ 范围:{plan?.scopeLabel || sourceLabel} · {plan?.rangeLabel || RANGE_LABELS[range]} +

+
+
+ {mark(understanding)} +
+ 理解搜索条件 + {understanding?.status === 'running' &&

{understanding.message}

} + {plan && ( +
+ {plan.keywords.length > 0 && 关键词「{plan.keywords.join('、')}」} + 时间「{plan.rangeLabel}」 + 范围「{plan.scopeLabel}」 + {plan.contactNames.map((name) => ( + 联系人「{name}」 + ))} + 目标「{aiSearchIntentLabel(plan.intent)}」 +
+ )} +
+
+ {agentTrace.length > 0 && ( +
+ +
+ 本地检索策略 + {agentTrace + .filter((item) => item.event === 'toolCallEnd' || item.event === 'agentDecision') + .slice(-3) + .map((item) => ( +

+ {item.toolName ? `${item.toolName} · ` : ''} + {item.label} + {item.resultCount !== undefined ? ` · ${item.resultCount} 条` : ''} +

+ ))} +
+
+ )} +
+ {mark(knowledge)} +
+ 从本地知识库查找 + {knowledge &&

{knowledge.message}

} + {knowledge?.stats?.knowledgeMessageCount !== undefined && ( +
+ + 知识库已收录 {knowledge.stats.knowledgeMessageCount.toLocaleString()} 条消息 + + {knowledge.stats.matchedMessages !== undefined && ( + 找到 {knowledge.stats.matchedMessages.toLocaleString()} 条相关消息 + )} +
+ )} +
+
+
+ {mark(evidenceProgress)} +
+ 整理原始证据 + {evidenceProgress &&

{evidenceProgress.message}

} + {evidenceProgress?.stats?.matchedMessages !== undefined && ( +
+ 相关消息 {evidenceProgress.stats.matchedMessages.toLocaleString()} 条 + {evidenceProgress.stats.evidenceCount !== undefined && ( + 保留 {evidenceProgress.stats.evidenceCount} 条 Evidence + )} +
+ )} +
+
+
+ {mark(aggregation)} +
+ 按人物和会话整理 + {aggregation &&

{aggregation.message}

} + {aggregation?.stats?.peopleCount !== undefined && ( +
+ {aggregation.stats.peopleCount} 人 + {aggregation.stats.conversationCount !== undefined && ( + {aggregation.stats.conversationCount} 个会话 + )} +
+ )} +
+
+
+ {mark(ai)} +
+ 生成带来源的回答 + {ai && ( +

+ {ai.message} + {ai.modelName ? ` · ${ai.modelName}` : ''} +

+ )} + {ai?.stats?.contextEvidenceCount !== undefined && ( +
+ 已提供 {ai.stats.contextEvidenceCount} 条相关消息 + {ai.stats.tokenEstimate !== undefined && ( + 上下文约 {ai.stats.tokenEstimate.toLocaleString()} Tokens + )} +
+ )} +
+
+
-
- ) + ) + } + + const renderSearchDetails = (): React.ReactElement | null => { + const plan = searchProgress.completed?.plan || searchProgress.search_plan_ready?.plan + if (!plan || !searchTrace) return null + const ai = searchProgress.completed || searchProgress.ai_generating + return ( +
setSearchDetailsOpen(event.currentTarget.open)} + > + 查看检索详情 +
+
+ 搜索条件 + 关键词:{plan.keywords.join('、') || '未识别到明确关键词'} + 时间范围:{plan.rangeLabel} + 搜索范围:{plan.scopeLabel} + 查询意图:{aiSearchIntentLabel(plan.intent)} +
+
+ 本地知识库 + 已收录消息:{searchTrace.knowledgeMessages.toLocaleString()} + 候选消息:{searchTrace.retrievedEvidence.toLocaleString()} + Final Evidence:{searchTrace.finalEvidence} + 本地知识库:{formatDuration(searchTrace.timings.knowledgeSearchMs)} + + Worker 通信 {formatDuration(searchTrace.timings.workerIpcMs)} · FTS{' '} + {formatDuration(searchTrace.timings.ftsMs)} · 消息读取{' '} + {formatDuration(searchTrace.timings.messageLoadMs)} + +
+
+ AI 回答 + 上下文消息:{searchTrace.contextEvidence} + + 输入:{(searchTrace.inputTokens || 0).toLocaleString()} Tokens + {searchTrace.inputTokensEstimated ? '(估算)' : ''} + + {ai?.modelName && 模型:{ai.modelName}} + AI 生成:{formatDuration(searchTrace.timings.aiGenerationMs)} + {searchTrace.invalidCitationIds.length > 0 && ( + 已移除无效引用:{searchTrace.invalidCitationIds.join('、')} + )} +
+
+ 处理过程 + + 受控检索: + {searchTrace.agent.mode === 'agent' + ? `${searchTrace.agent.toolCalls} 次 Tool` + : '已使用旧检索 fallback'} + + 理解问题:{formatDuration(searchTrace.timings.queryUnderstandingMs)} + 确认范围:{formatDuration(searchTrace.timings.contactResolutionMs)} + + Evidence 整理: + {formatDuration( + searchTrace.timings.candidateRankingMs + searchTrace.timings.evidenceBuildMs + )} + + + 人物聚合:{searchTrace.aggregation.peopleCount} 人 ·{' '} + {searchTrace.aggregation.conversationCount} 个会话 ·{' '} + {formatDuration(searchTrace.timings.aggregationMs)} + + 总耗时:{formatDuration(searchTrace.timings.totalMs)} +
+ {searchTrace.agent.trace.length > 0 && ( +
+ 检索轨迹 + {searchTrace.agent.trace.map((item) => ( + + {item.toolName ? `${item.toolName}:` : ''} + {item.label} + {item.resultCount !== undefined ? ` · ${item.resultCount} 条` : ''} + {item.elapsedMs !== undefined ? ` · ${formatDuration(item.elapsedMs)}` : ''} + + ))} +
+ )} +
+
+ ) + } const renderResult = (): React.ReactElement => (
- AI 深度检索结果 + ✓ 已完成

{query}

- {sourceLabel} · {RANGE_LABELS[range]} · 基于 {messageCount} 条消息 · 证据采样{' '} - {evidence.length} 条{cachedAt ? ' · 已使用缓存' : ''} + 知识库已收录 {messageCount.toLocaleString()} 条消息 → 找到{' '} + {searchTrace?.retrievedEvidence || 0} 条相关消息 → {evidence.length} 条 Evidence → + 已生成回答{cachedAt ? ' · 已使用缓存' : ''}

+ {searchTrace && ( +
+ 总耗时 {formatDuration(searchTrace.timings.totalMs)} + 本地检索 {formatDuration(searchTrace.timings.knowledgeSearchMs)} + AI {formatDuration(searchTrace.timings.aiGenerationMs)} + 上下文 {searchTrace.contextEvidence} 条 +
+ )} + {renderSearchDetails()}
-
{renderMarkdown(answer)}
+
+ {renderMarkdown(answer, { + evidenceCount: evidence.length, + onEvidenceClick: setSelectedEvidence + })} +
+ {evidence.length > 0 && ( +
+ 引用: + {evidence.map((_, index) => ( + + ))} +
+ )}
) @@ -627,15 +799,45 @@ export function AISearchWorkspace({ type="button" className="primary" onClick={() => { - setRange('30d') - setStage('idle') + const expandToAll = range === '30d' || range === 'all' + setRange(expandToAll ? 'all' : '30d') + setTimeRangeOverride( + expandToAll + ? { + label: '全部历史', + reason: '用户主动扩大到全部历史', + source: 'user_retry' + } + : undefined + ) + bypassCacheRef.current = true + void runAnalysis(undefined, { + range: expandToAll ? 'all' : '30d', + timeRangeOverride: expandToAll + ? { + label: '全部历史', + reason: '用户主动扩大到全部历史', + source: 'user_retry' + } + : undefined + }) }} > - 扩大到近 30 天 + {range === '30d' || range === 'all' ? '搜索全部历史' : '扩大到近 30 天'}
) + const renderPartial = (): React.ReactElement => ( +
+
!
+ 证据已就绪 +

证据已找到,但 AI 暂时无法生成回答

+

{analysisError}。右侧仍可查看并跳转到本次找到的原始消息。

+ {renderSearchDetails()} +
+ ) + return (
@@ -645,6 +847,10 @@ export function AISearchWorkspace({

在本地聊天记录中提炼主题、结论和可追溯证据

+
+ + Knowledge {knowledgeStateLabel(knowledgeStatus)} +
{modelLabel} @@ -690,131 +896,179 @@ export function AISearchWorkspace({ )}
{stage === 'idle' && renderIdle()} {stage === 'loading' && renderLoading()} {stage === 'result' && renderResult()} + {stage === 'partial' && renderPartial()} {stage === 'insufficient' && renderInsufficient()}
void runAnalysis(event)}> @@ -822,6 +1076,55 @@ export function AISearchWorkspace({ 正在询问 {sourceLabel} {RANGE_LABELS[range]} + + + + + + +
+ 历史提问 + + + +
+ {history.length ? ( + history.map((item) => ( +
+ + +
+ )) + ) : ( + 还没有历史提问 + )} +
+
+