From ac75d5057ea76006d10ce9dcf2a265114163f6ea Mon Sep 17 00:00:00 2001 From: xiaosheng <73678111+xiaoshengbao@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:39:08 +0800 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=20AI=20=E5=8A=A9=E6=89=8B=E3=80=81=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E4=B8=8E=E9=80=9A=E7=9F=A5=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ai-cross-platform.yml | 64 + .gitignore | 11 + README.md | 6 + THIRD_PARTY_NOTICES.md | 32 + design-qa.md | 89 + desktop/package-lock.json | 15 +- desktop/package.json | 1 + .../resources/native-core-source-windows.json | 17 +- desktop/scripts/ai-packaging.cjs | 32 + desktop/scripts/build-backend.cjs | 3 + desktop/scripts/dev.cjs | 5 +- desktop/scripts/smoke-ai-notifications.cjs | 32 + desktop/src/ai-diagnostics.cjs | 61 + desktop/src/ai-notifications.cjs | 86 + desktop/src/main.cjs | 31 +- desktop/src/preload.cjs | 7 + desktop/src/renderer-startup.cjs | 42 + .../windows-source-native-core-bootstrap.cjs | 56 +- desktop/tests/ai-diagnostics.test.cjs | 39 + desktop/tests/ai-notifications.test.cjs | 35 + desktop/tests/ai-packaging.test.cjs | 18 + desktop/tests/renderer-startup.test.cjs | 52 + ...dows-source-native-core-bootstrap.test.cjs | 29 +- docs/ai-diagnostics.md | 102 ++ docs/ai-macos-compatibility.md | 81 + docs/ai-macos-user-acceptance.md | 141 ++ docs/chat-agent.md | 86 + docs/chat-ai.md | 62 + docs/licenses/lobe-icons-MIT.txt | 21 + docs/local-semantic-search.md | 165 ++ frontend/assets/css/agent.css | 201 +++ frontend/assets/css/ai-settings.css | 211 +++ frontend/assets/css/ai.css | 24 + .../icons/ai-providers/claude-color.svg | 1 + frontend/assets/icons/ai-providers/claude.svg | 1 + .../icons/ai-providers/deepseek-color.svg | 1 + .../assets/icons/ai-providers/deepseek.svg | 1 + frontend/assets/icons/ai-providers/doubao.png | Bin 0 -> 17843 bytes frontend/assets/icons/ai-providers/doubao.svg | 1 + .../icons/ai-providers/gemini-color.svg | 1 + frontend/assets/icons/ai-providers/gemini.svg | 1 + frontend/assets/icons/ai-providers/groq.svg | 1 + .../assets/icons/ai-providers/kimi-color.svg | 1 + frontend/assets/icons/ai-providers/kimi.svg | 1 + .../assets/icons/ai-providers/lmstudio.png | Bin 0 -> 12829 bytes .../assets/icons/ai-providers/lmstudio.svg | 1 + frontend/assets/icons/ai-providers/ollama.svg | 1 + frontend/assets/icons/ai-providers/openai.svg | 1 + .../icons/ai-providers/openrouter-color.svg | 1 + .../assets/icons/ai-providers/openrouter.svg | 1 + .../assets/icons/ai-providers/qwen-color.svg | 1 + frontend/assets/icons/ai-providers/qwen.svg | 1 + .../icons/ai-providers/siliconcloud-color.svg | 1 + .../icons/ai-providers/siliconcloud.svg | 1 + .../assets/icons/ai-providers/zhipu-color.svg | 1 + frontend/assets/icons/ai-providers/zhipu.svg | 1 + frontend/components/AgentSettings.vue | 29 + frontend/components/AiProviderIcon.vue | 20 + frontend/components/AiSettings.vue | 327 ++++ frontend/components/LocalSearchSettings.vue | 428 +++++ frontend/components/SettingsDialog.vue | 22 +- frontend/components/UiSelect.vue | 146 ++ frontend/components/chat/AgentAnswer.vue | 115 ++ frontend/components/chat/AgentEvidence.vue | 49 + frontend/components/chat/AgentMaterials.vue | 58 + frontend/components/chat/AgentRun.vue | 68 + .../components/chat/AgentSourceInspector.vue | 53 + frontend/components/chat/AiSidebar.vue | 318 ++++ frontend/components/chat/AiSummaryResult.vue | 17 + frontend/components/chat/ChatAgentPanel.vue | 290 ++++ frontend/components/chat/ChatOverlays.vue | 12 +- frontend/components/chat/ConversationPane.vue | 16 +- frontend/composables/chat/useChatSearch.js | 123 +- frontend/composables/useAiApi.js | 42 + frontend/composables/useApi.js | 29 +- frontend/package-lock.json | 96 ++ frontend/package.json | 1 + frontend/pages/chat/[[username]].vue | 59 + frontend/plugins/ai-notifications.client.js | 13 + frontend/tests/agent-citation.test.js | 79 + frontend/tests/agent-evidence.test.js | 47 + frontend/tests/agent-materials.test.js | 46 + frontend/tests/agent-source-inspector.test.js | 46 + frontend/tests/agent-timeline.test.js | 63 + frontend/tests/ai-diagnostics.test.js | 73 + frontend/tests/ai-panel.test.js | 325 ++++ frontend/tests/ai-presets.test.js | 148 ++ frontend/tests/ai-source-navigation.test.js | 80 + frontend/tests/chat-agent.test.js | 206 +++ .../tests/fixtures/AgentCitationPreview.vue | 41 + .../tests/fixtures/AgentRedesignPreview.vue | 60 + .../tests/fixtures/LocalSearchPreview.vue | 32 + frontend/tests/fixtures/agent-citation.html | 2 + frontend/tests/fixtures/agent-citation.js | 5 + frontend/tests/fixtures/agent-evidence.html | 1 + frontend/tests/fixtures/agent-evidence.js | 20 + frontend/tests/fixtures/agent-redesign.html | 1 + frontend/tests/fixtures/agent-redesign.js | 4 + frontend/tests/fixtures/local-search.html | 1 + frontend/tests/fixtures/local-search.js | 52 + frontend/tests/local-search.test.js | 376 +++++ frontend/tests/ui-select.test.js | 56 + frontend/utils/agentTimeline.js | 8 + frontend/utils/aiDiagnostics.js | 107 ++ frontend/utils/anchorContextCache.js | 19 + pyproject.toml | 17 + src/wechat_decrypt_tool/ai/__init__.py | 1 + src/wechat_decrypt_tool/ai/agent_budget.py | 62 + src/wechat_decrypt_tool/ai/agent_context.py | 559 +++++++ src/wechat_decrypt_tool/ai/agent_model.py | 271 ++++ src/wechat_decrypt_tool/ai/agent_schemas.py | 92 ++ src/wechat_decrypt_tool/ai/agent_service.py | 711 ++++++++ src/wechat_decrypt_tool/ai/agent_timeline.py | 67 + src/wechat_decrypt_tool/ai/agent_tools.py | 92 ++ src/wechat_decrypt_tool/ai/agent_workspace.py | 141 ++ src/wechat_decrypt_tool/ai/diagnostics.py | 338 ++++ .../ai/diagnostics_http.py | 88 + src/wechat_decrypt_tool/ai/lifecycle.py | 33 + src/wechat_decrypt_tool/ai/media.py | 210 +++ src/wechat_decrypt_tool/ai/messages.py | 213 +++ src/wechat_decrypt_tool/ai/providers.py | 267 +++ src/wechat_decrypt_tool/ai/runtime_check.py | 92 ++ src/wechat_decrypt_tool/ai/schemas.py | 104 ++ src/wechat_decrypt_tool/ai/service.py | 594 +++++++ src/wechat_decrypt_tool/ai/storage.py | 135 ++ src/wechat_decrypt_tool/api.py | 13 +- src/wechat_decrypt_tool/backend_entry.py | 7 +- .../chat_export_service.py | 41 +- .../local_search/__init__.py | 1 + .../local_search/catalog.py | 38 + .../local_search/downloads.py | 302 ++++ src/wechat_decrypt_tool/local_search/gpu.py | 167 ++ src/wechat_decrypt_tool/local_search/index.py | 176 ++ .../local_search/inference.py | 225 +++ .../local_search/reading.py | 20 + .../local_search/service.py | 497 ++++++ src/wechat_decrypt_tool/logging_config.py | 44 +- src/wechat_decrypt_tool/request_logging.py | 15 + .../resources/local_search_gpu.json | 60 + .../resources/local_search_models.json | 140 ++ src/wechat_decrypt_tool/routers/ai.py | 338 ++++ src/wechat_decrypt_tool/routers/ai_agent.py | 140 ++ src/wechat_decrypt_tool/routers/chat.py | 41 +- .../routers/local_search.py | 124 ++ tests/conftest.py | 17 + tests/test_ai_agent.py | 337 ++++ tests/test_ai_agent_context.py | 331 ++++ tests/test_ai_agent_protocol.py | 280 ++++ tests/test_ai_api.py | 149 ++ tests/test_ai_diagnostics.py | 282 ++++ tests/test_ai_message_pages.py | 289 ++++ tests/test_ai_platform.py | 42 + tests/test_ai_providers.py | 157 ++ tests/test_ai_services.py | 336 ++++ tests/test_conversation_category.py | 19 + tests/test_local_search.py | 463 ++++++ tests/test_local_search_reading.py | 33 + tools/benchmark_ai_logging.py | 59 + tools/benchmark_local_search_reading.py | 127 ++ tools/build_ai_smoke.cjs | 18 + tools/build_local_search_catalog.py | 42 + tools/build_local_search_gpu_manifest.py | 25 + tools/seed_ai_acceptance.py | 61 + tools/verify_agent_context.py | 76 + tools/verify_ai_runtime.py | 12 + .../verify_local_search_download_recovery.py | 43 + tools/verify_local_search_frozen.py | 26 + tools/verify_local_search_gpu.py | 29 + tools/verify_local_search_live.py | 136 ++ tools/verify_local_search_models.py | 23 + tools/verify_local_search_reference.py | 54 + tools/verify_local_search_retrieval.py | 60 + uv.lock | 1431 ++++++++++++++++- 173 files changed, 17429 insertions(+), 111 deletions(-) create mode 100644 .github/workflows/ai-cross-platform.yml create mode 100644 design-qa.md create mode 100644 desktop/scripts/ai-packaging.cjs create mode 100644 desktop/scripts/smoke-ai-notifications.cjs create mode 100644 desktop/src/ai-diagnostics.cjs create mode 100644 desktop/src/ai-notifications.cjs create mode 100644 desktop/src/renderer-startup.cjs create mode 100644 desktop/tests/ai-diagnostics.test.cjs create mode 100644 desktop/tests/ai-notifications.test.cjs create mode 100644 desktop/tests/ai-packaging.test.cjs create mode 100644 desktop/tests/renderer-startup.test.cjs create mode 100644 docs/ai-diagnostics.md create mode 100644 docs/ai-macos-compatibility.md create mode 100644 docs/ai-macos-user-acceptance.md create mode 100644 docs/chat-agent.md create mode 100644 docs/chat-ai.md create mode 100644 docs/licenses/lobe-icons-MIT.txt create mode 100644 docs/local-semantic-search.md create mode 100644 frontend/assets/css/agent.css create mode 100644 frontend/assets/css/ai-settings.css create mode 100644 frontend/assets/css/ai.css create mode 100644 frontend/assets/icons/ai-providers/claude-color.svg create mode 100644 frontend/assets/icons/ai-providers/claude.svg create mode 100644 frontend/assets/icons/ai-providers/deepseek-color.svg create mode 100644 frontend/assets/icons/ai-providers/deepseek.svg create mode 100644 frontend/assets/icons/ai-providers/doubao.png create mode 100644 frontend/assets/icons/ai-providers/doubao.svg create mode 100644 frontend/assets/icons/ai-providers/gemini-color.svg create mode 100644 frontend/assets/icons/ai-providers/gemini.svg create mode 100644 frontend/assets/icons/ai-providers/groq.svg create mode 100644 frontend/assets/icons/ai-providers/kimi-color.svg create mode 100644 frontend/assets/icons/ai-providers/kimi.svg create mode 100644 frontend/assets/icons/ai-providers/lmstudio.png create mode 100644 frontend/assets/icons/ai-providers/lmstudio.svg create mode 100644 frontend/assets/icons/ai-providers/ollama.svg create mode 100644 frontend/assets/icons/ai-providers/openai.svg create mode 100644 frontend/assets/icons/ai-providers/openrouter-color.svg create mode 100644 frontend/assets/icons/ai-providers/openrouter.svg create mode 100644 frontend/assets/icons/ai-providers/qwen-color.svg create mode 100644 frontend/assets/icons/ai-providers/qwen.svg create mode 100644 frontend/assets/icons/ai-providers/siliconcloud-color.svg create mode 100644 frontend/assets/icons/ai-providers/siliconcloud.svg create mode 100644 frontend/assets/icons/ai-providers/zhipu-color.svg create mode 100644 frontend/assets/icons/ai-providers/zhipu.svg create mode 100644 frontend/components/AgentSettings.vue create mode 100644 frontend/components/AiProviderIcon.vue create mode 100644 frontend/components/AiSettings.vue create mode 100644 frontend/components/LocalSearchSettings.vue create mode 100644 frontend/components/UiSelect.vue create mode 100644 frontend/components/chat/AgentAnswer.vue create mode 100644 frontend/components/chat/AgentEvidence.vue create mode 100644 frontend/components/chat/AgentMaterials.vue create mode 100644 frontend/components/chat/AgentRun.vue create mode 100644 frontend/components/chat/AgentSourceInspector.vue create mode 100644 frontend/components/chat/AiSidebar.vue create mode 100644 frontend/components/chat/AiSummaryResult.vue create mode 100644 frontend/components/chat/ChatAgentPanel.vue create mode 100644 frontend/composables/useAiApi.js create mode 100644 frontend/plugins/ai-notifications.client.js create mode 100644 frontend/tests/agent-citation.test.js create mode 100644 frontend/tests/agent-evidence.test.js create mode 100644 frontend/tests/agent-materials.test.js create mode 100644 frontend/tests/agent-source-inspector.test.js create mode 100644 frontend/tests/agent-timeline.test.js create mode 100644 frontend/tests/ai-diagnostics.test.js create mode 100644 frontend/tests/ai-panel.test.js create mode 100644 frontend/tests/ai-presets.test.js create mode 100644 frontend/tests/ai-source-navigation.test.js create mode 100644 frontend/tests/chat-agent.test.js create mode 100644 frontend/tests/fixtures/AgentCitationPreview.vue create mode 100644 frontend/tests/fixtures/AgentRedesignPreview.vue create mode 100644 frontend/tests/fixtures/LocalSearchPreview.vue create mode 100644 frontend/tests/fixtures/agent-citation.html create mode 100644 frontend/tests/fixtures/agent-citation.js create mode 100644 frontend/tests/fixtures/agent-evidence.html create mode 100644 frontend/tests/fixtures/agent-evidence.js create mode 100644 frontend/tests/fixtures/agent-redesign.html create mode 100644 frontend/tests/fixtures/agent-redesign.js create mode 100644 frontend/tests/fixtures/local-search.html create mode 100644 frontend/tests/fixtures/local-search.js create mode 100644 frontend/tests/local-search.test.js create mode 100644 frontend/tests/ui-select.test.js create mode 100644 frontend/utils/agentTimeline.js create mode 100644 frontend/utils/aiDiagnostics.js create mode 100644 frontend/utils/anchorContextCache.js create mode 100644 src/wechat_decrypt_tool/ai/__init__.py create mode 100644 src/wechat_decrypt_tool/ai/agent_budget.py create mode 100644 src/wechat_decrypt_tool/ai/agent_context.py create mode 100644 src/wechat_decrypt_tool/ai/agent_model.py create mode 100644 src/wechat_decrypt_tool/ai/agent_schemas.py create mode 100644 src/wechat_decrypt_tool/ai/agent_service.py create mode 100644 src/wechat_decrypt_tool/ai/agent_timeline.py create mode 100644 src/wechat_decrypt_tool/ai/agent_tools.py create mode 100644 src/wechat_decrypt_tool/ai/agent_workspace.py create mode 100644 src/wechat_decrypt_tool/ai/diagnostics.py create mode 100644 src/wechat_decrypt_tool/ai/diagnostics_http.py create mode 100644 src/wechat_decrypt_tool/ai/lifecycle.py create mode 100644 src/wechat_decrypt_tool/ai/media.py create mode 100644 src/wechat_decrypt_tool/ai/messages.py create mode 100644 src/wechat_decrypt_tool/ai/providers.py create mode 100644 src/wechat_decrypt_tool/ai/runtime_check.py create mode 100644 src/wechat_decrypt_tool/ai/schemas.py create mode 100644 src/wechat_decrypt_tool/ai/service.py create mode 100644 src/wechat_decrypt_tool/ai/storage.py create mode 100644 src/wechat_decrypt_tool/local_search/__init__.py create mode 100644 src/wechat_decrypt_tool/local_search/catalog.py create mode 100644 src/wechat_decrypt_tool/local_search/downloads.py create mode 100644 src/wechat_decrypt_tool/local_search/gpu.py create mode 100644 src/wechat_decrypt_tool/local_search/index.py create mode 100644 src/wechat_decrypt_tool/local_search/inference.py create mode 100644 src/wechat_decrypt_tool/local_search/reading.py create mode 100644 src/wechat_decrypt_tool/local_search/service.py create mode 100644 src/wechat_decrypt_tool/resources/local_search_gpu.json create mode 100644 src/wechat_decrypt_tool/resources/local_search_models.json create mode 100644 src/wechat_decrypt_tool/routers/ai.py create mode 100644 src/wechat_decrypt_tool/routers/ai_agent.py create mode 100644 src/wechat_decrypt_tool/routers/local_search.py create mode 100644 tests/test_ai_agent.py create mode 100644 tests/test_ai_agent_context.py create mode 100644 tests/test_ai_agent_protocol.py create mode 100644 tests/test_ai_api.py create mode 100644 tests/test_ai_diagnostics.py create mode 100644 tests/test_ai_message_pages.py create mode 100644 tests/test_ai_platform.py create mode 100644 tests/test_ai_providers.py create mode 100644 tests/test_ai_services.py create mode 100644 tests/test_conversation_category.py create mode 100644 tests/test_local_search.py create mode 100644 tests/test_local_search_reading.py create mode 100644 tools/benchmark_ai_logging.py create mode 100644 tools/benchmark_local_search_reading.py create mode 100644 tools/build_ai_smoke.cjs create mode 100644 tools/build_local_search_catalog.py create mode 100644 tools/build_local_search_gpu_manifest.py create mode 100644 tools/seed_ai_acceptance.py create mode 100644 tools/verify_agent_context.py create mode 100644 tools/verify_ai_runtime.py create mode 100644 tools/verify_local_search_download_recovery.py create mode 100644 tools/verify_local_search_frozen.py create mode 100644 tools/verify_local_search_gpu.py create mode 100644 tools/verify_local_search_live.py create mode 100644 tools/verify_local_search_models.py create mode 100644 tools/verify_local_search_reference.py create mode 100644 tools/verify_local_search_retrieval.py diff --git a/.github/workflows/ai-cross-platform.yml b/.github/workflows/ai-cross-platform.yml new file mode 100644 index 00000000..2f5cba0a --- /dev/null +++ b/.github/workflows/ai-cross-platform.yml @@ -0,0 +1,64 @@ +name: AI Cross-Platform Checks + +on: + workflow_dispatch: + pull_request: + paths: + - '.github/workflows/ai-cross-platform.yml' + - 'src/wechat_decrypt_tool/ai/**' + - 'src/wechat_decrypt_tool/local_search/**' + - 'src/wechat_decrypt_tool/routers/ai*.py' + - 'src/wechat_decrypt_tool/routers/local_search.py' + - 'src/wechat_decrypt_tool/resources/local_search*.json' + - 'desktop/scripts/ai-packaging.cjs' + - 'desktop/scripts/build-backend.cjs' + - 'desktop/src/ai-notifications.cjs' + - 'desktop/src/renderer-startup.cjs' + - 'desktop/src/main.cjs' + - 'desktop/tests/ai-*.test.cjs' + - 'desktop/tests/renderer-startup.test.cjs' + - 'tools/verify_ai_runtime.py' + - 'tools/build_ai_smoke.cjs' + - 'frontend/components/**' + - 'frontend/composables/useAiApi.js' + - 'tests/test_ai*.py' + - 'tests/test_local_search*.py' + - 'pyproject.toml' + - 'uv.lock' + +permissions: + contents: read + +jobs: + ai: + strategy: + fail-fast: false + matrix: + os: [windows-2022, macos-14] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.11' + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - run: python -m pip install uv + - run: uv sync --locked --extra build + - name: AI runtime and media (synthetic, no API calls) + run: uv run python tools/verify_ai_runtime.py + - name: AI and local search regression + shell: bash + run: uv run pytest -q tests/test_ai*.py tests/test_local_search*.py + - run: npm ci + working-directory: frontend + - run: npx vitest run + working-directory: frontend + - run: npm run generate + working-directory: frontend + - name: Desktop notification and packaging contracts + run: node --test desktop/tests/ai-notifications.test.cjs desktop/tests/ai-packaging.test.cjs desktop/tests/renderer-startup.test.cjs + - name: Frozen AI runtime (no account or API credentials) + run: node tools/build_ai_smoke.cjs diff --git a/.gitignore b/.gitignore index 5c285fff..55a3bc27 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,14 @@ pnpm-lock.yaml # Local scratch file accidentally generated during development /bento-summary.html +/tmp/local-search-validation/ + +# 本地跨平台验收的传输包、运行目录与远程连接辅助脚本 +/tmp/macos-ai-* +/tmp/macos-public-manifest.json +/tmp/mac-desktop-probe.cjs +/tmp/mac-ui-check.cjs +/tmp/mac-user-ui.cjs +/tmp/mac-e2e-private.json +/tmp/ai-frozen-check/ +/tmp/ai-macos-regression-*/ diff --git a/README.md b/README.md index 91ee0d21..c52f3954 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,12 @@ npm run dev - API服务(默认): http://localhost:10392 (可通过环境变量 WECHAT_TOOL_PORT 修改) - API文档(默认): http://localhost:10392/docs +## 聊天 AI 总结与关注提醒 + +在「设置 → AI 服务」配置模型后,点击聊天右上角 AI,可按条数或时间批量总结群聊与好友消息,并设置定时总结、消息阈值和 AI 语义关注提醒。支持图片、常见文档分析及桌面通知。详见 [使用与开发说明](docs/chat-ai.md)。 + +可在「设置 → AI 服务 → 本地检索」按账号开启可选的语义检索,使用 Hugging Face 固定版本模型,支持 CPU 与 NVIDIA GPU 自动回退。使用方法、下载来源和兼容性实测见 [本地语义检索说明](docs/local-semantic-search.md)。 + ## MCP 服务 设置页中的“AI 接入提示词”会包含 endpoint 和 Bearer token,可直接复制给客户端作为接入指令。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a73a37f8..f410b21b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,5 +1,22 @@ # Third-Party Notices +## AI provider icons + +AI provider SVG brand marks in `frontend/assets/icons/ai-providers/` come from +[`@lobehub/icons-static-svg` 1.95.0](https://www.npmjs.com/package/@lobehub/icons-static-svg/v/1.95.0), +part of [Lobe Icons](https://github.com/lobehub/lobe-icons), copyright (c) 2023 LobeHub. +The SVG files are distributed unchanged under the MIT License. Color variants +retain their original colors; monochrome variants use themed CSS masks. The license is included in +`docs/licenses/lobe-icons-MIT.txt`. Brand marks identify their respective services. + +The PNG app icons are original assets served by the respective official websites: + +- Doubao: https://lf-flow-web-cdn.doubao.com/obj/flow-doubao/favicon/new-doubao/128x128.png +- LM Studio: https://lmstudio.ai/assets/marketing/logo-192x192.png + +These PNG assets are not covered by the Lobe Icons MIT License. All brand names +and trademarks belong to their respective owners and are used to identify services. + ## Optional local voice transcription The optional local voice transcription feature uses the following Python packages: @@ -31,3 +48,18 @@ The complete upstream license text is included at The optional Windows QQ feedback bridge reuses WeQ's `nt_helper.node` and QQ flash-transfer protocol implementation. WeQ is copyright H3CoF6 and licensed under CC BY-NC-SA 4.0: https://github.com/H3CoF6/WeQ + +## Optional local semantic search + +Local retrieval uses ONNX Runtime (MIT), Hugging Face Hub (Apache-2.0), +Hugging Face tokenizers (Apache-2.0), and sqlite-vec (MIT / Apache-2.0 dual license). +Embedding weights are downloaded separately from fixed Hugging Face revisions: +BAAI BGE Small/Base Chinese converted to ONNX by Xenova, and intfloat +multilingual E5 Small. Their selected repositories declare MIT; source, +revision and file hashes are recorded in `local_search_models.json`. + +The optional NVIDIA component downloads pinned official Python wheels for CUDA +Runtime, cuBLAS, cuDNN and cuFFT. These libraries retain their NVIDIA license +terms and bundled notices; they are not relicensed under this repository's +license. ONNX Runtime GPU remains MIT. See `docs/local-semantic-search.md` and +`local_search_gpu.json` for the exact component list and validation status. diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 00000000..fe44dcab --- /dev/null +++ b/design-qa.md @@ -0,0 +1,89 @@ +# AI 助手改版验收 + +- 日期:2026-09-08 +- source visual truth path: `output/ai-assistant-ui/reference.png` +- implementation screenshot path: `output/ai-assistant-ui/sidebar.png`、`output/ai-assistant-ui/expanded.png` +- 本地交互预览:`http://127.0.0.1:4173/tests/fixtures/agent-redesign.html` +- 预览直接挂载正式 ChatAgentPanel / AgentRun / AgentAnswer / AgentSourceInspector 组件,以示例接口替代真实账号及模型调用。 + +## 尺寸与状态 + +原设计板为 1536 × 1024 像素。侧栏裁切区域为 (53,134)-(429,997),展开区域为 (479,134)-(1483,997)。保留原图宽高比,将两区域分别归一到 360px 和 1060px 宽。生成稿两区域纵横比与预设 CSS 尺寸略有出入,没有拉伸图片或据此认定像素级一致。 + +浏览器 viewport 为 1200 × 930 CSS px;截图输出为 1200 × 930 像素。Windows 内部 DPR 约 1.75,工具已将整页截图归一为 CSS 像素。使用整页截图裁切,不采用工具的 clip 输出(clip 在本环境重复缩放)。最终侧栏截图 360 × 800,展开截图 1060 × 800。补充验证了 560px 高度和 800px 浏览器宽度,后者助手约 751px 宽,自动退回浮层引用,无水平溢出。 + +状态:浅色、完成回答、空草稿;展开图选中第 2 条引用。示例内容与设计稿保持同一问题和答案结构;采用组件生成的真实连续引用编号,不硬编码设计图的 30 / 46。实际回答不会随窗口宽度改写,示例文本长度、引用换行与设计图略有不同。窗口高度不足时正文独立滚动,固定操作区始终可见。 + +## 对照证据 + +- 全图组合对照:`output/ai-assistant-ui/comparison.png`,上排设计,下排实现,实际同屏对照后检查。 +- 顶部 / 输入框局部对照:`output/ai-assistant-ui/details-comparison.png`。 +- 深色矮窗及设置浮层:`output/ai-assistant-ui/short-dark-settings.png`。 +- 完整原始截图:`sidebar-full.png`、`expanded-full.png`(同目录)。 + +## 检查结果 + +- 字体:沿用中文系统字体;正文 14px / 1.65 行高,标题 15–18px。没有通过缩小正文字号腾空间,引用以 11px 行内标记显示。长会话名与模型名截断,完整名称可通过 title 核对。 +- 布局:44px 工具栏 + 36px 范围栏;输入区空草稿约 112px;800px 面板约 607px 用于正文。取消常驻模式页签、大模型下拉框与大说明块,工具入口收进菜单。展开时保持受控阅读宽度,右侧出处栏宽 288px,空间不足自动恢复就近浮层。 +- 颜色:沿用项目白 / 灰表面、微信绿和明暗主题变量。深色模式下提高引用、跟随状态及设置入口绿色文字对比度。 +- 图像 / 图标:使用项目原有 Font Awesome 图标,设计无新增照片、纹理或插画资产。小图标形状与生成图存在轻微差异,属于既有图标库约束。 +- 文案 / 数据:功能文案对应现有功能,读取范围警告可展开,来源依据与用量仍能查看。右侧仅显示接口返回的真实原文与附近消息;缓存命中时复用已加载聊天,没有编造上下文。引用缺失锚点时显示定位提示。 + +## 修订记录 + +1. [P2,已修复] 初版回答末尾仍有三行复制 / 出处 / 用量,影响窄窗正文。将用量并入处理过程,复制和出处同排;缩小空输入框起始高度。最终组合图及局部图确认修复。 +2. [P2,已修复] 深色独立预览缺少主应用主题变量,正文及视觉模型控件颜色错误。为测试容器补齐实际主题变量,同时提高新交互绿色在深色背景的对比度。`short-dark-settings.png` 为修复后的浏览器截图。 +3. 截图工具 clip 输出在 Windows DPR 下重复缩放;改用完整截图按实际 CSS 区域裁切。此为证据归一化问题,未因此修改正式 UI。 + +无剩余 P0 / P1 / P2 视觉问题。P3:展开视图标题栏可在后续加入当前 AI 对话标题;生成稿中的装饰性发送者字母头像未引入,优先展示现有发送者名称。设计板中第二条示例追问不作为固定内容写入正式 UI。 + +## 交互与验证 + +浏览器验证:窄窗引用预览、关闭与原文定位;展开右侧出处与上下文;点击定位后回到聊天并固定 AI 对话;窗口缩窄关闭右栏并恢复引用浮层;模型下拉、对话设置、工具与任务切换、返回时草稿保留;560px 高度下多行输入与发送按钮;深色主题。组件测试额外覆盖失败重试、切换引用丢弃过期上下文、缺失锚点、范围变更及原有对话流程。 + +控制台:组件预览没有新增 error / warn。最初打开主应用时记录到 SidebarRail 的既有 hydration mismatch;该警告来源是 3000 端口主应用,不属于 4173 端口组件预览。主应用浏览器没有加载账号,未执行真实模型请求;此处不声称真实账号端到端验证。 + +Nuxt 生产构建通过(`output/ai-assistant-ui/build.log`)。全量 Vitest:167 通过、1 失败;失败是未修改的 local-search.test.js 新增 macOS 高级设置文案断言(仍显示“GPU 加速”)。本次 AI 助手相关测试均通过。 + +## 完成项 + +- [x] 将设计落实到现有 Vue 组件,保留 API、草稿、停止 / 补充、固定、范围和历史功能。 +- [x] 双行导航、紧凑自适应输入框、折叠过程与说明。 +- [x] 宽视图原文对照、窄视图引用浮层、缓存上下文复用。 +- [x] 浏览器视觉对照、交互检查、相关测试和生产构建。 + +final result: passed + +# 聊天分类选择验收(2026-09-09) + +- source visual truth path: `output/chat-scope/reference.png`(最近一轮第 1 张)。 +- implementation screenshot path: `output/chat-scope/desktop.png`;补充 `narrow.png`、`dark.png`。 +- 同屏对照证据:`output/chat-scope/comparison.png`,左侧设计稿、右侧正式组件。 +- 预览:`http://127.0.0.1:4173/tests/fixtures/local-search.html?scope`。直接挂载正式 LocalSearchSettings,只有接口使用虚构数据,不写真实账号配置。 +- 状态:浅色、无搜索词、群聊已选 126/128,个人聊天已选 12/636,首屏名称与设计稿一致。 + +## 尺寸与比较方法 + +设计图与最终整页截图均为 1487 × 1058 像素;最终 CSS viewport 为 1487 × 1058。正式弹窗为 820 × 700.7 CSS px,位置 (333.7,178.8)。设计稿弹窗裁切 (241,87)-(1247,965),等比缩至 820px 宽;实际截图裁切 (333,178)-(1154,881),没有拉伸。设计稿归一后高约 715px,正式组件略紧凑以适配现有应用。Windows 浏览器截图库早期将内容按 1/1.75 缩入画布,最终改用非 fullPage 截图取得与 CSS 坐标一致的图像;工具仍有文字重采样模糊,不把此问题误判为网页字体模糊,也不声称像素级一致。 + +## 发现与迭代 + +- 首次对照发现 [P2] 标题、列表与按钮偏小,弹窗高度约 663px。已将标题调至 24px、分类 18px、行文字 16px、行高 54px,扩大操作按钮与底部留白。修复后约 701px 高,重新截图并同屏比较,主要区域比例与六行可见密度已接近选定稿。 +- 修复 480–600px 宽度下通用弹窗样式覆盖分类弹窗内边距的问题,提高定向规则优先级;390px 宽使用上下分类,仍各自独立滚动。 +- 字体:沿用 Microsoft YaHei / 应用系统字体,标题、分类、行文案、次级计数层级一致;长名称省略并保留 title。 +- 间距:共用搜索、等宽双栏、独立滚动、固定底栏;小窗口无水平溢出。390 × 740 时底栏下缘 728px;1200 × 600 时下缘 576px。 +- 色彩:沿用绿色 #079b57 和应用语义色;选中行浅绿,未选中行白色;深色下使用既有背景和选中颜色。 +- 图标/图像:本设计无头像和其他新增图片素材,使用项目已有 Font Awesome 搜索/关闭图标与原生复选框。 +- 文案:个人聊天与群聊分开。全选、清除的可访问名称包含分类;搜索时全选改为“全选结果”。“已选择”沿用原产品措辞,计数是真实选择数量。 +- 局部检查:在归一对照图中检查标题、分类栏、行与底部按钮,无需额外放大图;文字精确内容由 DOM 和交互测试补充核对。 +- P3:浏览器原生复选框与滚动条外观随平台略有变化;静态稿的部分分隔线未保留,避免恢复表格式列表。 + +## 行为与检查 + +- 浏览器实际操作:群聊全选使总数 138 → 140,个人仍为 12;搜索“城市”只出现 1 个群聊、0 个个人;清除后总数 139、个人仍为 12;取消回到原草稿 138。 +- 前端 33 项检查通过:独立分类操作、旧接口标识兼容、搜索隐藏项保留、空结果按钮禁用、取消不应用、确认后提交范围、账号切换等。 +- 后端 1 项检查通过:保留群聊标识,兼容旧预览字段,仍排除公众号。 +- 浏览器控制台未发现 error。预览测试未调用真实模型、下载或建立索引。 +- 已完成实施与相关检查,无待处理 P0/P1/P2 问题。 + +final result: passed diff --git a/desktop/package-lock.json b/desktop/package-lock.json index a0875acd..0717efa8 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -17,6 +17,7 @@ "cross-env": "^10.1.0", "electron": "^40.0.0", "electron-builder": "26.15.3", + "electron-winstaller": "5.4.0", "png-to-ico": "^3.0.1" } }, @@ -405,7 +406,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -427,7 +427,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -444,7 +443,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -459,7 +457,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -1517,8 +1514,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -2111,7 +2107,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -2132,7 +2127,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -3147,7 +3141,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -3522,7 +3515,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -3540,7 +3532,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -3743,7 +3734,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -4061,7 +4051,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" diff --git a/desktop/package.json b/desktop/package.json index 46298ef2..84b39e62 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -165,6 +165,7 @@ "cross-env": "^10.1.0", "electron": "^40.0.0", "electron-builder": "26.15.3", + "electron-winstaller": "5.4.0", "png-to-ico": "^3.0.1" } } diff --git a/desktop/resources/native-core-source-windows.json b/desktop/resources/native-core-source-windows.json index ad44b379..58c06af6 100644 --- a/desktop/resources/native-core-source-windows.json +++ b/desktop/resources/native-core-source-windows.json @@ -1,11 +1,16 @@ { - "schemaVersion": 2, + "schemaVersion": 3, "platform": "win32", "architecture": "x64", "publisherRepository": "LifeArchiveProject/WeChatDataAnalysis", - "releaseTag": "windows-source-runtime-20260817-7795dced-32004006556", - "assetName": "wechatdataanalysis-windows-source-runtime-x64-v1.tar.gz", - "assetSha256": "5e7eeb7e824616aa462f5cbb5b21369516014b5a323c41b6456d71ec1d860ec9", - "runtimeManifestSha256": "e6902d4a6d3536ff96e2d58bff5af5287bec216423d1f2c224b29c16dc3cb0d9", - "expiresAtUnix": 1790838083 + "releaseTag": "v2.4.0", + "assetName": "WeChatDataAnalysis-2.4.0-Setup.exe", + "assetSha256": "c924347408a94947588b54bf26352ced396513d3bef9386595c36a8210c1410a", + "buildId": "wcdb-prod-20260907-v240-6", + "nativeFiles": { + "wechatdb_client.dll": "59fdf95ba7eae4bc20c971fd28f714f154a54c84ab0b06184af5eb15ad33a06f", + "wechatdb_broker.exe": "eae2450c3ac9bc9b1bc82f317cffdb9163bca96ab4d55fd39817f515adc05813", + "wechatdb_native_build.json": "27b41c72953587300cad6922176c7288949e9d636bd22be59da930a589c0fb03" + }, + "expiresAtUnix": 1792680288 } diff --git a/desktop/scripts/ai-packaging.cjs b/desktop/scripts/ai-packaging.cjs new file mode 100644 index 00000000..b0715e6c --- /dev/null +++ b/desktop/scripts/ai-packaging.cjs @@ -0,0 +1,32 @@ +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); +const { spawnSync } = require('node:child_process'); + +// AI 的动态导入和清单统一收集,源码与冻结程序使用同一组资源。 +function aiPackagingArgs(root, platform = process.platform) { + const packages = ['langchain_core', 'langchain_openai', 'langchain_anthropic', 'langgraph', 'langsmith', + 'pypdf', 'pypdfium2', 'pypdfium2_raw', 'tiktoken', 'docx', 'pptx', 'openpyxl', + 'onnxruntime', 'tokenizers', 'sqlite_vec', 'huggingface_hub']; + const args = packages.flatMap(name => ['--collect-all', name]); + for (const name of ['local_search_models.json', 'local_search_gpu.json']) { + args.push('--add-data', `${path.join(root, 'src/wechat_decrypt_tool/resources', name)}${platform === 'win32' ? ';' : ':'}wechat_decrypt_tool/resources`); + } + args.push('--collect-submodules', 'tiktoken_ext', '--hidden-import', 'langgraph.checkpoint.sqlite.aio'); + return args; +} + +function runPackagedAiSmoke(backend, env = process.env) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wda-ai-smoke-')); + try { + const childEnv = { ...env, PYTHONPATH: '' }; + delete childEnv.PYTHONHOME; + const result = spawnSync(backend, ['--smoke-ai'], { cwd: directory, env: childEnv, encoding: 'utf8', timeout: 120000 }); + if (result.status !== 0) throw new Error(result.stderr || result.stdout || 'AI 打包运行检查失败'); + const report = JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1)); + if (!report.ok || !report.frozen) throw new Error('AI 打包运行检查未通过'); + console.log(`AI 打包运行检查通过:${report.platform} / ${report.arch}`); + } finally { fs.rmSync(directory, { recursive: true, force: true }); } +} + +module.exports = { aiPackagingArgs, runPackagedAiSmoke }; diff --git a/desktop/scripts/build-backend.cjs b/desktop/scripts/build-backend.cjs index 8da614d8..de26e1eb 100644 --- a/desktop/scripts/build-backend.cjs +++ b/desktop/scripts/build-backend.cjs @@ -1,3 +1,4 @@ +const { aiPackagingArgs, runPackagedAiSmoke } = require('./ai-packaging.cjs'); const fs = require("fs"); const os = require("os"); const path = require("path"); @@ -652,6 +653,7 @@ function main() { "opencc", "--collect-all", "watchfiles", + ...aiPackagingArgs(repoRoot), entry, ]; @@ -687,6 +689,7 @@ function main() { ); runPackagedOpenccSmoke(packagedBackend); runPackagedWatchfilesSmoke(packagedBackend); + runPackagedAiSmoke(packagedBackend); // Keep native dependencies outside the onefile extraction directory so the // broker and client library have stable paths at runtime. diff --git a/desktop/scripts/dev.cjs b/desktop/scripts/dev.cjs index 3b55eeb9..46a0f19e 100644 --- a/desktop/scripts/dev.cjs +++ b/desktop/scripts/dev.cjs @@ -151,7 +151,6 @@ async function main() { log(`[native-core] source=${sourceNativeCore.reason} profile=source-public`); } - const npmCommand = "npm"; // Track electron.exe itself instead of an intermediate command shell. const electronCommand = require("electron"); const children = new Set(); @@ -167,7 +166,9 @@ async function main() { process.on("SIGINT", () => shutdown(130)); process.on("SIGTERM", () => shutdown(143)); - const frontend = spawnLogged(npmCommand, ["run", "dev"], { cwd: frontendDir, env: sharedEnv }, "[frontend]"); + // 复用启动脚本的 Node,避免系统 npm 的脚本入口切回旧版 Node。 + const frontend = spawnLogged(process.execPath, [path.join(frontendDir, "node_modules", "@nuxt", "cli", "bin", "nuxi.mjs"), "dev"], + { cwd: frontendDir, env: sharedEnv, shell: false }, "[frontend]"); children.add(frontend); frontend.once("exit", (code, signal) => { log(`frontend exited code=${code} signal=${signal}`); diff --git a/desktop/scripts/smoke-ai-notifications.cjs b/desktop/scripts/smoke-ai-notifications.cjs new file mode 100644 index 00000000..c65d5938 --- /dev/null +++ b/desktop/scripts/smoke-ai-notifications.cjs @@ -0,0 +1,32 @@ +// 使用合成内容验证 Windows / macOS 原生通知,既不读取聊天数据,也不调用模型。 +const { app, Notification } = require('electron'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createAiNotifications } = require('../src/ai-notifications.cjs'); + +app.whenReady().then(() => { + const output = path.resolve(__dirname, '../../logs/ai-notification-smoke.json'); + fs.mkdirSync(path.dirname(output), { recursive: true }); + let finished = false; + const finish = result => { + if (finished) return; + finished = true; + fs.writeFileSync(output, JSON.stringify(result, null, 2)); + app.exit(result.shown ? 0 : 1); + }; + if (!Notification.isSupported()) return finish({ supported: false, shown: false }); + class ObservedNotification extends Notification { + constructor(options) { + super(options); + this.on('show', () => setTimeout(() => { this.close(); finish({ supported: true, shown: true }); }, 1500)); + this.on('failed', (_event, error) => finish({ supported: true, shown: false, error })); + } + } + const service = createAiNotifications({ Notification: ObservedNotification, getPort: () => 1, + dataDir: path.resolve(__dirname, '../../logs/ai-notification-smoke'), navigate: () => {} }); + service.dispatch({ id: Date.now(), kind: 'notification', unique_key: `smoke:${Date.now()}`, body: { + title: 'AI 通知测试', body: '桌面通知验证:这是一条合成测试消息。', + target: { account: 'smoke', task_id: 'a'.repeat(32), username: '', anchor: '' }, + } }); + setTimeout(() => finish({ supported: true, shown: false, error: '未收到系统 show 事件' }), 15000); +}); diff --git a/desktop/src/ai-diagnostics.cjs b/desktop/src/ai-diagnostics.cjs new file mode 100644 index 00000000..fd3390a8 --- /dev/null +++ b/desktop/src/ai-diagnostics.cjs @@ -0,0 +1,61 @@ +const http = require('node:http'); +const events = new Set('request.failed sse.open sse.disconnected sse.recovered sse.invalid response.stale source.ready source.failed navigation.started navigation.finished navigation.failed notification.requested notification.shown notification.failed notification.clicked notification.ack notification.ack_failed notification.duplicate notification.unsupported transport.dropped transport.offline'.split(' ')); +const ids = new Set('trace_id operation_id diagnostic_id task_id run_id thread_id'.split(' ')); +const counts = new Set('http_status duration_ms count dropped_count queued event_id version after attempt'.split(' ')); +const labels = { origin: ['frontend','desktop'], phase: ['request','stream','source','navigation','notification','transport'], status: ['success','failed','pending','closed','missing','stale','ready'], reason_code: ['network','http','parse','timeout','unsupported','overflow','offline','missing','stale','rejected'], method: ['GET','POST','PUT','DELETE','PATCH'], component: ['ai','agent','search','notification','source'] }; +function safeEvent(value) { + if (!value || !events.has(value.event) || !value.metadata || typeof value.metadata !== 'object') return null; + const metadata = {}; + for (const [key, item] of Object.entries(value.metadata)) { + if (key === 'source_id' && typeof item === 'string' && /^[a-f0-9]{24,32}$/i.test(item)) metadata[key] = item; + else if (ids.has(key) && typeof item === 'string' && /^[a-f0-9-]{32,36}$/i.test(item)) metadata[key] = item; + else if (counts.has(key) && typeof item === 'number' && Number.isFinite(item) && item >= 0 && item <= 1e12) metadata[key] = item; + else if (labels[key]?.includes(item)) metadata[key] = item; + } + return { event: value.event, metadata }; +} +function writeFallback(entries, log) { + if (!Array.isArray(entries) || entries.length > 50) return false; + for (const item of entries) { const safe = safeEvent(item); if (safe) log(`[ai.diagnostic.undelivered] ${JSON.stringify(safe)}`); } + return true; +} +function createAiDiagnostics({ getPort, log, send }) { + let queue = [], timer = null, busy = false, stopped = false, dropped = 0, inFlight = 0; + const transmit = send || (entries => new Promise((resolve, reject) => { + const body = JSON.stringify({ events: entries }); + const req = http.request({ hostname: '127.0.0.1', port: getPort(), path: '/api/ai/diagnostics/events', method: 'POST', timeout: 5000, + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, res => { + res.resume(); res.statusCode >= 200 && res.statusCode < 300 ? resolve() : reject(new Error('http')); + }); + req.on('error', reject); req.on('timeout', () => req.destroy(new Error('timeout'))); req.end(body); + })); + const later = (delay = 1000) => { if (!stopped && !timer && (queue.length || dropped)) { timer = setTimeout(() => { timer = null; void flush(); }, delay); timer.unref?.(); } }; + const fallback = entries => { try { writeFallback(entries, log); } catch {} }; + async function flush() { + if (busy || stopped) return; + busy = true; + if (dropped) { + if (queue.length >= 500) { queue.shift(); dropped++; } + queue.unshift({ value: safeEvent({ event: 'transport.dropped', metadata: { origin: 'desktop', dropped_count: dropped } }), attempt: 0 }); dropped = 0; + } + const batch = queue.splice(0,50); + inFlight = batch.length; + let delay = 1000; + try { if (batch.length) await transmit(batch.map(item => item.value)); } + catch { + fallback(batch.filter(item => !item.attempt).map(item => item.value)); + const retained = batch.filter(item => ++item.attempt < 4); + if (!stopped) queue = [...retained,...queue]; + if (queue.length > 500) { dropped += queue.length - 500; queue = queue.slice(-500); } + delay = Math.min(8000,1000 * 2 ** (batch[0]?.attempt || 1)); + } finally { busy = false; inFlight = 0; later(delay); } + } + return { + record(event, metadata = {}) { if (stopped) return; const value = safeEvent({ event, metadata: { origin: 'desktop', ...metadata } }); if (!value) return; + if (queue.length >= 500-inFlight) { queue.shift(); dropped++; } queue.push({ value, attempt: 0 }); later(); }, + flush, size: () => queue.length+inFlight, + stop() { stopped = true; clearTimeout(timer); while (queue.length) fallback(queue.splice(0,50).map(item => item.value)); + if (dropped) fallback([safeEvent({ event:'transport.dropped', metadata:{ origin:'desktop', dropped_count:dropped } })]); }, + }; +} +module.exports = { createAiDiagnostics, writeFallback, safeEvent }; diff --git a/desktop/src/ai-notifications.cjs b/desktop/src/ai-notifications.cjs new file mode 100644 index 00000000..2bf0fcfa --- /dev/null +++ b/desktop/src/ai-notifications.cjs @@ -0,0 +1,86 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const http = require('node:http'); + +function validTarget(value) { + return value && typeof value.account === 'string' && /^[a-f0-9]{32}$/.test(value.task_id || '') + && typeof (value.username || '') === 'string' && typeof (value.anchor || '') === 'string'; +} + +function createAiNotifications({ Notification, getPort, dataDir, navigate, diagnostics = { record() {} } }) { + let stopped = false, request = null, reconnectTimer = null, pendingTarget = null, disconnected = false; + const statePath = path.join(dataDir, 'ai-notifications.json'); + const delivered = new Set(); + // 保留原生通知对象,直到点击或关闭;macOS 通知中心可能稍后才回调。 + const activeNotifications = new Set(); + try { for (const id of JSON.parse(fs.readFileSync(statePath, 'utf8'))) delivered.add(String(id)); } catch {} + const save = () => { + fs.mkdirSync(dataDir, { recursive: true }); + const temp = `${statePath}.tmp`; + fs.writeFileSync(temp, JSON.stringify([...delivered].slice(-10000))); + fs.renameSync(temp, statePath); + }; + const acknowledge = (id, metadata) => { + const req = http.request({ hostname: '127.0.0.1', port: getPort(), path: `/api/ai/events/${id}/ack`, method: 'POST', timeout: 5000 }, res => { + res.resume(); diagnostics.record(res.statusCode >= 200 && res.statusCode < 300 ? 'notification.ack' : 'notification.ack_failed', { ...metadata, http_status: res.statusCode }); + }); + req.on('error', () => diagnostics.record('notification.ack_failed', { ...metadata, reason_code: 'network' })); req.on('timeout', () => req.destroy(new Error('timeout'))); req.end(); + }; + const dispatch = event => { + if (event.kind !== 'notification' || !Number.isSafeInteger(event.id) || !validTarget(event.body?.target)) return; + // 唯一键包含任务 ID;后端数据库重建后不会与旧递增 ID 冲突。 + const key = event.unique_key || `${event.body.target.task_id}:${event.id}`; + const metadata = { event_id: event.id, task_id: event.body.target.task_id }; + if (delivered.has(key)) { diagnostics.record('notification.duplicate', metadata); acknowledge(event.id, metadata); return; } + if (!Notification.isSupported()) { diagnostics.record('notification.unsupported', metadata); acknowledge(event.id, metadata); return; } + delivered.add(key); + try { save(); } catch { diagnostics.record('notification.failed', metadata); delivered.delete(key); return; } + try { + const notification = new Notification({ title: String(event.body.title || 'AI 提醒').slice(0, 120), body: String(event.body.body || '').slice(0, 240) }); + activeNotifications.add(notification); + notification.on('click', () => { + diagnostics.record('notification.clicked', metadata); pendingTarget = event.body.target; + try { Promise.resolve(navigate(pendingTarget)).catch(() => diagnostics.record('navigation.failed', metadata)); } + catch { diagnostics.record('navigation.failed', metadata); } + activeNotifications.delete(notification); + }); + notification.on('close', () => activeNotifications.delete(notification)); + notification.on('show', () => diagnostics.record('notification.shown', metadata)); + notification.on('failed', () => { diagnostics.record('notification.failed', metadata); activeNotifications.delete(notification); }); + diagnostics.record('notification.requested', metadata); + notification.show(); + acknowledge(event.id, metadata); + } catch { diagnostics.record('notification.failed', metadata); } + }; + const reconnect = () => { + if (stopped) return; + if (!disconnected) diagnostics.record('sse.disconnected', { component: 'notification' }); + disconnected = true; + if (!reconnectTimer) reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, 3000); + }; + const connect = () => { + if (stopped) return; + let buffer = ''; + request = http.get({ hostname: '127.0.0.1', port: getPort(), path: '/api/ai/events?notifications=true', headers: { Accept: 'text/event-stream' } }, response => { + if (response.statusCode !== 200) { response.resume(); reconnect(); return; } + diagnostics.record(disconnected ? 'sse.recovered' : 'sse.open', { component: 'notification' }); disconnected = false; + response.setEncoding('utf8'); + response.on('data', chunk => { + buffer += chunk.replace(/\r\n/g, '\n'); + if (buffer.length > 1024 * 1024) { request.destroy(); return; } + let end; + while ((end = buffer.indexOf('\n\n')) >= 0) { + const frame = buffer.slice(0, end); buffer = buffer.slice(end + 2); + const data = frame.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trim()).join('\n'); + if (data) { try { dispatch(JSON.parse(data)); } catch { diagnostics.record('sse.invalid', { component: 'notification', reason_code: 'parse' }); } } + } + }); + response.on('end', reconnect); response.on('error', reconnect); + }); + request.on('error', reconnect); + request.on('close', reconnect); + }; + return { start: connect, stop: () => { stopped = true; clearTimeout(reconnectTimer); request?.destroy(); for (const item of activeNotifications) item.close?.(); activeNotifications.clear(); }, + takeTarget: () => { const value = pendingTarget; pendingTarget = null; return value; }, dispatch }; +} +module.exports = { createAiNotifications, validTarget }; diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index c62a659a..a3a74384 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -3,6 +3,7 @@ const { BrowserWindow, Menu, Tray, + Notification, nativeImage, ipcMain, globalShortcut, @@ -44,6 +45,7 @@ const { shouldRetryBackendOnDifferentPort, } = require("./backend-startup.cjs"); const { applyNativeCoreRuntimePolicy } = require("./native-core-runtime.cjs"); +const { loadWithRedirect } = require("./renderer-startup.cjs"); const { ENV_INTEGRITY_NATIVE_PATH, ENV_MACOS_DB_KEY_BUNDLE, @@ -67,6 +69,8 @@ const DESKTOP_TITLEBAR_HEIGHT = 32; let backendProc = null; let resolvedDataDir = null; let mainWindow = null; +let aiNotifications = null; +let aiDiagnostics = null; let mainWindowLaunchPromise = null; let initialStartupPromise = null; let tray = null; @@ -2564,7 +2568,7 @@ async function loadWithRetry(win, url) { attempt += 1; logMain(`[main] loadWithRetry attempt=${attempt} url=${url}`); try { - await win.loadURL(url); + await loadWithRedirect(win, url); logMain(`[main] loadWithRetry success attempt=${attempt} elapsedMs=${Date.now() - startedAt} url=${url}`); return; } catch (err) { @@ -2805,6 +2809,14 @@ function getWrappedBatch(rawId) { } function registerWindowIpc() { + ipcMain.handle('ai:diagnosticFallback', (event, entries) => { + if (event.sender !== mainWindow?.webContents) return false; + return require('./ai-diagnostics.cjs').writeFallback(entries, logMain); + }); + ipcMain.handle('ai:takeNavigation', (event) => { + if (event.sender !== mainWindow?.webContents) return null; + return aiNotifications?.takeTarget() || null; + }); const getWin = (event) => BrowserWindow.fromWebContents(event.sender); ipcMain.handle("window:minimize", (event) => { @@ -3503,6 +3515,21 @@ async function main() { await ensureMainWindowReady(); + const { createAiNotifications } = require('./ai-notifications.cjs'); + aiDiagnostics = require('./ai-diagnostics.cjs').createAiDiagnostics({ getPort: getBackendPort, log: logMain }); + aiNotifications = createAiNotifications({ + diagnostics: aiDiagnostics, + Notification, + getPort: getBackendPort, + dataDir: app.getPath('userData'), + navigate: async (target) => { + await ensureMainWindowReady(); + requestMainWindow('ai-notification'); + mainWindow?.webContents.send('ai:navigate', target); + }, + }); + aiNotifications.start(); + // Auto-check updates once after the first UI load (packaged builds only). checkForUpdatesOnStartup(); } @@ -3527,6 +3554,8 @@ app.on("will-quit", () => { }); app.on("before-quit", () => { + aiNotifications?.stop(); + aiDiagnostics?.stop(); isQuitting = true; destroyTray(); stopBackend(); diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs index e8081456..acd40c7a 100644 --- a/desktop/src/preload.cjs +++ b/desktop/src/preload.cjs @@ -64,6 +64,13 @@ contextBridge.exposeInMainWorld("wechatDesktop", { // Marker used by the frontend to distinguish the Electron desktop shell from the pure web build. __brand: "WeChatDataAnalysisDesktop", platform: process.platform, + takeAiNavigation: () => ipcRenderer.invoke('ai:takeNavigation'), + aiDiagnosticFallback: (entries) => ipcRenderer.invoke('ai:diagnosticFallback', entries), + onAiNavigate: (callback) => { + const listener = (_event, target) => callback(target); + ipcRenderer.on('ai:navigate', listener); + return () => ipcRenderer.removeListener('ai:navigate', listener); + }, windowControlsMode: "overlay", minimize: () => ipcRenderer.invoke("window:minimize"), toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"), diff --git a/desktop/src/renderer-startup.cjs b/desktop/src/renderer-startup.cjs new file mode 100644 index 00000000..181f036c --- /dev/null +++ b/desktop/src/renderer-startup.cjs @@ -0,0 +1,42 @@ +const path = require('node:path'); +const { fileURLToPath } = require('node:url'); + +function isInternalRedirect(start, destination) { + try { + const from = new URL(start), to = new URL(destination); + if (from.href === to.href) return false; + if (['http:', 'https:'].includes(from.protocol)) return from.origin === to.origin; + if (from.protocol !== 'file:' || to.protocol !== 'file:') return false; + const relative = path.relative(path.dirname(fileURLToPath(from)), fileURLToPath(to)); + return !path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`); + } catch { return false; } +} + +// 首次使用页会中止初始导航;仅在同源目标实际完成加载后接受该跳转。 +async function loadWithRedirect(win, url, timeoutMs = 5000) { + const contents = win.webContents; + let finished = false; + let resolveFinished; + let timer; + const completion = new Promise(resolve => { resolveFinished = resolve; }); + const onFinish = () => { + if (isInternalRedirect(url, contents.getURL())) { + finished = true; + resolveFinished(true); + } + }; + contents.on('did-finish-load', onFinish); + try { + await win.loadURL(url); + } catch (error) { + if (error?.code !== 'ERR_ABORTED' && error?.errno !== -3) throw error; + if (finished) return; + timer = setTimeout(() => resolveFinished(false), timeoutMs); + if (!await completion) throw error; + } finally { + clearTimeout(timer); + contents.removeListener('did-finish-load', onFinish); + } +} + +module.exports = { loadWithRedirect, isInternalRedirect }; diff --git a/desktop/src/windows-source-native-core-bootstrap.cjs b/desktop/src/windows-source-native-core-bootstrap.cjs index c8a1d965..1cabbf96 100644 --- a/desktop/src/windows-source-native-core-bootstrap.cjs +++ b/desktop/src/windows-source-native-core-bootstrap.cjs @@ -104,18 +104,26 @@ function validatePin(input) { "runtimeManifestSha256", "expiresAtUnix", ]; + const installer = input?.schemaVersion === 3; + if (installer) keys.splice(keys.indexOf("runtimeManifestSha256"), 1, "nativeFiles", "buildId"); assertExactKeys(input, keys, "Windows public source-runtime pin"); + if (installer) { + assertExactKeys(input.nativeFiles, [...PAYLOAD_FILES.keys()].map(name => path.basename(name)), "固定原生组件文件"); + if (!BUILD_ID_PATTERN.test(String(input.buildId || "")) || + Object.values(input.nativeFiles).some(hash => !SHA256_PATTERN.test(String(hash)) || hash === "0".repeat(64))) { + throw new Error("安装包原生组件固定摘要无效"); + } + } if ( - input.schemaVersion !== 2 || + ![2, 3].includes(input.schemaVersion) || input.platform !== "win32" || input.architecture !== "x64" || input.publisherRepository !== PUBLISHER_REPOSITORY || - !RELEASE_TAG_PATTERN.test(String(input.releaseTag || "")) || - input.assetName !== ASSET_NAME || + !(installer ? /^v\d+\.\d+\.\d+$/.test(input.releaseTag) : RELEASE_TAG_PATTERN.test(String(input.releaseTag || ""))) || + input.assetName !== (installer ? `WeChatDataAnalysis-${input.releaseTag.slice(1)}-Setup.exe` : ASSET_NAME) || !SHA256_PATTERN.test(String(input.assetSha256 || "")) || input.assetSha256 === "0".repeat(64) || - !SHA256_PATTERN.test(String(input.runtimeManifestSha256 || "")) || - input.runtimeManifestSha256 === "0".repeat(64) || + (!installer && (!SHA256_PATTERN.test(String(input.runtimeManifestSha256 || "")) || input.runtimeManifestSha256 === "0".repeat(64))) || !Number.isSafeInteger(input.expiresAtUnix) || input.expiresAtUnix <= 0 ) { @@ -231,8 +239,9 @@ function downloadPinnedRelease(archivePath, pin, { env, spawnSyncImpl }) { spawnSyncImpl, } ); - const stat = assertRegularFile(archivePath, MAX_ARCHIVE_BYTES); - if (stat.size > MAX_ARCHIVE_BYTES || sha256File(archivePath) !== pin.assetSha256) { + const maxBytes = pin.schemaVersion === 3 ? 1024 * 1024 * 1024 : MAX_ARCHIVE_BYTES; + assertRegularFile(archivePath, maxBytes); + if (sha256File(archivePath) !== pin.assetSha256) { throw new Error("WCDA 公共 Windows 源码运行时的固定 SHA-256 校验失败"); } } @@ -284,6 +293,19 @@ function extractArchive(archivePath, destination, { env, spawnSyncImpl }) { ); } +function extractInstallerRuntime(archivePath, destination, { env, spawnSyncImpl }) { + // 只解包,不运行安装程序;安装包和三个组件均按固定摘要校验。 + // NSIS 自解压包需要完整 7z;7za 精简版不包含 NSIS 解码器。 + const sevenZip = path.join(path.dirname(require.resolve("electron-winstaller/package.json")), "vendor", "7z.exe"); + const options = { env: publicDownloadEnvironment(env), spawnSyncImpl, label: "提取发布版只读原生组件" }; + runTool(sevenZip, ["e", archivePath, `-o${destination}`, "$PLUGINSDIR/app-64.7z", "-y"], options); + const inner = path.join(destination, "app-64.7z"); + assertRegularFile(inner, 1024 * 1024 * 1024); + runTool(sevenZip, ["e", inner, `-o${path.join(destination, "native-core")}`, + ...[...PAYLOAD_FILES.keys()].map(name => `resources/backend/native/${path.basename(name)}`), "-y"], options); + fs.unlinkSync(inner); +} + function walkRuntimeDirectory(root) { const files = new Set(); const directories = new Set(); @@ -403,6 +425,21 @@ function validateWindowsSourceRuntimeDirectory( } const tree = walkRuntimeDirectory(directory); assertExactSet(tree.directories, RUNTIME_DIRECTORIES, "Windows source-runtime directory"); + if (pin.schemaVersion === 3) { + assertExactSet(tree.files, new Set(PAYLOAD_FILES.keys()), "发布版原生组件文件"); + for (const relative of PAYLOAD_FILES.keys()) { + const file = path.join(directory, ...relative.split("/")); + assertRegularFile(file); + if (sha256File(file) !== pin.nativeFiles[path.basename(relative)]) throw new Error(`原生组件摘要不匹配:${relative}`); + } + const paths = runtimePaths(directory); + const policy = resolveNativeCoreRuntimePolicy({ env: {}, isPackaged: false, nativeDir: paths.nativeDir, nowUnix, platform: "win32" }); + if (policy.artifactState !== "source-public" || policy.manifest.buildId !== pin.buildId || policy.manifest.buildExpiresAtUnix !== pin.expiresAtUnix) { + throw new Error("发布版组件不符合固定的源码只读运行时要求"); + } + assertWindowsNativeAsrCapability({ nativeDir: paths.nativeDir, manifest: policy.manifest }); + return { ...paths, manifest: policy.manifest, pin, policy }; + } assertExactSet(tree.files, RUNTIME_FILES, "Windows source-runtime file"); const manifestPath = path.join(directory, RUNTIME_MANIFEST_FILE); @@ -519,11 +556,12 @@ function ensureWindowsSourceNativeCore({ const nonce = `${process.pid}-${crypto.randomBytes(6).toString("hex")}`; const temporary = path.join(root, `.${cacheKey}.tmp-${nonce}`); - const archivePath = path.join(root, `.${cacheKey}.download-${nonce}.tar.gz`); + const archivePath = path.join(root, `.${cacheKey}.download-${nonce}${pin.schemaVersion === 3 ? ".exe" : ".tar.gz"}`); fs.mkdirSync(temporary); try { downloadPinnedRelease(archivePath, pin, { env, spawnSyncImpl }); - extractArchive(archivePath, temporary, { env, spawnSyncImpl }); + if (pin.schemaVersion === 3) extractInstallerRuntime(archivePath, temporary, { env, spawnSyncImpl }); + else extractArchive(archivePath, temporary, { env, spawnSyncImpl }); validateWindowsSourceRuntimeDirectory(temporary, pin, { nowUnix }); if (fs.existsSync(runtimeDir)) removeCacheEntry(root, runtimeDir); fs.renameSync(temporary, runtimeDir); diff --git a/desktop/tests/ai-diagnostics.test.cjs b/desktop/tests/ai-diagnostics.test.cjs new file mode 100644 index 00000000..a12aaa1a --- /dev/null +++ b/desktop/tests/ai-diagnostics.test.cjs @@ -0,0 +1,39 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { EventEmitter } = require('node:events'); +const { createAiDiagnostics, writeFallback } = require('../src/ai-diagnostics.cjs'); +const { createAiNotifications } = require('../src/ai-notifications.cjs'); + +test('桌面队列批量、有界、离线有限重试且写现有日志兜底', async () => { + const logs = []; let calls = 0; + const queue = createAiDiagnostics({ log: line => logs.push(line), send: async () => { calls++; throw new Error('SECRET'); } }); + queue.record('notification.failed', { event_id: 1, body: 'SECRET' }); + for (let i = 0; i < 5; i++) await queue.flush(); + assert.equal(calls,4); assert.equal(queue.size(),0); + assert.equal(logs.length,1); assert.ok(!logs.join('').includes('SECRET')); + for (let i = 0; i < 600; i++) queue.record('notification.failed', { event_id:i }); + assert.equal(queue.size(),500); queue.stop(); + assert.equal(writeFallback(Array(51).fill({}),line => logs.push(line)),false); +}); + +test('通知显示、点击、显示失败与 ACK 失败分别记录且不含正文', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(),'wda-ai-diag-')); + const diagnostics = [], shown = []; + class Notification extends EventEmitter { static isSupported() { return true; } show() { shown.push(this); } } + const server = http.createServer((req,res) => { res.writeHead(503); res.end(); }); + await new Promise(resolve => server.listen(0,'127.0.0.1',resolve)); + const service = createAiNotifications({ Notification, getPort: () => server.address().port, dataDir:root, navigate() {}, diagnostics:{ record:(event,metadata) => diagnostics.push({event,metadata}) } }); + try { + service.dispatch({ id:1, kind:'notification', body:{ title:'SECRET_TITLE', body:'SECRET_BODY', target:{ account:'a', task_id:'a'.repeat(32) } } }); + assert.equal(diagnostics[0].event,'notification.requested'); + assert.ok(!diagnostics.some(x => x.event==='notification.shown')); + shown[0].emit('show'); shown[0].emit('failed','SECRET_ERROR'); shown[0].emit('click'); + for (let i=0;i<50 && !diagnostics.some(x=>x.event==='notification.ack_failed');i++) await new Promise(resolve=>setTimeout(resolve,10)); + for (const event of ['notification.shown','notification.failed','notification.clicked','notification.ack_failed']) assert.ok(diagnostics.some(x=>x.event===event),event); + assert.ok(!JSON.stringify(diagnostics).includes('SECRET')); + } finally { service.stop(); await new Promise(resolve=>server.close(resolve)); fs.rmSync(root,{recursive:true,force:true}); } +}); diff --git a/desktop/tests/ai-notifications.test.cjs b/desktop/tests/ai-notifications.test.cjs new file mode 100644 index 00000000..2f3afc54 --- /dev/null +++ b/desktop/tests/ai-notifications.test.cjs @@ -0,0 +1,35 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { EventEmitter } = require('node:events'); +const { createAiNotifications, validTarget } = require('../src/ai-notifications.cjs'); + +test('通知持久去重并保留点击目标', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wda-ai-notification-')); + const shown = [], navigated = []; + class Notification extends EventEmitter { + static isSupported() { return true; } + show() { shown.push(this); } + } + const options = { Notification, getPort: () => 1, dataDir: root, navigate: target => navigated.push(target) }; + const service = createAiNotifications(options); + const target = { account: 'account', task_id: 'a'.repeat(32), username: 'group', anchor: 'db:table:1' }; + const event = { id: 1, kind: 'notification', unique_key: 'unique', body: { title: '项目群', body: '讨论延期', target } }; + service.dispatch(event); service.dispatch(event); + assert.equal(shown.length, 1); + shown[0].emit('click'); + assert.deepEqual(navigated, [target]); + assert.deepEqual(service.takeTarget(), target); + assert.equal(service.takeTarget(), null); + const restarted = createAiNotifications(options); + restarted.dispatch(event); + assert.equal(shown.length, 1); + service.stop(); restarted.stop(); + fs.unlinkSync(path.join(root, 'ai-notifications.json')); fs.rmdirSync(root); +}); + +test('不接受任意跳转 URL 或无效任务标识', () => { + assert.equal(Boolean(validTarget({ account: 'a', task_id: 'https://example.com' })), false); +}); diff --git a/desktop/tests/ai-packaging.test.cjs b/desktop/tests/ai-packaging.test.cjs new file mode 100644 index 00000000..3e0eff60 --- /dev/null +++ b/desktop/tests/ai-packaging.test.cjs @@ -0,0 +1,18 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { aiPackagingArgs } = require('../scripts/ai-packaging.cjs'); + +for (const platform of ['darwin', 'win32']) { + test(`${platform} 的 AI 资源必须以 add-data 参数传递,包含动态编码与检查点模块`, () => { + const args = aiPackagingArgs('/workspace', platform); + for (const name of ['local_search_models.json', 'local_search_gpu.json']) { + const index = args.findIndex(value => value.includes(name)); + assert.equal(args[index - 1], '--add-data'); + assert.ok(args[index].endsWith(`${platform === 'win32' ? ';' : ':'}wechat_decrypt_tool/resources`)); + } + assert.ok(args.includes('langgraph.checkpoint.sqlite.aio')); + assert.ok(args.includes('tiktoken_ext')); + assert.ok(args.includes('sqlite_vec')); + assert.ok(args.includes('pypdfium2_raw')); + }); +} diff --git a/desktop/tests/renderer-startup.test.cjs b/desktop/tests/renderer-startup.test.cjs new file mode 100644 index 00000000..8d152a62 --- /dev/null +++ b/desktop/tests/renderer-startup.test.cjs @@ -0,0 +1,52 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { loadWithRedirect, isInternalRedirect } = require('../src/renderer-startup.cjs'); + +test('首次使用页跳转完成后不重新加载首页', async () => { + const contents = new EventEmitter(); + contents.getURL = () => 'http://127.0.0.1:3000/agreement?redirect=/'; + let calls = 0; + const win = { webContents: contents, loadURL: async () => { + calls++; + setImmediate(() => contents.emit('did-finish-load')); + throw Object.assign(new Error('aborted'), { code: 'ERR_ABORTED' }); + } }; + await loadWithRedirect(win, 'http://127.0.0.1:3000/', 100); + assert.equal(calls, 1); + assert.equal(contents.listenerCount('did-finish-load'), 0); +}); + +test('跳转先完成后收到中止事件也视为成功', async () => { + const contents = new EventEmitter(); + contents.getURL = () => 'http://localhost:3000/agreement'; + await loadWithRedirect({ webContents: contents, loadURL: async () => { + contents.emit('did-finish-load'); + throw Object.assign(new Error('aborted'), { errno: -3 }); + } }, 'http://localhost:3000/', 10); +}); + +test('未完成、跨源和连接错误不会伪装成加载成功', async () => { + for (const destination of ['http://localhost:3000/', 'https://example.com/agreement']) { + const contents = new EventEmitter(); + contents.getURL = () => destination; + await assert.rejects(loadWithRedirect({ webContents: contents, loadURL: async () => { + contents.emit('did-finish-load'); + throw Object.assign(new Error('aborted'), { code: 'ERR_ABORTED' }); + } }, 'http://localhost:3000/', 10), /aborted/); + assert.equal(contents.listenerCount('did-finish-load'), 0); + } + const contents = new EventEmitter(); + await assert.rejects(loadWithRedirect({ webContents: contents, loadURL: async () => { + throw new Error('ERR_CONNECTION_REFUSED'); + } }, 'http://localhost:3000/'), /ERR_CONNECTION_REFUSED/); +}); + +test('打包页面只接受同一目录内的跳转', () => { + const url = file => pathToFileURL(path.resolve('app', file)).href; + assert.equal(isInternalRedirect(url('ui/index.html'), url('ui/agreement/index.html')), true); + assert.equal(isInternalRedirect(url('ui/index.html'), url('secret.html')), false); + assert.equal(isInternalRedirect(url('ui/index.html'), 'about:blank'), false); +}); diff --git a/desktop/tests/windows-source-native-core-bootstrap.test.cjs b/desktop/tests/windows-source-native-core-bootstrap.test.cjs index 479e8dbb..c8649f65 100644 --- a/desktop/tests/windows-source-native-core-bootstrap.test.cjs +++ b/desktop/tests/windows-source-native-core-bootstrap.test.cjs @@ -259,14 +259,33 @@ test("tracked Windows source pin selects the exact immutable public Release asse assert.equal( publicReleaseUrl(trackedPin), "https://github.com/LifeArchiveProject/WeChatDataAnalysis/releases/download/" + - "windows-source-runtime-20260817-7795dced-32004006556/" + - "wechatdataanalysis-windows-source-runtime-x64-v1.tar.gz" + "v2.4.0/WeChatDataAnalysis-2.4.0-Setup.exe" ); - assert.equal(trackedPin.assetSha256, "5e7eeb7e824616aa462f5cbb5b21369516014b5a323c41b6456d71ec1d860ec9"); - assert.equal(trackedPin.runtimeManifestSha256, "e6902d4a6d3536ff96e2d58bff5af5287bec216423d1f2c224b29c16dc3cb0d9"); - assert.equal(trackedPin.expiresAtUnix, 1790838083); + assert.equal(trackedPin.assetSha256, "c924347408a94947588b54bf26352ced396513d3bef9386595c36a8210c1410a"); + assert.equal(trackedPin.nativeFiles["wechatdb_native_build.json"], "27b41c72953587300cad6922176c7288949e9d636bd22be59da930a589c0fb03"); + assert.equal(trackedPin.expiresAtUnix, 1792680288); }); +test("安装包组件缓存逐文件校验,并保持源码只读策略", () => withTempRoot(root => { + const directory = path.join(root, "runtime"); + fs.mkdirSync(path.join(directory, "native-core"), { recursive: true }); + for (const [relative, bytes] of PAYLOADS) fs.writeFileSync(path.join(directory, relative), bytes); + const pin = { schemaVersion: 3, platform: "win32", architecture: "x64", publisherRepository: PIN.publisherRepository, + releaseTag: "v2.4.0", assetName: "WeChatDataAnalysis-2.4.0-Setup.exe", assetSha256: sha256(ARCHIVE_CONTENT), + buildId: NATIVE_MANIFEST.buildId, expiresAtUnix: EXPIRES_AT_UNIX, + nativeFiles: Object.fromEntries([...PAYLOADS].map(([name, bytes]) => [path.basename(name), sha256(bytes)])) }; + assert.equal(validateWindowsSourceRuntimeDirectory(directory, pin, { nowUnix: NOW_UNIX }).policy.artifactState, "source-public"); + const dll = path.join(directory, "native-core", "wechatdb_client.dll"); + fs.appendFileSync(dll, "modified"); + assert.throws(() => validateWindowsSourceRuntimeDirectory(directory, pin, { nowUnix: NOW_UNIX }), /摘要不匹配/); + fs.writeFileSync(dll, PAYLOADS.get("native-core/wechatdb_client.dll")); + const manifest = { ...NATIVE_MANIFEST, readOnlyBuild: false, wechatActions: ["write"] }; + const bytes = Buffer.from(JSON.stringify(manifest)); + fs.writeFileSync(path.join(directory, "native-core/wechatdb_native_build.json"), bytes); + pin.nativeFiles["wechatdb_native_build.json"] = sha256(bytes); + assert.throws(() => validateWindowsSourceRuntimeDirectory(directory, pin, { nowUnix: NOW_UNIX }), /restricted source-public/); +})); + test("generic source bootstrap wires the verified Windows directory without macOS-only variables", () => withTempRoot((root) => { const tools = fixtureTools(); diff --git a/docs/ai-diagnostics.md b/docs/ai-diagnostics.md new file mode 100644 index 00000000..77f2815f --- /dev/null +++ b/docs/ai-diagnostics.md @@ -0,0 +1,102 @@ +# AI 日志与上线排障 + +AI 总结、提醒、Agent、本地检索、前端请求和桌面通知的运行诊断写入现有应用日志。默认 INFO 只记录关键节点;逐页、逐段、逐批等正常细节降为 DEBUG,减少长期使用的日志体积。此策略只作用于 AI 诊断入口,不改变其他模块、根 logger 或文件 handler 的日志级别。 + +## 日志位置与格式 + +位置仍是当前 output 下的 `logs/YYYY/MM/DD/DD_wechat_tool.log`,可通过应用的“打开当前日志”打开。连续运行跨零点后,第一条记录切换至当天文件;打开日志时也会检查日期。切换 output 目录、删除后重建目录无需重启。没有新增大小限额、保留天数或自动清理。 + +```text +2026-09-09 10:00:00 | INFO | wechat_decrypt_tool.ai.diagnostics | [ai.model.call.finished] 模型调用:步骤结束;运行诊断 {"trace_id":"…","task_id":"…","call_id":"…","status":"success","duration_ms":1200,"usage_known":true} +``` + +关键业务步骤产生 `.started` 与 `.finished`;异常退出记录 `.failed` 或 `.interrupted`。`finished` 表示函数已经返回,应结合 `status` 与最终 `terminal` / `task.state` 判断业务结果,不能仅凭函数返回认定任务成功。下方事件清单同时包含 INFO 关键事件和 DEBUG 详细事件,不代表默认会全部写入文件。 + +默认记录范围: + +- 保留任务创建、状态变化、执行结束、取消与恢复、配置修改、服务启停、每次模型请求开始和结束(含用量、耗时)、Agent 工具动作,以及通知显示/点击/ACK、SSE 断线与恢复。 +- 保留所有显式 WARNING/ERROR、带异常或失败元数据的事件;详细步骤失败也不会被降级。重复后台故障沿用首次及每分钟汇总。 +- 正常消息读取、附件页、分析段、推理批次、事务包装、缓存、规则未触发、预算逐次消耗、首个流式字符及正常页面导航只记 DEBUG。 +- 索引已提交检查点、模型下载进度、GPU 下载进度按任务及本次执行分别限频,首次立即记录,之后每分钟最多一条 INFO;中间进度仍可在 DEBUG 查看。完成和异常不受此限频影响。 + +临时排查细节时可使用项目原有 DEBUG 配置。开启 DEBUG 后仍执行同样的脱敏规则,但详细日志会增多,排查后应恢复 INFO。没有自动清理机制,关键日志也会随任务数、模型调用数和使用天数累积,不保证固定总大小。 + +关联字段: + +| 字段 | 用途 | +| --- | --- | +| `trace_id` | 串联请求、任务和后台处理;新任务保存到 SQLite,恢复沿用 | +| `operation_id` / `parent_operation_id` | 单个步骤及其调用关系 | +| `execution_id` | 每次实际执行生成新值;继续处理保持任务 ID,同时区分本次执行 | +| `task_id` / `run_id` / `thread_id` | 总结或索引任务、Agent 运行、对话 | +| `rule_id` / `profile_id` / `version` / `scope_revision` | 规则、配置、补充要求与授权范围版本 | +| `call_id` | 与 SQLite usage 记录 ID 相同;重试的每次请求分别记录 | +| `diagnostic_id` | 关联失败记录;HTTP 错误也在 `X-WCDA-AI-Diagnostic` 响应头提供 | + +异步代码通过 ContextVar 传递关联信息,专用读取线程池显式复制上下文。下载和推理子进程仅通过 Pipe 传回元数据,由父进程写文件。旧记录没有关联字段时按未知处理,不补造历史用量。 + +## 功能与事件索引 + +下表事件均带 `ai.` 前缀;完整步骤的 started/finished/failed 后缀省略。 + +| 功能 | 主要事件与信息 | +| --- | --- | +| 配置、默认模型、连接测试 | `profile.saved/deleted/defaults.saved/connection.*`:变更字段名、模型、协议、版本和图片能力 | +| 模型列表 | `model.catalog`、`model.catalog.page.*`:分页结果数量、HTTP 状态及耗时 | +| 模型请求 | `model.call.started/acquired/first_token/finished/attempt_failed/validation/retry/compatibility`:排队、请求和首次输出耗时,每次用量、校验和失败分类 | +| 总结创建、固定读取范围 | `summary.task.created`、`summary.conversation.read.*`、`summary.graph_read`:任务 ID、开始/截止时间、范围模式、会话数量及读取结果 | +| 分段、合并、引用、总览 | `summary.segment.*`、`summary.merge.*`、`summary.graph_analyze/graph_overview`:分段数、层级、缓存复用、引用校验和失败会话 | +| 规则、提醒、取消和恢复 | `summary.run_rule`、`summary.rule.skipped`、`summary.retry.scheduled/requested`、`summary.pause_rule`、`summary.alert.matches`、`summary.cancel_task`、`summary.execution.resume`、`summary.task.state` | +| 检查点 | `summary.rule.checkpoint.committed`、`agent.analysis.checkpoint.committed`、`index.checkpoint.committed`:仅在对应事务成功返回后输出 | +| 图片与附件 | `media.resolve_media/enrich/located/cache/page.*/failed`:格式、大小、每页处理与缓存,缺失、加密、损坏、超限或缺少视觉模型等固定原因分类 | +| Agent 多轮、授权、工具 | `agent.submit/input.accepted/request.reused/scope.applied/input.superseded`、`agent.execute_tool`、`agent.tool.action/cache/data_source.failed`:请求去重、版本、范围、动作、返回数量与缓存 | +| 大范围分析 | `agent.context.*`、`agent.analysis.segment/coverage/checkpoint.*`、`agent.merge.segment.*`:背景压缩、每页读取、分段发现、合并、疑点核查与资料分页 | +| Agent 额度和终态 | `agent.budget.consumed/paused`、`agent.context.reduced`、`agent.run.terminal`、`agent.execution.failed`:消费计数、预算缩小、停止原因与保留资料数量 | +| 模型下载与离线导入 | `download.start/run/state/progress/file.*/retry/process.*/import.*`、`model.file.verify.*`:文件、固定版本、已有字节、重试等待、校验与加载测试 | +| GPU 组件 | `gpu.component.detected/file.*/package.install.*/install/failed`、`background.failure`:支持情况、组件文件校验、安装及检测失败 | +| 推理与 CPU 回退 | `inference.encode/process.*/batch.finished/cpu.fallback`:实际设备、运行库版本、批次大小、子进程 PID/退出码及回退原因 | +| 建立/恢复索引 | `search.build/resume/run`、`index.page.*/batch.*/commit/checkpoint.committed`:读取范围、动态批次、复用数量、推理和提交游标 | +| 智能搜索和分页 | `search.hybrid/recall.finished/ticket.hit/ticket.expired/keyword.fallback`:关键词/语义召回数、票据命中、设备及降级原因 | +| 存储、后台循环 | `storage.transaction.failed`、`index.transaction.rolled_back`、`background.failure/recovered`:错误类型、系统/SQLite 错误码、合并次数与持续时间 | +| 服务启停与恢复 | `lifecycle.*`、`runtime.component`、`storage.usage.recovered`、`agent.runs.recovered`、各服务 `start/stop/purge_account` | +| 前端请求和 SSE | `client.request.failed`、`client.sse.open/disconnected/recovered/invalid`、`client.response.stale` | +| 原文与通知导航 | `client.source.ready/failed`、`client.navigation.started/finished/failed` | +| 桌面通知 | `client.notification.requested/shown/failed/clicked/ack/ack_failed/duplicate/unsupported` | +| 上报链路 | `client.transport.dropped`;后端离线时桌面 `desktop-main.log` 中的 `[ai.diagnostic.undelivered]` | + +关键步骤使用 INFO;兼容切换、可恢复失败和降级使用 WARNING;最终失败、存储异常和后台异常使用 ERROR。重复后台故障首次立即记录,之后每分钟汇总,恢复时补齐被合并次数。下载内部仍每 5 秒采集诊断进度,INFO 文件每分钟最多记录一次,阶段变化和文件完成立即记录。流式回答默认记录请求开始和结束,首次输出仅记 DEBUG,不逐字记录。SSE 心跳、无变化的轮询和组件渲染不记录。 + +## 如何排查 + +先取得任务/运行 ID 或接口响应头中的 trace/diagnostic ID,在当前及前一天日志中搜索。例如 PowerShell 安装了 rg 时: + +```powershell +rg '任务或运行ID' 'D:\实际output目录\logs' +``` + +按问题定位: + +1. **没有执行**:查 `task.created` / `input.accepted` / `search.build`、规则配置和 `background.failure`。需要追踪未触发原因时临时开启 DEBUG,查看 `rule.skipped` 的 `active_task`、`revision_changed`、`no_new_messages`、`below_threshold`。 +2. **一直等待**:模型有 `call.started` 无 finished 表示尚未完成,完成记录中的 queue_ms/request_ms 可区分排队和请求耗时;实时区分两者需开启 DEBUG 查看 acquired。下载查看 retry 的等待秒数;索引查看最近一分钟的 checkpoint.committed 和任务终态。详细 page/batch/commit 仅在 DEBUG 下可见。 +3. **回答失败**:从 `agent.run.terminal`、`summary.task.state` 或 `http.rejected/failed` 沿 trace 查 `call_id`。检查鉴权、限流、超时、无效 JSON、引用失败、上下文不足及兼容切换;`usage_known=false` 表示未知,不能按零费用理解。修复后继续,比较新旧 `execution_id` 与缓存事件。 +4. **语义降级**:查 `search.keyword.fallback`。`scope_not_indexed` 表示索引未覆盖;`semantic_failed` 继续追踪 inference/process 错误。`inference.cpu.fallback` 表示当前批次改用 CPU;结合后续 checkpoint.committed 和 search.run.finished 的状态确认恢复结果,批次详情需开启 DEBUG。 +5. **通知未出现**:先查后端 `summary.notify` 和通知事件,再按 task_id 查 requested → shown 或 failed/unsupported。ACK 只代表客户端已处理投递,**不能证明系统显示成功或用户已看到**;用户点击有单独 clicked。后端离线时看 desktop-main.log,导航失败再查 navigation/source 事件。 +6. **恢复后看似重复处理**:任务 ID 应相同、执行 ID 不同。缓存事件不是新模型调用;检查点只有 committed 才算推进。回滚、部分附件失败和未完成分页会保留游标,恢复重读是预期行为。 + +## 内容边界和客户端入口 + +不记录密钥、正文、问题、检索词、提示词、模型回答、通知正文、Base64、签名下载地址、请求体或完整任务/timeline。异常仅保留类型、固定分类、HTTP/系统错误码和文件/函数/行号,不输出异常消息、源码行及局部变量。开启 DEBUG 同样受此限制;SDK 原始请求日志被过滤,AI 上下文中的旧模块自由文本也转换为安全诊断。 + +`POST /api/ai/diagnostics/events` 只接受本机请求、固定事件及白名单字段,每批最多 50 条、64 KiB,不接受任意日志文本。客户端按秒批量发送,队列最多 500 条,最多尝试 4 次;首次发送失败给桌面日志兜底,溢出明确计数。上报使用独立传输,不进入请求错误上报链,避免递归。 + +AI 普通请求使用 `X-WCDA-AI-Trace` 请求/响应头,保留 Agent 原有 request_id 的业务去重用途。浏览器原生 EventSource 无法设置自定义请求头,使用经过校验的 `ai_trace` 随机编号关联 SSE,服务端仍返回 trace 响应头。 + +## 验证与限制 + +新增回归覆盖并发上下文和线程池、恢复执行 ID、HTTP 本机限制/体积/白名单、INFO/DEBUG/SDK/异常栈的敏感哨兵、跨天并发写入、重复初始化、目录重建、output 切换、模型错误与重试、流式日志条数、索引回滚、前端队列/SSE、通知阶段和桌面离线兜底。 + +`tools/benchmark_ai_logging.py --messages 100000 --output tmp/ai-key-logging-benchmark.json` 沿用现有合成 SQLite 消息场景,比较日志关闭和开启。收敛前记录 576 条、168370 字节;当前 INFO 策略记录 1 条已提交检查点、371 字节,日志体积降低约 99.8%。本轮索引耗时不足一分钟,因此未触发第二次进度记录;更慢的任务会每分钟记录进度。这个基准直接调用读取和提交方法,不包含完整任务启停、网络模型请求、真实 GPU 推理和桌面通知,不能将 371 字节当成全部 AI 使用的体积上限。 + +本轮日志关闭/开启的读取及事务提交耗时为 44.323 → 33.482 秒,机器负载与缓存影响明显,不能将负增幅解释为日志带来加速或承诺固定开销。结果文件独立写到指定路径,不写入生产业务日志。新增回归还验证连续一千轮正常细节不写 INFO、异常仍可见、DEBUG 仍脱敏、进度按任务/执行限频,以及其他模块共享文件 handler 时 INFO/DEBUG 不受影响。 + +当前环境的联系人导出脱敏测试依赖未安装的原生 broker,属于既有环境阻塞;AI 日志测试不依赖此组件。真实断网、GPU 驱动崩溃和各操作系统通知中心显示仍应在对应发布机器上做最终联调。 diff --git a/docs/ai-macos-compatibility.md b/docs/ai-macos-compatibility.md new file mode 100644 index 00000000..aa07a406 --- /dev/null +++ b/docs/ai-macos-compatibility.md @@ -0,0 +1,81 @@ +# AI 功能 macOS 兼容性与验收 + +更新时间:2026-09-08。此文档区分组件验收与完整桌面验收,不以模拟测试代替真实聊天流程。 + +## 运行策略与修复 + +- AI 服务、总结、Agent、关注提醒和用量审计使用共享 Python / Vue 实现。模型接口仍读取用户配置,不写死模型名称。 +- 本地检索在 Mac 使用 ONNX Runtime CPU。自动策略直接选择 CPU,不把未安装 Windows NVIDIA 组件误报为故障;界面隐藏 NVIDIA 下载入口。来自其他平台的 CUDA 偏好保留,实际执行回退 CPU。 +- `uv.lock` 按平台固定 ORT:Mac 1.23.2,Windows 1.28.0。Mac 1.23.2 有 macOS 13+ 的 Apple Silicon 与 Intel wheel;1.28.0 的 Mac wheel 仅有 macOS 14+ arm64,因此不统一使用该版本。最低依赖平台不等同于全应用最低系统验收。 +- SQLite 向量扩展使用应用运行时;系统 Python 不支持加载扩展时给出明确错误,并关闭失败连接。源码测试采用 uv 管理的 CPython 3.11。 +- PyInstaller 统一收集 LangChain、LangGraph、SQLite 检查点、ONNX、分词器、文档解析和模型清单;修复模型清单缺少 `--add-data` 参数的问题。新增冻结程序离线检查,避免只在开发环境可用。 +- Electron 持有通知对象直到点击、关闭或失败;应用停止时释放,避免 macOS 的延迟事件丢失。 +- 修复首次使用页跳转被 `loadURL` 的 `ERR_ABORTED` 当成失败而持续重载:只有同源目标实际完成加载才接受跳转,真实连接失败继续进入原有重试。 + +官方参考:[ORT 安装](https://onnxruntime.ai/docs/install/)、[sqlite-vec Python 运行环境](https://alexgarcia.xyz/sqlite-vec/python.html)、[Electron 桌面通知](https://www.electronjs.org/docs/latest/tutorial/notifications)。具体 wheel 分别核对 [ORT 1.23.2](https://pypi.org/pypi/onnxruntime/1.23.2/json) 与 [ORT 1.28.0](https://pypi.org/pypi/onnxruntime/1.28.0/json)。 + +## 实机环境 + +- MacBook Air,Apple M4,16 GB,arm64,macOS 26.3.1。 +- uv 管理的 CPython 3.11、Node 24.13.0、Electron 40.0.0。 +- 测试目录:`~/WeChatDataAnalysis-ai-check-20260908`。使用独立用户数据目录;未覆盖已有应用、密钥或微信数据。 +- 模拟模型与合成消息测试不调用在线模型 API。真实本地模型来自固定 Hugging Face 目录,未使用 Hugging Face Token。 + +## 已通过 + +| 检查 | macOS 结果 | 验收边界 | +| --- | --- | --- | +| 后端 AI / Agent / 本地检索回归 | 132 项通过 | 包含模拟模型、协议恢复、权限、审计、索引与设备策略 | +| Vue 回归 | 168 项通过 | 14 个测试文件,包含按钮状态和 Mac 设备界面 | +| 桌面通知、打包与页面启动 | 8 项通过 | 点击路由为自动化测试;覆盖首次使用页跳转 | +| 生产前端构建 | 34 个路由生成成功 | 非完整签名 DMG | +| 公共运行时 | 通过 | 模型适配器初始化、业务 SQLite、LangGraph 检查点、FTS5、sqlite-vec | +| 媒体解析 | 通过 | 合成 DOCX、XLSX、PPTX、图片 PDF、JPEG、PNG、WebP、GIF | +| 三款检索模型 | 完整匿名下载并加载通过 | BGE Small 中文、BGE Base 中文、Multilingual E5 Small | +| 离线混合检索 | 三款均通过 | 真实分词、CPU 推理、FTS5、向量索引与 RRF,合成数据 | +| 冻结程序 | 通过 | onefile 动态库加载、媒体、SQLite、检查点及独立 CPU 子进程推理 | +| 原生通知 | `supported=true`、`shown=true` | Mac Electron 实际 show 事件;未实测人手点击 | +| 本地检索 / Agent 渲染 | 通过 | Mac Electron 真实组件、虚拟数据、浅色及深色;不是已连接微信的整机页面 | +| 完整源码桌面启动 | 通过 | Mac 图形会话启动真实 Electron → 原生 broker → FastAPI,健康接口正常,首次使用页不再循环重载 | +| 运行中的 AI HTTP 接口 | 4 个入口均返回 200 JSON | 全局 AI 设置、Agent 设置、本地检索状态与设备列表;未读取真实聊天 | + +Windows 对应后端 132 项、Vue 168 项、桌面 8 项回归通过,公共运行时检查通过。最后一次设备显示文案调整后,另行复验本地检索 Vue 31 项通过。新增 `.github/workflows/ai-cross-platform.yml` 持续检查 Windows / macOS;此记录不宣称新工作流已在 GitHub 执行。 + +## 本地检索样例 + +48 条合成消息、8 个固定问题,三款模型的关键词 Recall@20 为 0.25,混合检索为 1.0。该样例用于检查执行链路,不代表真实账号效果或普遍准确率。 + +| 模型 | 查询中位数 | 推理工作进程 RSS | +| --- | --- | --- | +| BGE Small 中文 | 5.21 ms | 221.69 MB | +| BGE Base 中文 | 25.21 ms | 580.47 MB | +| Multilingual E5 Small | 4.28 ms | 1615.16 MB | + +以上是该合成小索引的局部测量,不包含模型下载、首次启动、完整 UI 往返和在线回答;不能据此承诺大量聊天的耗时或内存。 + +## 桌面启动排障与未覆盖项目 + +源码原生组件下载及校验通过。直接从 SSH 后台启动时,原生数据库 broker 报 `Cannot create device identity: broker unavailable`(退出码 4);改由 macOS 图形会话通过 `open -n -a ... --args ...` 启动后,原生组件与后端正常。没有关闭安全检查、修改钥匙串权限或绕过原生组件。该现象表明远程 shell 启动与桌面启动不同,未进一步推断底层钥匙串错误类型。 + +随后发现并修复首次使用页正常重定向引发的循环重载。2026-09-08 19:39 的实机启动健康接口返回正常,桌面停留首次使用页。当时仅完成启动和组件测试。 + +同日随后补充了真实 Mac Electron 用户流程:模型下载、本地索引、真实账号副本语义搜索、真实模型总结、连续追问、跨群对比、停止继续、补充要求、引用定位和审计。详细结果、实际 Token 以及仍未覆盖的实时提醒等场景见 [Mac AI 实机用户流程验收](ai-macos-user-acceptance.md)。Intel Mac、macOS 13/14 实机以及签名安装包均未完成实测,不能承诺所有 Mac 组合均已验证。 + +## 复验入口与证据 + +```sh +uv sync --locked --python 3.11 --extra build +uv run python tools/verify_ai_runtime.py +uv run pytest -q tests/test_ai*.py tests/test_local_search*.py +cd frontend +npm ci +npx vitest run +npm run generate +cd .. +node --test desktop/tests/ai-notifications.test.cjs desktop/tests/ai-packaging.test.cjs desktop/tests/renderer-startup.test.cjs +node tools/build_ai_smoke.cjs +``` + +模型已下载时,运行检查器可加 `--model-root <模型目录>` 验证真实 BGE Small 子进程推理。检查器仅生成合成数据,不读取账号和密钥。完整后端打包脚本也自动调用 `--smoke-ai`。 + +Mac 测试目录中的 `logs/` 保留:`python-tests.log`、`vue-tests-complete.log`、`frontend-build-release.log`、`runtime.log`、`models.log`、`retrieval.log`、`frozen-build.log`、`frozen-inference.log`、`desktop-ai-tests.log`、`ai-notification-smoke.json`、`mac-ui-check.json` 及界面截图。初次启动失败诊断保存在 `mac-desktop.log`;修复后的启动记录在 `mac-desktop-probe.log` 和测试用户目录的 `desktop-main.log`。 diff --git a/docs/ai-macos-user-acceptance.md b/docs/ai-macos-user-acceptance.md new file mode 100644 index 00000000..e49710d2 --- /dev/null +++ b/docs/ai-macos-user-acceptance.md @@ -0,0 +1,141 @@ +# Mac AI 实机用户流程验收 + +日期:2026-09-08。设备:MacBook Air / Apple M4 / 16 GB / macOS 26.3.1。 + +本记录补充 `ai-macos-compatibility.md` 中的组件、运行库与构建测试。本次实际操作 Mac 图形会话中的 Electron 主窗口,点击设置、选择聊天、下载模型、建索引、搜索、提问及来源入口;连接真实 FastAPI、SQLite 和模型服务,未替换业务接口或模型响应。 + +## 环境和数据 + +- 隔离源码目录:`~/WeChatDataAnalysis-ai-check-20260908`;独立 Electron 用户目录与后端 output,未覆盖已安装应用。 +- 操作方式:通过 Tailscale / SSH,在 Mac 本机用 Playwright CDP 操作实际 Electron 页面;不是 Windows 浏览器,也不是组件 fixture 页。 +- 在线服务:用户已有 DeepSeek 配置,先复制到隔离后端;随后在设置页点击「从上游获取」得到 3 个模型,并测试已选择的 `deepseek-v4-flash-vision-exp`。未将密钥写入本文或验收日志。 +- 可核对聊天:`tools/seed_ai_acceptance.py` 创建专用账号,3 个会话、117 条消息,包含明确的旧价、新价、改期和责任人。它只负责准备数据库;后续全部通过正常聊天、搜索及 AI 接口读取。 +- 真实数据:使用 Mac 现有已解密账号的隔离副本,仅在「妈妈」聊天最近 30 天建立本地索引,该聊天未提交给在线回答模型。登录后的实时验收仅将用户与自己的会话中本日主动发送的三条验收消息用于在线提醒判断和总结。 +- 切换测试数据组时预设隔离浏览器的账号偏好并导航到 `/chat`;不将这一步计为账号切换 UI 验收。现有账号切换入口对缺少密钥的导入快照不列为可切换账号。 + +## 实际操作结果 + +| 用户操作 | 实际结果 | +| --- | --- | +| 首次进入桌面、打开聊天 AI、进入设置 | 正常;未出现先前首次使用页重载问题 | +| 从上游获取模型、测试连接 | 返回 3 个模型;显示连接成功、本次已测试图片输入;密钥输入框只显示已保存状态 | +| 本地检索首次下载 BGE Small | 实际下载 95,401,750 字节,约 18.13 秒,完成校验和加载;没有自动切换模型 | +| 点击使用模型、选择聊天、一键全选 | 3 个测试聊天全部选中,确认后保留选择 | +| 点击开启并开始整理 | 117 条消息 / 10 个片段;实际设备 CPU;完成后可搜索 | +| 智能搜索「项目费用调整」 | 找到改价记录;同一查询在关键词模式为 0 条;当前会话过滤后只返回海桥记录 | +| 点击搜索结果 | 进入原聊天上下文,合成样例的改价消息高亮可见 | +| 真实账号:选择一个聊天、最近 30 天、开始整理 | 65 条真实消息 / 19 个片段;本地审计建索引约 0.497 秒;明确提示读取已解密快照 | +| 真实账号:搜索「不在家吃饭」 | 智能搜索首批结果包括「我不回家吃」「我今天不回家吃」;关键词模式 0 条;点击进入原会话上下文 | +| Agent:查最终报价和交付日期 | 正确回答 15600 元、9 月 25 日,引用最终确认及变更消息 | +| 连续追问:相较之前改了什么、为什么 | 正确回答 12800 → 15600、9 月 18 → 25,原因是新增导出模块 | +| 明确授权后扩大到青禾项目群 | 正确回答另一个项目 8600 元、10 月 8 日,并用表格区分两个项目 | +| 处理中切换工具页、侧栏和大视图 | 后台继续;返回后能看到答案,历史和引用保留 | +| 引用预览、定位到聊天 | 显示发送者、时间、原文及上下文;定位后 Agent 对话固定并保留 | +| 停止 → 继续查找 → 处理中发送补充要求 | 停止状态可见;继续后补充版本 2 已应用,最终仅展开小林的字段确认职责 | +| 刷新 / 重新打开 Agent / 对话历史 | 之前 4 轮回答与处理记录可以重新打开;未冒充完整退出恢复测试 | +| 消息总结:当前会话最近 100 条 | 实读 41 条,约 14 秒完成;报价、日期、待办均与样例相符 | +| 自动总结规则:创建暂停规则、立即执行 | 规则保存为暂停;无新增内容时未产生额外模型请求。未据此宣称定时触发已验收 | +| 快照账号:创建已启用的关注提醒 | 被拒绝并提示「当前账号是导入快照,实时关注提醒不可用」,没有留下启用规则 | +| BGE Base:下载 → 暂停 → 继续 | 暂停发生于连接初期,显示已暂停;继续后看到 380 MB、33 MB/s,最终下载完成;下载中只有暂停入口,离线导入禁用 | +| 用量记录页 | 显示 23 次调用、成功/失败/取消明细、3 次失败、2 次用量未知,已知 Token 与后端记录一致 | + +语义结果中的候选不等于全部都相关,短消息和相邻上下文仍会带来一般性候选。本次小样例不能代表大账号召回效果或通用准确率。 + +## 在线用量与回答验证 + +以下为后端实际审计;调用次数包括兼容切换、格式纠错及取消请求。 + +| 场景 | 调用数 | 已知输入 Token | 已知输出 Token | 未知用量请求 | 用时 | +| --- | ---: | ---: | ---: | ---: | ---: | +| 首次查询价格和日期 | 5 | 14,300 | 4,127 | 1 | 39.040 秒 | +| 追问变更及原因 | 5 | 27,297 | 2,669 | 0 | 27.114 秒 | +| 扩大范围、跨项目对比 | 4 | 30,858 | 6,768 | 0 | 63.012 秒 | +| 停止、继续、追加约束 | 7 | 49,558 | 8,694 | 1 | 91.596 秒(含暂停等待) | +| 消息总结 | 1 | 2,872 | 1,606 | 0 | 14.074 秒 | +| 图片连接测试 | 1 | 251 | 642 | 0 | 未在此表单独测量 | +| 合计 | 23 | 125,136 | 24,506 | 2 | — | + +首次请求出现 HTTP 400 后兼容路径成功;后续出现过动作校验失败,自动纠错后完成,失败响应用量仍被记录。4 轮 Agent 最终回答中共 19 次来源引用,均映射到该轮实际读取的证据,未发现伪造来源 ID。验证来源 ID 有效不等于自动证明每句话的语义正确;以上事实另行按固定样例人工核对。 + +本地索引和搜索不计入在线 Token。真实账号本地查询两次记录约 12.8 / 15.3 ms,仅为本地检索调用耗时,不包含完整 UI 往返。无可靠单价,不推算费用。 + +## 实测发现与修复 + +1. 关注提醒失败后切换到自动任务,旧错误仍显示:切换工具时清除上一页的操作反馈。 +2. AI 服务连接测试的成功提示会带到其他设置页:切换设置页签时清除反馈。 +3. 智能搜索已经返回结果,但界面笼统显示「索引未建立」:现在明确写成「关键词索引」,并优先显示实际建立中状态,避免与语义索引混淆。 + +修复后 Windows 和 Mac 各 25 项相关 Vue 回归通过;Mac 生产构建生成 34 个路由成功。此前完整后端、运行库、媒体解析、冻结程序与三款模型 CPU 测试见兼容性文档,未重复计为本次用户操作。 + +## 续验:后台执行、完整退出与审计恢复 + +同日 20:30–20:52 继续在同一台 Mac、同一隔离桌面验收;以下新增消息均写入专用合成账号作为测试刺激,不是向真实微信发消息,也不代表微信实时数据源已验收。 + +| 场景 | 实测结果 | +| --- | --- | +| 定时总结到点执行 | UI 启用一分钟规则后,向测试聊天加入一条明确的新待办;到点生成任务 `bcb978274bb249768e1cc9135cead4da`,读取 42 条消息,约 9.38 秒完成,答案包含新截止时间和负责人 | +| 无新增消息 | 随后的多个调度周期仍只有该任务,没有新增模型请求 | +| 条数阈值 | UI 将规则改为累计 2 条;只加入第 43 条后等待超过一分钟,没有触发;加入第 44 条后生成一次任务 `7bd73adb12744fb792f34cd55e830769`,仅分析这 2 条新增消息,约 3.05 秒完成,游标推进到 `s:200044` | +| 最小化后台执行 | 通过真实桌面 preload 的 `minimize()` 调用应用现有 IPC,条数检测和模型请求继续完成;随后通过 macOS `open -a` 恢复应用查看结果。不是从系统菜单手动点击的验收 | +| 完全退出 | UI 将关闭行为设为「直接退出」,Agent 处理中通过真实 `close()` IPC 关闭;后台 20392 和 Electron 调试 29223 均停止监听,未用杀进程冒充正常退出 | +| 重开、继续同一轮 | 正常重新启动 Electron,任务 `60e60860f07c4a1a973b2f9d0d8293dd` 显示「应用已重启,点击继续处理」;UI 点击继续后同一 ID 完成,正确回答最新截止时间 9 月 9 日 16:00、小林负责,并保留原文引用 | +| RAG 重启与增量 | 再次重启后在普通聊天 UI 使用智能搜索「字段复核的最后期限」;首条是新加入的字段清单截止时间,随后两条是阈值测试新增消息,均标为语义相关。本轮没有重新下载模型或手动重建索引 | +| 通知持久化 | 两个新增总结分别只有一条通知事件;后端均收到投递确认,Electron 的去重记录重启后保留。不能据此证明通知中心点击成功 | + +续验发现并修复两项问题: + +1. Agent「工具与任务」内容遮住顶部「更多 AI 功能」菜单,真实点击被内容拦截。嵌入工具页使用局部层级,顶部菜单置于内容上方;Mac 侧栏及大视图均能正常点击进入 AI 设置。 +2. 完整退出时未返回的模型调用,重启后审计仍保留 `running`。后端启动时将上次进程遗留调用标为 `interrupted`,前端显示「已中断」,保留原有已知 / 未知用量,不补造结束时间、耗时或 Token。再次完整重启后,实际遗留记录已正确恢复。 + +新增真实在线请求 8 次:定时总结 1 次(输入 2,937 / 输出 1,046 Token),退出恢复的 Agent 轮次 6 次(已知输入 36,967 / 输出 7,339,2 次用量未知),条数总结 1 次(输入 505 / 输出 296)。累计 31 次调用,已知输入 165,545 / 输出 33,187 Token,4 次用量未知。Agent 活跃运行时间约 102.43 秒,不含退出等待;中断请求的准确用时未知。 + +修复后 Windows / Mac 均通过后端 24 项、Vue 26 项相关回归;Mac 生产构建成功生成 34 个路由。Windows 首次测试的 18 项断言通过,但 pytest 清理系统临时目录时遇到权限错误;改用独立工作区临时目录后,24 项完整运行退出码为 0。 + +新增证据位于 Mac `logs/mac-e2e-followup-audit.json`、`mac-e2e-followup-backend.log`、`mac-e2e-followup-tests.log`、`mac-e2e-followup-build.log`、`mac-e2e-17-count-summary.png`、`mac-e2e-18-interrupted-audit.png` 和 `mac-e2e-19-rag-after-restart.png`。已复核总结与审计截图;测试规则已暂停,关闭窗口行为已恢复为最小化到托盘。 + +## 仍未完成的验收 + +### 登录后的真实消息验收准备 + +用户已在自己的微信会话发送 `mac验收准备`,实时接口读取成功。检查发现低频会话的提醒上下文会为了补足 20 条而回溯多年;现已限制为最近 24 小时、最多 20 条,同时遵守用户显式指定的开始时间。新增回归覆盖时间窗口与更窄范围,Windows / Mac 的相关后端测试均为 25 项通过;Mac 已正常退出并重启加载修复。 + +已通过 Mac 实际 Electron 界面,在用户与自己的会话创建「Mac 验收:交付变更提醒」(10 秒检测)和「Mac 验收:两条新增消息总结」(阈值 2),均启用桌面通知、关闭媒体处理。持久化检查点已建立且没有触发历史数据分析,随后最小化应用,等待用户发送本次验收的两条新消息。此时尚不能将真实提醒和原生通知点击标记为通过;验收后需暂停这两条临时规则。 + +用户随后分两次发送「项目按原定时间推进。暂时不需要调整」与「原定周五赶不上了。改到下周二。请我确认新日期」,间隔 17 秒。应用最小化期间完成下列实际验收: + +- 第一条消息独立触发检测,结果为未命中,无提醒;当时未达到条数阈值,未生成总结。 +- 第二条消息独立触发检测,正确识别改期和日期确认要求,仅引用第二条来源,并保存一条提醒。上下文仅包含本日准备消息和两条测试消息,未读取多年以前的会话内容。 +- 条数规则累计两条后生成且仅生成一份总结,保留先说不用调整、后改至下周二的变化以及确认日期的待办。两条来源均映射真实消息。 +- 两条规则成功后游标推进至第二条消息;无新增消息时未继续调用模型。 +- 后端分别保存一条总结通知、一条提醒通知,Electron 均确认投递,桌面持久化去重键各一条。投递记录不等于用户点击验证,原生通知中心点击仍待确认。 + +本次共 3 次在线调用,均成功且用量完整:正常消息检测输入 412 / 输出 88 Token,用时 2.197 秒;总结输入 484 / 输出 269,用时 3.036 秒;改期检测输入 468 / 输出 149,用时 1.609 秒。合计输入 1,364 / 输出 506,总计 1,870 Token。累计验收 34 次调用,已知输入 166,909 / 输出 33,693,先前 4 次未知用量保持未知。证据保存在 `logs/mac-e2e-live-ai-audit.json`。 + +验收完成后,已在 Mac 实际界面分别暂停两条临时规则。另发现关注检测详情误用「总结已完成」且把命中结果交给总结组件,导致结果区空白;现区分关注检测和总结状态,明确展示「未发现符合关注条件的新消息」,命中时展示理由和可定位来源。 + +上述显示修复后 Windows / Mac 均通过 28 项界面回归,Mac 生产构建退出码为 0。实际打开两次检测历史,分别确认未命中说明与命中理由;点击命中记录「查看原消息」后,真实改期消息出现在聊天可视区。截图 `logs/mac-e2e-20-live-alert.png` 已视觉复核。此为应用内来源入口验收,不替代系统通知中心点击。 + +后续用户确认已登录微信后,实时状态接口实测 `available=true`、`connected=true`、`key_present=true`,错误为空,真实账号 UI 切换也成功。证据保存在 `logs/mac-e2e-realtime-after-login.json`。此前连接阻塞已经解除;尚需用新的真实消息完成提醒和通知点击,不能只根据连接成功标记这两项通过。下文保留此前阻塞诊断以便追溯。 + +- 此前实时连接阻塞已解除,真实新增消息提醒、条数触发与通知去重已完成上文场景;同次检测多条命中的合并通知尚未在这次真实消息场景覆盖。历史采样显示线程曾阻塞于目录枚举,未确认具体根因,不将其简单归因为模型问题。 +- 定时触发、完全退出恢复已完成上述场景;错过多个定时周期后合并补处理尚未实测。 +- 系统通知此前验证过真实 Electron show 事件;本轮未验证用户从 macOS 通知中心点击返回应用。Mac 远程管理拒绝账号的桌面控制认证,SSH 命令可用;已请求确认该账号的「观察 / 控制」权限。Apple Events 桌面查询超时、SSH 原生截图失败,未绕过系统权限。 +- 图片输入通过连接测试;PDF/Office/图片解析此前通过真实运行库测试,但本轮没有通过聊天附件 UI 跑完所有格式的 Agent 分析。 +- 只在 M4 / macOS 26.3.1 实机完成上述流程;Intel、其他系统版本、签名 DMG、系统休眠和长时间大数据压力未验证。 +- BGE Base 本轮暂停发生于实际文件传输之前;不能据此替代中途断网、磁盘不足和有字节续传等故障验收。 + +## 复验与证据 + +```sh +# 仅在新的隔离 output 下创建;已有同名账号会报错,避免覆盖。 +python tools/seed_ai_acceptance.py --output /path/to/isolated/output + +# 最后修复的回归 +cd frontend +npx vitest run tests/ai-panel.test.js tests/ai-source-navigation.test.js +npm run generate +``` + +Mac 测试目录 `logs/` 保存真实界面截图 `mac-e2e-01` 至 `mac-e2e-16`、脱敏汇总 `mac-e2e-audit.json`、测试结果 `mac-ai-final-tests.log`、构建结果 `mac-e2e-final-build.log`。实际聊天截图只留本地,不写入源码文档或发送给模型服务。来源定位和回答截图已进行视觉复核。 + +测试应用与模型保留在隔离目录,方便继续复验;自动总结测试规则为暂停状态。本文不将部分通过描述成「所有 Mac AI 功能已验收」。 diff --git a/docs/chat-agent.md b/docs/chat-agent.md new file mode 100644 index 00000000..5e65bcf8 --- /dev/null +++ b/docs/chat-agent.md @@ -0,0 +1,86 @@ +# 聊天 Agent + +跨平台运行策略、Mac 实机结果及未覆盖项目见 [macOS AI 兼容性与验收](ai-macos-compatibility.md)。 + +聊天页右上角 AI 默认进入对话模式;原消息总结、自动任务、关注提醒位于「工具」。侧栏和大视图共享状态。 + +## 使用 + +- 默认跟随当前聊天;点图钉可固定当前 AI 对话。查看来源时自动固定,避免原文跳转带走正在进行的 AI 对话。 +- 可连续提问,处理中发送的内容记为补充,在下一次动作前应用;输入法确认键不发送,Shift+Enter 换行。 +- 「读取范围」可手动选择会话;明确要求搜索其他群或点名会话时才扩大授权,范围保留在当前 AI 对话。 +- 历史可以新建、重命名和删除;与微信聊天记录分开保存,账号之间隔离。 +- 「设置 → AI 服务 → Agent」调整工具、模型、媒体与时间上限。适中默认为 12/24/8/300 秒,深入默认为 36/72/24/900 秒。 +- 达到限额、停止或应用重启后,点击继续查找会保留已有证据并开始新的额度。缓存页无需重复调用视觉模型。 +- 用量关联每轮运行 ID;未返回的上游用量标为未知,不估算费用。长对话的背景压缩也计入模型用量。 + +## 数据与接口 + +业务数据位于现有 output/ai/ai.sqlite3,使用 agent_thread、agent_run、agent_settings 类型。阶段检查点位于 agent_checkpoints.sqlite3,节点只保存运行标识;密钥执行时解析,不进入检查点。 + +`/api/ai/agent` 提供 settings、threads、threads/{id}/messages、runs/{id}、runs/{id}/stop、runs/{id}/continue 和 events。消息带客户端 request_id 去重;运行中再次发送进入同一任务并增加补充版本。 + +SSE 使用稳定事件 ID,支持 Last-Event-ID 重连,前端同时定期查询。完全退出后未完成任务标记为中断,不自动产生新的模型费用。 + +只读工具封装既有消息搜索、范围分页、消息上下文和媒体定位,不提供 SQL、任意文件、Shell 或消息发送能力。查询参数由后端限制到已授权账号、会话与时间范围。 + +具体信息搜索复用关键词与语义混合检索;语义索引不可用时退回关键词并展示原因。索引可能落后于实时聊天;回答前对涉及的近期会话补读最近消息,分页未完成或数据源不可用时向模型提供覆盖缺口。未解析图片中的文字不承诺可以直接检索。 + +## 验证 + +- 后端测试:`tests/test_ai_agent.py`,以及原 AI 服务、接口和供应商测试。 +- 前端测试:`frontend/tests/chat-agent.test.js`,以及原 AI 面板和自定义选择器测试。 +- 自动测试全部使用模拟响应;真实模型验收应使用用户指定的问题和会话,查看本次用量记录。 + +## 执行对话流与协议修复 + +工具调用按时间显示,展开可查看范围、页码位置和覆盖情况;关键发现穿插简短进展说明。完成后默认折叠过程,用户上翻阅读时保留展开状态,回到底部再收起。历史每轮可以重新加载过程。 + +决策优先使用指定工具调用。上游明确拒绝强制工具选择时改用 JSON 动作协议;每次决策最多三次请求,纠错与兼容切换也计入额度。消息 offset 始终表示消息位置,会话选择单独使用 conversation_offset,避免遗漏后续页面。 + +工具成功后保存证据、待执行队列和查询缓存,重试从失败步骤继续。响应先提取 Token 用量再做格式与来源校验,失败审计保留分类、字段路径、结束原因和诊断 ID,不保存原始响应或密钥。旧记录未知用量不回填。 + +运行接口增加 timeline、error_info、coverage_warnings;对话接口增加 runs 摘要。过程记录带 id、seq、revision,SSE 与查询快照按记录修订号合并。工具源不可用和未完成分页都会显示覆盖说明。 + +2026-09-08 真实验证使用用户指定的同一会话及“最近讨论了哪些重要的事?”:旧任务恢复完成,新增 5 次请求,已知输入 104,579 / 输出 11,711 Token(1 次兼容请求未返回用量);新对话从头执行完成,读取 92 条消息,用时约 51 秒,6 次请求,已知输入 21,806 / 输出 3,422 Token(1 次未返回用量)。恢复任务沿用原 200 条证据。复现与兼容调试请求也关联原任务审计;这些数值不包含旧任务中无法追补的未知用量,不估算费用。 +# 回答依据与检索标记(2026-09-08) + +AI 助手的搜索步骤显示实际返回的检索方式:关键词与语义混合检索,或退回关键词检索及覆盖说明。展开步骤可看本页两路命中数,同一消息可同时计入两路;聊天搜索结果也显示双路匹配标记。 + +每轮“回答依据”分别展示已找到的消息、本次回答请求包含的原文来源、回答实际标注的有效引用。展开可查看原文摘要、检索来源、是否放入请求及引用状态,并定位原消息。来源按 20 条展开以控制长列表渲染量。 + +后端在组装回答请求时按统一输入预算保存原文来源清单,调用成功后标记完成;调用未完成、上下文遗漏和旧数据缺少追踪记录均明确说明。原先的 90,000 字符拼接方式已由下面的分段处理替代。来源清单区分原文和分段摘要;它证明请求包含来源,不证明模型一定采纳内容。后续读取上下文保留此前的关键词/语义命中信息。 + +验收:相关 Agent 后端 29 项、Vue 21 项回归通过。使用 `frontend/tests/fixtures/agent-evidence.html` 的虚构资料检查真实组件展开、检索标记、引用状态及布局,不调用云端模型。 + +## 大范围分析与当前对话上下文(2026-09-09) + +模型解析对象、条件和时间,填写本机时区的 Unix 秒。后端验证起止顺序、截止时间与授权会话,在面板显示实际查询范围。没有有效日期时不会自行替换成固定的三天或七天。 + +处理方式包括具体信息检索、概览、时间线、精确统计和完整提取。具体信息复用关键词与语义混合检索,可继续读原文前后文。其他四种方式由程序遍历授权范围内的所有分页;模型不能通过提前选择回答跳过未分析分页。统计按消息身份去重后由 SQLite 计算,提供每日总数、发言人排行及每日各会话发言人明细;语义提取始终称为“分析结果”。 + +分段按请求预算拆分消息与附件文字,保存原文字符位置;相邻段带少量仅作背景的重叠内容。每段提取带来源的发现,全部发现单独存档,再逐层合并,较长的合并输入会继续分组。疑似冲突先取回对应来源前后文,仍不能确定的事项继续标注待核实。完整遍历不代表模型的语义提取绝无遗漏;完整结果页展示的是全部已提取结果。 + +运行 JSON 仅保存分页位置、当前分段、合并层级和覆盖状态。`agent_material` 保存任务原文与会话、日期、来源索引,`agent_piece` 保存带版本的分段、发现、合并结果、长输入片段、工具结果和旧过程记录。分页器复用同一来源游标,去重编号使用固定缓存的磁盘临时 SQLite 库。消息正文和中间结果不随每次状态更新重新写入。 + +每段和每个合并步骤完成后保存检查点。停止、重启、上游失败或本轮额度耗尽时,继续操作从未完成部分恢复;已完成分段不再调用模型。用户修改问题或范围会产生新版本,旧版本不参与新分析。缩小范围时同时清理不符合条件的原文,所有资料接口校验账号、会话范围修订号和任务版本。新建 AI 对话不会带入其他对话背景,没有新增跨对话长期记忆。 + +当前对话按体积整理较早消息,保留目标、对象、日期、纠正和引用;原始用户消息、助手消息仍在本地。“第二件事”依据近期回答和当前目标理解,“那上周呢”由模型更新日期。超长用户输入先分段提取与合并要求,前后端均限制为 1 MiB UTF-8;本次不新增上传入口。 + +默认输入预算 12,000,未知分词器时用 UTF-8 字节上界保守计量文本;图片独立预留预算,不把 Base64 当成文本 Token。系统提示、工具定义、历史、摘要、原文、纠错余量全部计入输入预算。已知模型窗口可在 AI 服务配置中填写,系统另外预留输出与封装空间;未知窗口不根据模型名称猜测。实际 Token 仍以服务端用量为准。上游明确报告上下文不足或分段输出截断时缩小未完成分段,重试受次数和本轮额度约束;容量不足时保留进度并明确报错。 + +`GET /runs/{id}/materials` 接受 `kind=sources|findings|statistics`、`offset`、`limit`、`query` 和 `version`;`GET /runs/{id}/materials/{source}` 按字符位置读取原文。Agent 也可以使用 `search_material`、`read_material` 和 `read_results` 回查。面板在“查看全部来源与详细结果”中分页展示资料,分别显示读取量和分析量,不在总量未知时显示百分比。实时源失败、快照限制、范围未完成和旧记录覆盖未知均单独说明。 + +自动验收见 `tests/test_ai_agent_context.py`、`tests/test_ai_message_pages.py` 和 `frontend/tests/agent-materials.test.js`,覆盖多会话、多页、范围修改、原文引用、附件长文本、重启恢复、超限缩小、8K 窗口、接口隔离及十万条合成消息。十万条流式读取包含重复消息,测试断言 Python 分配峰值低于 8 MiB、首批读取后的进程常驻内存增长低于 32 MiB;十万条原文落库另与确定性计数基准对照。这些数值是测试输入下的工作集约束,不代表所有附件解析库的内存上限。 + +真实验收工具为 `tools/verify_agent_context.py`:读取现有模型配置,在独立状态目录保存任务与用量;支持 `--resume-run` 和连续 `--followup`,不会将密钥复制到验收记录,也不会修改用户的 AI 自动任务。 + +### 本次验收结果 + +- 相关后端回归 123 项、前端回归 54 项通过;最终计数展示调整另通过 14 项上下文回归,前端生产构建通过。 +- 当前配置模型完成真实群聊 228 条消息的概览,以及沿用同一日期范围的连续追问。概览保存 19 个分段、50 条分析发现;追问保存 11 个分段、38 条分析发现。列表回答由程序标明完整结果数量,再展示重点摘要,避免把摘要中的几项当成全部结果。 +- 两轮资料及来源元数据分别为 68,220 / 68,023 UTF-8 字节(不含媒体字段),均超过默认单次 12,000 输入预算;所有分页完成后才生成回答。回答中的 20 / 25 个不同引用均存在于对应任务资料库,时间位于实际查询范围内。 +- 测试中的实时源不可用,因此实际使用已解密快照;该限制在覆盖状态和回答中展示。没有将快照覆盖冒充实时完整覆盖。 +- 调试、失败、兼容重试、上下文整理、恢复与最终回答刷新均计入原任务审计:累计 79 次模型调用,已知输入 124,456 / 输出 225,201 Token,另有 2 次未返回用量。上述数据包含修复过程,不作为正常单次问题的费用或耗时基准。 + +真实记录保存在独立的 `tmp/agent-context-live-20260909/verification.json`。记录核对覆盖范围、引用存在性和用量一致性,不将这些程序检查当作语义提取绝无遗漏的证明。 diff --git a/docs/chat-ai.md b/docs/chat-ai.md new file mode 100644 index 00000000..330c48f6 --- /dev/null +++ b/docs/chat-ai.md @@ -0,0 +1,62 @@ +# 聊天 AI 总结与关注提醒 + +跨平台运行策略、Mac 实机结果及未覆盖项目见 [macOS AI 兼容性与验收](ai-macos-compatibility.md)。 + +## 使用 + +1. 打开「设置 → AI 服务」,选择服务商并填写地址和密钥,离开输入框后自动从上游获取模型,也可点击「从上游获取」刷新。通过搜索和下拉框选择模型后保存,无需先填写模型名称。服务商仅预填地址和协议,不内置模型名单。 +2. 打开已有配置会自动刷新模型列表并保留已选模型;模型不在列表中会提示重新选择。新配置优先选择上游明确声明图片能力的模型,否则选择上游返回的第一个模型。上游未提供能力信息时,需要手动确认图片能力并测试连接。指定默认文本模型及默认视觉模型。连接测试会产生推理调用,获取列表不会。 +3. 进入群聊或好友会话,点击右上角 AI。选择一个或多个会话,以及最近条数、过去小时数或起止时间,开始总结。 +4. 在「自动任务」中建立间隔、每日定时或新增消息条数规则;在「关注提醒」中填写自然语言关注条件并选择检测频率。勾选启用后才开始后台执行。 +5. 在「历史记录」中查看结果和命中理由,点击来源可定位原消息。规则和任务均保存在本机,切换页面及最小化到托盘不影响执行。 + +云端服务会收到选定的消息和参与分析的媒体内容。也可以使用本机兼容服务。密钥仅由后端管理,不回传浏览器,不进入模型工作流检查点;默认不启用外部 LangSmith 追踪。 + +上游不支持模型列表或暂时连接失败时,提供重试和「手动输入(备用)」。修改服务地址或协议后,需要重新输入密钥才能获取模型,已保存密钥不会自动发送到新地址。 + +### AI 服务预设 + +「新增服务」提供 14 项预设:DeepSeek、Claude、Kimi、OpenAI、Google Gemini、通义千问(阿里云百炼)、智谱 GLM、豆包(火山方舟)、硅基流动、OpenRouter、Groq、Ollama、LM Studio 和自定义。可以按名称或别名搜索,例如「谷歌」「千问」「GLM」「火山」;自定义入口排在列表末尾。 + +预设会填写配置名称、API 地址和协议,地址仍可修改。国内平台默认使用国内通用 API 地址,不是 Coding Plan 专用入口。Gemini 使用 `/v1beta/openai` 兼容接口。切换预设会清空密钥、模型选择和图片能力,需要重新确认;不会复用原服务的密钥。 + +选择 Ollama 或 LM Studio 前,请先启动其本地服务并准备好模型。默认地址分别为 `http://127.0.0.1:11434/v1` 和 `http://127.0.0.1:1234/v1`,未启用鉴权时密钥可留空;选择后会自动获取模型。启用鉴权时需填写密钥,端口不同则修改地址。连接失败时检查服务、地址、端口和鉴权设置,再点击「从上游获取」。也可勾选「手动输入(备用)」填写真实模型名称后保存;保存配置不代表连接已验证。 + +## 内容与运行边界 + +- 支持文本、引用、卡片标题、已有语音转写、JPEG/PNG/WebP/GIF(首帧)、PDF(含扫描页)、DOCX、XLSX、PPTX、TXT、MD、CSV。 +- 自动转写新语音、视频理解、旧版 Office 文件及压缩包不在本版范围。 +- 附件须在本机可读取;缺失、损坏、加密及超限都会显示处理状态。单附件默认 20 MB,可调整至 200 MB;Office 解压总量限制为 512 MB。 +- 长资料分段处理,不静默截断。图片缩放至最长边 1600 像素后送入视觉模型。 +- 导入快照可以总结已有消息;实时关注需要可用的实时数据源。 +- 检测默认每分钟一次,可选 10 秒、5 分钟和自定义间隔。无新增消息不调用模型;新增条数阈值按规则中所有会话合计计算。 +- 新规则从启用时建立监控基线。完全退出应用后停止;重新启动恢复检查点,错过的定时执行合并为一次。 +- 自动任务失败退避重试,连续失败 3 次暂停对应规则。鉴权错误直接暂停。失败会话不推进成功检查点。 +- 手动重试保留原消息时间范围,重新解析当前模型配置并记录版本;配置变化时清除旧模型分段结果,附件补齐后也会重新分析发生变化的内容。 +- 桌面通知默认展示会话名和提醒理由,可关闭或隐藏内容。操作系统禁用通知时,应用内仍保存结果。 + +## 开发与验证 + +后端公共能力位于 `src/wechat_decrypt_tool/ai/`,接口位于 `routers/ai.py`。LangChain 适配模型;LangGraph 编排任务,SQLite 保存业务记录与检查点。新功能应通过公共模型服务引用全局配置。 + +模型发现参考 [Codex++ 的模型目录获取逻辑](https://github.com/BigPizzaV3/CodexPlusPlus/blob/48d43158688f5096c7059c690f8cd1daab340681/crates/codex-plus-core/src/model_catalog.rs):统一接口地址,兼容数组和 `data/models/items` 包装、`id/model/name` 标识并去重。本项目另外处理 Claude 的分页及图片能力元数据,不根据模型名字猜测能力。切换凭据时清空旧列表,并忽略迟到响应。 + +运行依赖通过 `uv sync --extra build --extra voice-transcription` 安装。前端需使用符合当前 Nuxt/Vite 要求的 Node 版本;Node 18 无法构建当前依赖。 + +Windows 源码启动使用 `desktop/resources/native-core-source-windows.json` 固定的 2.4.0 官方安装包:首次下载并校验整个安装包,只提取三个只读原生组件,不执行安装程序。组件再次校验摘要、有效期、只读能力和源码运行身份后缓存复用。这样避免旧的独立源码运行时缺少只读声明导致启动失败。先在 `desktop` 安装 npm 依赖,再以 Node 22.12 以上运行 `node scripts/dev.cjs`;Nuxt 子进程复用同一个 Node。 + +模型测试均可使用模拟服务,不需要真实密钥: + +```text +python -m pytest tests/test_ai_services.py tests/test_ai_api.py +npx vitest run tests/ai-panel.test.js +node --test desktop/tests/ai-notifications.test.cjs +``` + +`desktop/scripts/smoke-ai-notifications.cjs` 由 Electron 执行,用合成消息验证原生通知,结果写入 `logs/ai-notification-smoke.json`。不读取微信数据或调用模型。 + +## 使用量审计 + +设置 → AI 服务可查看逐次调用记录,包含调用时间、模型与配置版本、账号、任务 ID、重试次数、图片数量、输入和输出 token、状态,并可导出已加载记录为 JSON。每次重试独立计数,结果格式校验失败仍记录供应商已返回的用量。鉴权失败、超时或取消未返回用量时显示“未知”,不能视为免费;强制退出时未结束的记录显示“执行中 / 中断后未完成”。账单金额以服务商为准。 + +审计不保存密钥、提示词、聊天正文或原始供应商异常。旧记录不具备的字段显示为未知。元数据接口(如获取模型列表)不计入模型推理调用数。 diff --git a/docs/licenses/lobe-icons-MIT.txt b/docs/licenses/lobe-icons-MIT.txt new file mode 100644 index 00000000..1dd53d2a --- /dev/null +++ b/docs/licenses/lobe-icons-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 LobeHub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/local-semantic-search.md b/docs/local-semantic-search.md new file mode 100644 index 00000000..852f48f6 --- /dev/null +++ b/docs/local-semantic-search.md @@ -0,0 +1,165 @@ +# 本地语义检索 + +## 使用方式 + +在 **设置 → AI 服务 → 本地检索** 按两步准备:选择已下载的模型,再选择聊天与时间范围,点击“开启并开始整理”一次完成保存、启用与建立索引。默认关闭;打开设置、下载完成和 Agent 提问都不会自动扫描全账号。 + +- 模型文件由本机账号共享,模型选择、索引和处理记录按微信账号隔离。 +- 模型先下载再选择;“更换模型”打开二级管理弹窗,选择先保留为草稿,点击主按钮后才建立新模型索引,成功后切换;此前的索引继续服务。 +- 从聊天打开范围选择时预选当前聊天;通用设置不默认全选。支持最近 30 天、90 天、自定义日期和全部历史。 +- 普通搜索新增“关键词/智能搜索”。默认关键词;智能搜索融合 FTS5 和语义候选,保留原消息定位、会话、发送者、时间及消息类型过滤。界面展示相关候选数量,相似度不代表正确率。 +- Agent 在已授权会话中自动使用可用索引;索引范围不能扩大 Agent 权限。结果说明未覆盖范围、数据新鲜度及候选截断,GPU 回退原因进入工具记录。 +- 关键词搜索和本地向量推理不调用云端模型。**Agent 生成回答仍使用已配置的 AI 服务,相应聊天证据会发送给该服务。** 自动总结、关注提醒的判断流程不变。 + +模块导航统一为“模型服务/本地检索/Agent/用量记录”,附简短用途,支持方向键切换。主页面只保留两步准备与一个主按钮,未满足条件时在按钮旁说明原因。暂停且配置未变时显示“继续整理”;运行中显示真实阶段、数量和用时,完成后提示从哪里使用智能搜索。GPU、自动更新、关闭功能和清理均在高级设置中,CPU 无需安装组件即可使用。 + +模型卡片提供下载、暂停、继续、重试、离线导入和删除。关闭功能保留模型与索引并停止后台任务;清理索引不删除微信消息。正在被账号配置或现有索引引用的模型不能删除。 + +尚未选择模型时展示推荐模型,并明确要求下载后点击“使用此模型”。如果要删除最后一个模型,可先关闭功能、清理索引以解除该账号的模型引用;其他账号仍在使用时仍会阻止删除。 + +## Hugging Face 固定来源 + +清单位于 `src/wechat_decrypt_tool/resources/local_search_models.json`,包含完整提交 SHA、逐文件大小与 SHA-256、许可证、池化方式、前缀、维度和输入限制。运行时不追踪 `main`。 + +| 模型 | Hugging Face 仓库与固定提交 | 下载字节数 | 向量 | +| --- | --- | ---: | ---: | +| BGE Small 中文,推荐 | `Xenova/bge-small-zh-v1.5` · `75c43b069aac4d136ba6bc1122f995fedcfd2781` | 95,401,750 | 512 | +| BGE Base 中文 | `Xenova/bge-base-zh-v1.5` · `71e50dc531959f9e04ebf190ea25b00261a0a186` | 407,503,264 | 768 | +| Multilingual E5 Small | `intfloat/multilingual-e5-small` · `614241f622f53c4eeff9890bdc4f31cfecc418b3` | 492,421,556 | 384 | + +BGE 原作者为 **BAAI**,ONNX 转换来源为 **Xenova**;E5 来源为 intfloat。三个仓库清单标注 MIT。只下载 `onnx/model.onnx`、必要的分词器和配置,不下载训练权重或其他量化版本。当前三份清单均为六个文件,FP32、最大输入 512 Token。 + +BGE 使用 CLS 池化和 L2 归一化;查询加中文检索前缀,文档不加。E5 使用 attention mask mean pooling、L2 归一化及 `query: ` / `passage: ` 前缀。 + +官方下载 SDK 固定为 `huggingface-hub==1.27.0`。所有请求显式传入 `token=False`,关闭隐式凭据与遥测,不读取用户其他工具的 HF 登录状态。匿名模式支持当前收录的公开、非 gated 资源;**不代表 Hugging Face 所有模型均免登录**。接口行为参见 [官方下载接口](https://huggingface.co/docs/huggingface_hub/package_reference/file_download)与 [gated 模型说明](https://huggingface.co/docs/hub/models-gated)。 + +下载在独立工作进程中执行,全局单模型串行、模型内逐文件下载。HF 的 CDN 重定向允许通过,长期状态只保存仓库、提交和文件标识。没有第三方镜像切换。每轮恢复共享最多五次尝试;429 使用服务端等待时间,临时错误有限退避,CDN 访问拒绝重新解析固定地址。版本不存在、源权限变化、磁盘不足和校验失败单独提示。SDK 内层重试关闭,防止预算叠加。 + +暂停保留 HF 临时数据,继续复用已校验文件;“取消并清理”只删除本应用的模型版本目录。下载完成需通过完整哈希、模型加载和小样本推理才可选择。完整模型启动只访问本地文件;损坏文件不能伪装成 CUDA 错误。 + +### 离线导入 + +在可联网电脑准备表中**同一提交版本**的清单文件,保留 `onnx/model.onnx` 等目录结构。目标电脑点击模型卡片“离线导入”,填写该目录;应用校验固定文件哈希并执行本地推理。无需 HF Token,不执行模型仓库代码。 + +NVIDIA 组件离线导入目录需包含当前 CPython 架构对应的 ORT GPU wheel,以及四个 NVIDIA wheel,文件名和哈希见 GPU 清单。不接受任意模型、任意 ONNX、仓库脚本或第三方 CUDA 安装包。 + +## CPU / NVIDIA 兼容矩阵 + +CPU 随基础程序打包:Windows 使用 `onnxruntime==1.28.0`,macOS 使用 `onnxruntime==1.23.2`(同时有 macOS 13+ arm64 / x86_64 wheel)。分词器固定 `tokenizers==0.23.1`,向量扩展固定 `sqlite-vec==0.1.9`。Mac 的自动模式使用 CPU,隐藏 Windows NVIDIA 组件入口。详见 [macOS AI 兼容性与验收](ai-macos-compatibility.md)。 + +Windows x64 GPU 加速组件独立于 CPU 和语音 CTranslate2 环境,清单位于 `src/wechat_decrypt_tool/resources/local_search_gpu.json`: + +| 组件 | 固定版本 | +| --- | --- | +| ONNX Runtime GPU | 1.26.0,CPython 3.11 / 3.12 / 3.13 Windows x64 | +| CUDA Runtime | 12.8.90 | +| cuBLAS | 12.8.4.1 | +| cuDNN | 9.8.0.87 | +| cuFFT | 11.3.3.83 | +| 应用采用的 Windows 驱动基线 | 572.61 或更新 | + +CPython 3.11 对应组件下载量为 **1,671,874,915 字节**;解压还需要额外磁盘空间。组件来自官方项目的 PyPI wheel,与检索模型的 Hugging Face 来源分开管理。wheel 内许可证随组件保留。 + +驱动基线采用 [CUDA 12.8 Update 1 对应驱动](https://docs.nvidia.com/cuda/archive/12.8.1/cuda-toolkit-release-notes/index.html),并非宣称低于该版本的所有 CUDA 12 程序均不能运行。本应用保守回退 CPU。ORT 1.27 起默认 GPU 包切换至 CUDA 13,因此不能直接升级 GPU 包;见 [ORT CUDA 要求](https://onnxruntime.ai/docs/execution-providers/CUDA-ExecutionProvider.html)。 + +| 场景 | 当前验证情况 | +| --- | --- | +| RTX 4070 SUPER,驱动 596.49,Windows x64 / Python 3.11 | 三个模型真实 CUDA 推理通过 | +| 同机强制 CPU | 三个模型通过;固定样例检索通过 | +| 打包后的 Windows EXE | CPU、外置 CUDA 组件、sqlite-vec 扩展通过冒烟测试 | +| RTX 30 系 / RTX 50 系 | 目标支持范围,尚无对应实机,标记未验证 | +| 干净 Windows / 无独显实体电脑 | 尚未实机验收;已覆盖无组件与模拟 GPU 故障的 CPU 路径 | +| Python 3.12 / 3.13 GPU wheel | 清单已固定,未进行对应解释器实机验收 | +| macOS 26.3.1 / Apple M4 / CPU | 三个模型匿名完整下载、推理与混合检索、冻结程序推理、源码桌面启动通过;真实账号端到端流程尚未验收,见验收记录 | +| Intel Mac / Linux CPU | 未实机验收;Intel Mac 已核对依赖 wheel;首版不提供其他 GPU 后端 | + +设备策略为自动、CPU、NVIDIA GPU,多 GPU 可选设备编号。检查包括驱动基线、组件加载、所选模型真实推理,以及 profiling 中实际执行的 CUDA 节点;仅有 Provider 名称不算成功。CPU/GPU 同一份 FP32 模型,GPU 关闭 TF32,不修改系统 PATH,也不要求全局 CUDA Toolkit。 + +GPU 进程故障、超时、缺库或显存不足时销毁故障进程,在独立 CPU 进程重做未提交批次;该应用会话暂时禁用故障 GPU,手动重新检测或重启后可再尝试。用户取消不触发回退,CPU 失败暂停并保留游标。GPU 回退成功仍是智能搜索;本地推理整体失败才回到关键词分页。 + +## 索引与恢复 + +- 主操作区提供“从头整理”,按页面当前选择的模型、聊天和时间范围重新生成全部向量;暂停任务也可选择放弃续传、从头处理。模型变化时主按钮显示“使用新模型从头整理”,普通建立接口也会自动创建独立索引,绝不复用旧模型向量;新索引失败时保留旧索引,完成后才切换。 +- SQLite 业务记录保存配置、下载任务、索引任务和本地审计;账号使用独立 sqlite-vec 数据库。索引 generation 隔离模型切换,数据与游标在同一事务提交。 +- 内容包括文字、引用、卡片标题、已有语音转写和已缓存的本地附件提取文字。新的语音识别、OCR、视觉描述和视频分析不会由建索引触发。旧版只保存混合视觉输出的附件缓存不会直接冒充本地提取文本。 +- 相邻聊天按会话及十分钟间隔组织片段,目标 384 Token、重叠 64 Token,保留逐消息来源映射;超长正文分段。 +- 首次任务固定截止时间。手动整理和每分钟自动更新都默认增量读取最近提交截止时间前十分钟起的窗口,处理同秒与短期补写;按聊天复用已覆盖范围,新增聊天或向前扩展历史时补齐对应范围。重复保存相同设置不改变配置版本和断点,设备等偏好变化不使已有覆盖失效;缩小范围清理后同步覆盖记录,重新扩大时补回。转写/本地附件缓存版本变化时重新核对选中范围,每天另做一次范围校对。长期历史补写可能等待下一次校对;界面显示本次整理原因与已复用消息数。 +- 未改变内容不重复生成向量;原文变化时重建相关片段,保留相邻未修改消息。缩小范围立即约束检索并清理被移除部分。 +- 后台建索引单并发、每批最多 8 个片段、CPU 两个推理线程;交互查询优先于后续后台批次。语音模型处于活动状态时,后台索引改用 CPU 让出 GPU。空闲五分钟释放模型进程。 +- 每个会话在专用线程内保持连续消息流,正常翻页不重新扫描前缀;SQLite 连接、原生游标的推进和关闭在同一线程内完成。新断点将最后提交时间及该秒已提交消息身份与数据一起事务保存,恢复直接从该时间读取并跳过已保存身份,保留同秒尚未提交消息。旧版只有 offset 的断点首次恢复仍需跳过一次前缀,下一次提交后自动迁移。 +- 默认自动批量(`read_batch_size=0`)。总内存不超过 4 GiB 或可用内存不足 1 GiB 时最多 100 条;总内存不超过 8 GiB 或可用内存不足 3 GiB 时最多 500 条;其余情况自动使用 1,000 条。高级设置可选 100/500/1,000/2,000 条,手动选择仍受内存保护,每批重新检查。不依赖 NVIDIA 显卡是否存在。 +- 单批累计文本达到 128,000 字符时提前处理,避免少量长文本积累过多片段;保留完整单条消息,因此此阈值不是进程内存的硬上限。推理仍按最多 8 个片段执行,不随读取批量扩大显存需求。低内存策略通过模拟测试,其他硬件、系统和磁盘速度仍需实机验证。 +- 读取数量约每 250 ms(有新消息时)上报一次,页尾立即上报;SSE 每秒传递事件,1.5 秒轮询兜底。界面直接更新读取数、片段数、实际批量和已保存进度,较旧事件或轮询响应不得让进度倒退。设备状态不变时不重复写入相同事件。 +- 最小化继续处理;退出停止。重启恢复此前运行任务,主动暂停保持暂停。数据和断点仍在同一事务提交,读取中的数量不用于恢复;失败或暂停只保留已提交批次,大批写入期间每 100 条检查取消。恢复时会重读尚未保存的一批。 +- `members(source,chunk)` 索引保证按原消息查找关联片段时不扫描整张关联表,适用于新建及已有索引。批量去重和关联查询在后台线程执行。 +- 关键词及向量分别最多召回 200 条,按来源去重、RRF(常数 60)融合。查询票据固定账号、权限版本、范围及候选顺序,有效十分钟,最多缓存 32 次查询。检索文本返回前再次按原消息权限和过滤条件约束,不返回片段内越权相邻原文。 +- 清理索引不会删除微信记录,账号删除清理账号索引和业务记录;SQLite 文件空闲页可能保留以供复用。 + +接口前缀 `/api/ai/local-search` 包含 `status`、`settings`、`conversations`、模型操作、`device/list`、`device/recheck`、组件操作、索引任务和 `events`。修改操作仅允许本机访问。SSE 配合 1.5 秒轮询兜底,重连支持 Last-Event-ID。`/api/chat/search` 增加 `retrieval_mode` 和 `search_ticket`,原定位字段保持兼容。 + +## 2026-09-08 验收记录 + +### 真实账号完整流程复测(效率与完成状态修复后) + +- 使用用户已选择的两个聊天、最近 30 天、BGE Small 中文和 CUDA,通过正在运行的桌面应用本地后端完成重建;未扩大聊天范围。`tools/verify_local_search_live.py` 可复测重建、暂停继续、检索与重复检查,报告仅记录统计量,不保存聊天正文。 +- 本轮重建处理 6,622 条消息,生成 554 个片段,任务计时约 10.6 秒,实际设备为 CUDA。在保存 1,000 条消息后暂停,再继续同一任务,最终完成;读取数和生成数均有中间进度。 +- 检查落库向量全部有限且已归一化,没有非空消息缺少片段关联。两个聊天分别通过真实本地搜索接口的原文命中前十、会话和类型过滤、分页不重复,以及原文定位接口返回目标消息的检查。 +- 再次完整检查处理 6,622 条未变化消息,本次生成 0 个片段,现有索引仍有 6,622 条消息和 554 个片段。修复设置页将“本次生成”误写为索引总量的提示;现在独立显示当前索引总数,无变化时说明复用已有数据,空索引不再提示可搜索。 +- 实际 Windows 桌面设置页已复核:自动更新继续读取新增聊天,显示本次检查/生成数量及当前索引总量;因此用户稍后看到的总数可能比上述测试快照更多。 +- 修复同一片段的多条消息共享向量时,原文精确匹配被相邻消息挤到后面的排序问题,并增加回归。 +- 最终相关后端 70 项回归通过;本地检索 Vue 30 项通过。CPU 补测三款模型,每款使用 48 条合成消息及 8 个标注查询,混合 Recall@20 均为 1.00;该小样本结果不代表所有真实聊天的准确率或其他实体机性能。 +- 本地报告:`tmp/local-search-live-e2e.json`。本轮验证的是源码桌面应用与当前选定范围;其他显卡实体机、干净系统与安装包仍需分别验收。 + +### 连续读取与大数据量 + +`tools/benchmark_local_search_reading.py --messages 300000` 使用独立临时目录中的合成 SQLite 聊天,复用实际快照读取、消息解析和索引存储代码;不读取真实账号。向量使用固定的 512 维数据,以隔离数据库吞吐,结果不代表完整模型推理或实时微信数据库速度。 + +- 3,399 条相同消息:旧式 100 条 offset 分页打开数据源 34 次,遍历 59,532 条;连续读取只打开 1 次,遍历 3,399 条,结果校验一致。最终测量读取耗时 0.97 秒 → 0.19 秒。 +- 30 万条合成消息全部读取并写入索引,来源只打开 1 次、遍历 30 万条;最终测量耗时约 156 秒,进程峰值 RSS 约 106 MiB,较本轮开始增加约 42 MiB,首次进度约 0.21 秒,共 594 次进度回调。此结果仅代表当前 Windows 实机,不作为其他电脑的速度承诺。 +- 回归覆盖同秒消息、无 server ID、跨页重复、动态缩小批量、长文本提前分批、读页中途上报、推理失败恢复、事务中途取消、低内存及系统信息读取失败。前端覆盖实时增长、轮询旧响应和旧事件防倒退、批量配置及草稿隔离。 +- 本次相关后端回归 115 项、前端回归 51 项通过;最终修改后的本地检索前端 28 项再次通过。 + +验证目录独立于真实账号:`tmp/local-search-validation`,已加入 Git 忽略。未向云端发送聊天,也未消耗回答模型 Token。 + +### 完整匿名下载及模型对照 + +三款模型在新的应用模型目录中完整匿名下载,通过逐文件 SHA-256、CPU 加载及推理。首轮下载含验证耗时分别约 **15.1 / 20.1 / 25.1 秒**,该网络速度不能代表其他用户网络。 + +| 模型 | CPU / GPU 最大向量差异 | 与 BAAI 原始权重对照 | +| --- | ---: | --- | +| BGE Small | 1.565 × 10⁻⁷ | 分词一致、最大差异 2.012 × 10⁻⁷、样例排序一致 | +| BGE Base | 1.788 × 10⁻⁷ | 分词一致、最大差异 2.682 × 10⁻⁷、样例排序一致 | +| E5 Small | 7.451 × 10⁻⁸ | 使用 intfloat 仓库自带 ONNX,不属于 Xenova 转换对照 | + +原作者权重固定对照提交:Small `7999e1d3359715c523056ef9478215996d62a620`;Base `f03589ceff5aac7111bd60cfc7d497ca17ecac65`。仅在隔离验证环境安装 PyTorch / Transformers,未加入应用运行依赖。 + +首次 GPU 实测发现缺少 `cufft64_11.dll`,CPU 回退正常;补齐并固定 cuFFT wheel 后,三款模型均实际使用 CUDA。打包冒烟 EXE 分别报告 CPU ORT 1.28.0、GPU ORT 1.26.0 和 sqlite-vec 0.1.9。 + +额外完成真实 HF 暂停/重启恢复:约 10,486,476 字节时暂停,保留 10,485,760 字节临时权重;重新创建服务后仍为暂停,手动继续后完整校验成功。将相同文件导入新的本地模型目录并加载成功。脚本为 `tools/verify_local_search_download_recovery.py`。单元回归另外覆盖 429 等待预算、校验失败、版本缺失和访问拒绝。 + +### 合成标注检索集 + +48 条固定样例、8 个标注查询,涵盖延期、金额、订单编号、模糊回忆和英文表达。复用项目字符 FTS5 查询规则,执行真实 ONNX + sqlite-vec + RRF;测试时设置离线模式。 + +| 模型 | 关键词 Recall@20 | 混合 Recall@20 | 查询中位耗时 | 推理子进程 RSS | +| --- | ---: | ---: | ---: | ---: | +| BGE Small | 0.25 | 1.00 | 15 ms | 158 MB | +| BGE Base | 0.25 | 1.00 | 31.5 ms | 465 MB | +| E5 Small | 0.25 | 1.00 | 15.5 ms | 766 MB | + +这是小规模合成回归,**不代表真实聊天总体准确率,也不代表低配置电脑性能**。真实大量聊天、隐含上下文与更多人工标注集仍需要补充评估。 + +复现脚本:`tools/verify_local_search_models.py`、`tools/verify_local_search_gpu.py`、`tools/verify_local_search_reference.py`、`tools/verify_local_search_retrieval.py`、`tools/verify_local_search_frozen.py`。日志在 `logs/local-search-*.log`。后端与 Vue 回归覆盖账号隔离、过滤、事务恢复、模型切换、GPU 回退、取消、重试上限、离线导入、设置不自动扫描和来源定位;生产构建及桌面模型卡片已复核。 + +本轮相关后端回归 **102 项通过**(其中本地检索 23 项),Vue 回归 **132 项通过**;Nuxt 生产构建完成 34 个路由。打包冒烟再次验证 CPU 与外置 GPU 推理正常。项目已用更新后的源码重新启动,功能仍默认关闭。 + +尚未完成的发布验证:断网及 CDN 过期的端到端网络故障注入、磁盘耗尽实测、大规模真实账号效果、RTX 30/50、无独显实体机、干净 Windows 安装及语音与 GPU 的长期并行压力测试。不得将这些项目标为已通过。 + +## 设置页交互重设计验收(2026-09-08) + +- 聊天选择弹窗提供全选、全选当前搜索结果和清空选择;批量操作只修改草稿,确认后应用,关闭弹窗保留原范围。 +- 高级设置使用独立卡片和明确的展开/收起入口,展示运行设备、GPU 加速、自动更新及数据管理的用途。 +- 修复旧版主按钮依赖顶部启用开关、变灰却没有说明的问题;开启与首次建立索引合并为一次明确提交。 +- 模型选择和聊天选择只修改草稿,下载完成不自动选择;高级设置不会顺带保存主流程草稿。 +- Vue 回归覆盖首次开通、模型选择、范围取消、提交失败、暂停恢复、日期校验、账号切换和导航键盘操作。 +- 使用实际 Vue 组件及虚构数据的独立验收页检查布局与交互,不修改真实账号范围,也不额外启动真实聊天索引。预览位于 frontend/tests/fixtures/local-search.html,仅开发验收使用。 diff --git a/frontend/assets/css/agent.css b/frontend/assets/css/agent.css new file mode 100644 index 00000000..9142036a --- /dev/null +++ b/frontend/assets/css/agent.css @@ -0,0 +1,201 @@ +.agent-panel { --ag-bg:var(--app-surface-bg,#fff); --ag-soft:var(--app-surface-soft,#f7f8fa); --ag-border:var(--app-border,#e7e9ed); --ag-muted:var(--app-text-secondary,#7b8490); width:360px; max-width:calc(100vw - 64px); min-height:0; display:flex; flex-direction:column; flex-shrink:0; background:var(--ag-bg); color:var(--app-text-primary,#25352d); border-left:1px solid var(--ag-border); font-size:14px; line-height:1.6; z-index:35; container-type:inline-size; } +.agent-panel.is-expanded { position:absolute; inset:0; width:100%; max-width:none; z-index:60; border:0; } +.agent-panel.is-expanded .agent-welcome { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin:24px auto; } +.agent-panel.is-expanded .agent-welcome > .agent-brand, .agent-panel.is-expanded .agent-welcome h3, .agent-panel.is-expanded .agent-welcome > p { grid-column:1 / -1; } +.agent-panel.is-expanded .agent-welcome h3 { font-size:22px; margin:4px 0 0; } +.agent-panel.is-expanded .agent-welcome > p { font-size:13px; margin:0 0 18px; } +.agent-panel.is-expanded .agent-welcome > button { margin:0; padding:16px; min-height:78px; font-size:12px; gap:8px; } +.agent-panel.is-expanded .agent-markdown { font-size:14px; } +.agent-panel button, .agent-dialog button { font:inherit; cursor:pointer; border:1px solid transparent; border-radius:7px; padding:5px 8px; background:transparent; color:inherit; } +.agent-panel button:hover, .agent-dialog button:hover { background:var(--ag-soft,#f3f6f4); } +.agent-panel button:disabled { opacity:.45; cursor:not-allowed; } +.agent-panel button:focus-visible, .agent-dialog button:focus-visible { outline:2px solid #079b57; outline-offset:2px; } +/* 工具页嵌入后不沿用独立侧栏层级,保证顶部菜单始终可见、可点击。 */ +.agent-header { position:relative; z-index:2; display:flex; gap:4px; align-items:center; padding:6px 12px; min-height:44px; box-sizing:border-box; flex-shrink:0; border-bottom:1px solid var(--ag-border); } +.agent-header strong { flex:1; font-size:15px; white-space:nowrap; margin-right:4px; } +.agent-header button { display:grid; place-items:center; width:28px; height:30px; padding:0; color:var(--ag-muted); } +.agent-brand { width:28px; height:28px; display:grid; place-items:center; color:#079b57; font-size:18px; flex-shrink:0; } +.agent-menu-anchor { position:relative; } +.agent-menu { position:absolute; top:36px; right:0; width:190px; z-index:10; padding:5px; background:var(--ag-bg); border:1px solid var(--ag-border); border-radius:9px; box-shadow:0 8px 28px #0002; } +.agent-menu button { display:flex; gap:10px; width:100%; height:36px; padding:0 10px; text-align:left; color:inherit; font-size:13px; } +.agent-menu button > i:last-child:not(:first-child) { margin-left:auto; color:#079b57; } +.agent-tools-heading { display:flex; align-items:center; gap:14px; padding:5px 12px; border-bottom:1px solid var(--ag-border); font-size:12px; } +.agent-tools-heading button { color:var(--ag-muted); } +.agent-tools-heading i { margin-right:6px; } +.agent-tools.ai-sidebar { width:100%; max-width:none; flex:1; min-height:0; border:0; position:static; z-index:0; box-shadow:none; } +.agent-tools .ai-sidebar-header { display:none; } +.agent-context { display:flex; align-items:center; gap:8px; padding:4px 16px; height:36px; box-sizing:border-box; flex-shrink:0; border-bottom:1px solid var(--ag-border); } +.agent-context > i { font-size:14px; color:var(--ag-muted); } +.agent-context .agent-scope { display:flex; align-items:center; gap:7px; padding:0; min-width:0; font-size:13px; font-weight:550; } +.agent-scope span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.agent-scope i { color:var(--ag-muted); font-size:9px; flex-shrink:0; } +.agent-context .agent-follow { display:flex; gap:8px; align-items:center; margin-left:auto; padding:2px 7px; white-space:nowrap; font-size:11px; color:#07834a; background:#079b570c; } +.agent-follow i { font-size:10px; } +.agent-follow[aria-pressed=true] { background:#079b5720; } +.agent-workspace { display:flex; flex:1; min-height:0; min-width:0; } +.agent-reading { display:flex; flex-direction:column; flex:1; min-height:0; min-width:0; position:relative; } +.agent-panel.is-expanded .agent-reading { max-width:820px; margin:0 auto; } +.agent-panel.is-expanded .agent-workspace.has-source .agent-reading { max-width:860px; } +.agent-conversation { flex:1; min-height:0; overflow-y:auto; padding:16px; scrollbar-width:thin; overflow-wrap:anywhere; } +.agent-welcome { padding:28px 0; } +.agent-welcome h3 { font-size:17px; font-weight:600; margin:14px 0 7px; } +.agent-welcome p { color:var(--ag-muted); font-size:11px; margin-bottom:25px; } +.agent-welcome > button { display:flex; align-items:center; justify-content:space-between; width:100%; margin:9px 0; border:1px solid var(--ag-border); padding:10px 12px; text-align:left; font-size:11px; } +.agent-welcome > button i { color:var(--ag-muted); } +.agent-user { margin:0 0 16px auto; width:fit-content; max-width:90%; box-sizing:border-box; background:var(--ag-soft); border:0; padding:9px 12px; border-radius:12px 12px 3px 12px; white-space:pre-wrap; } +.agent-user p { margin:0; } +.agent-user small { display:block; margin-top:5px; color:#079b57; font-size:10px; } +.agent-reply { margin:0 0 16px; } +.agent-author { display:flex; align-items:center; gap:8px; font-weight:600; margin-bottom:15px; } +.agent-author > i { color:#079b57; } +.agent-author > span { margin-left:auto; font-size:10px; color:var(--ag-muted); font-weight:400; font-variant-numeric:tabular-nums; } +.agent-status { color:#079b57; display:flex; gap:7px; align-items:center; font-size:11px; } +.agent-live { border-left:2px solid #079b5750; padding-left:10px; margin:12px 0; } +.agent-live strong { display:block; font-size:11px; font-weight:500; } +.agent-live small, .agent-live p { color:var(--ag-muted); font-size:10px; margin:4px 0 0; } +.agent-activity, .agent-usage { color:var(--ag-muted); font-size:10px; margin:14px 0; } +.agent-activity summary, .agent-usage summary { cursor:pointer; } +.agent-activity summary span { float:right; } +.agent-activity ol { list-style:none; padding:8px 0; margin:0; max-height:240px; overflow-y:auto; } +.agent-activity li { display:flex; align-items:baseline; gap:7px; padding:5px 0; } +.agent-activity li > span { flex:1; } +.agent-activity small { font-variant-numeric:tabular-nums; white-space:nowrap; } +.agent-markdown { font-size:14px; line-height:1.65; } +.agent-markdown p { margin:8px 0; } +.agent-markdown h1, .agent-markdown h2, .agent-markdown h3 { font-size:15px; font-weight:650; margin:16px 0 6px; } +.agent-markdown ul, .agent-markdown ol { padding-left:20px; margin:10px 0; } +.agent-markdown table { display:block; overflow:auto; width:100%; border-collapse:collapse; font-size:11px; margin:14px 0; } +.agent-markdown th, .agent-markdown td { border:1px solid var(--ag-border); padding:6px 9px; min-width:65px; text-align:left; } +.agent-markdown th { background:var(--ag-soft); } +.agent-markdown pre { background:var(--ag-soft); padding:10px; overflow:auto; border-radius:7px; } +.agent-markdown blockquote { border-left:2px solid var(--ag-border); padding-left:12px; color:var(--ag-muted); } +.agent-markdown .agent-ref { vertical-align:baseline; font-size:11px; line-height:1.2; padding:1px 4px; margin:0 2px; background:#079b5710; color:#079b57; border-radius:4px; } +.agent-markdown .agent-ref { min-width:20px; min-height:20px; position:relative; } +.agent-markdown .agent-ref::after { content:""; position:absolute; inset:-2px 0; } +.agent-markdown .agent-ref[aria-expanded=true] { background:#079b57; color:#fff; box-shadow:0 0 0 2px #079b5720; } +.agent-citation-preview { position:fixed; inset:auto; box-sizing:border-box; border:1px solid var(--ag-border); border-radius:10px; padding:14px; margin:0; background:var(--ag-bg); color:var(--app-text-primary,#25352d); box-shadow:0 8px 32px #00000020; font-family:inherit; font-size:12px; line-height:1.6; overflow:auto; } +.agent-citation-preview:popover-open { display:flex; flex-direction:column; gap:8px; } +.agent-citation-preview header { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; } +.agent-citation-preview header > div { min-width:0; overflow-wrap:anywhere; } +.agent-citation-preview header strong, .agent-citation-preview header small { display:block; } +.agent-citation-preview header button { flex-shrink:0; min-width:28px; min-height:28px; } +.agent-citation-preview small { color:var(--ag-muted); font-size:11px; } +.agent-citation-preview .agent-citation-text { white-space:pre-wrap; overflow-wrap:anywhere; min-height:32px; max-height:180px; overflow:auto; scrollbar-width:thin; overscroll-behavior:contain; font-size:12px; margin:2px 0; padding:10px; background:var(--ag-soft); border-radius:6px; } +.agent-citation-preview .agent-citation-locate { display:flex; flex-shrink:0; align-items:center; justify-content:center; gap:8px; width:100%; min-height:34px; color:#079b57; border:1px solid #079b5730; background:#079b5710; } +.agent-citation-error { color:var(--app-danger,#b95039); font-size:11px; margin:0; } +@media (prefers-reduced-motion:reduce) { .agent-citation-preview .fa-spin { animation:none; } } +.agent-result-actions { display:flex; gap:9px; margin-top:12px; } +.agent-process-toggle { display:flex; width:100%; align-items:center; gap:7px; color:var(--ag-muted)!important; padding:6px 8px!important; margin:0 0 12px; font-size:11px!important; text-align:left; background:var(--ag-soft)!important; } +.agent-process-toggle > i:first-child { color:#079b57; font-size:13px; } +.agent-process-toggle small { margin-left:auto; white-space:nowrap; font-size:11px; } +.agent-process-toggle > i { font-size:9px; } +.agent-process { padding:0 0 8px; } +.agent-tool { margin:9px 0; color:var(--ag-muted); font-size:11px; } +.agent-tool > summary { display:flex; align-items:center; gap:9px; list-style:none; padding:8px 0; cursor:pointer; } +.agent-tool > summary::-webkit-details-marker { display:none; } +.agent-tool > summary > i { width:15px; flex-shrink:0; font-size:11px; } +.agent-tool > summary > span { flex:1; min-width:0; } +.agent-tool > summary small { display:block; font-size:10px; } +.agent-tool > summary time { white-space:nowrap; font-variant-numeric:tabular-nums; font-size:10px; } +.agent-tool.is-running > summary > i, .agent-tool.is-completed > summary > i { color:#079b57; } +.agent-tool.is-failed > summary > i { color:#b95039; } +.agent-tool-detail { border-left:1px solid var(--ag-border); margin-left:7px; padding:2px 12px 5px 17px; font-size:10px; } +.agent-tool-detail p { margin:4px 0; white-space:pre-wrap; } +.agent-progress-note { line-height:1.85; margin:14px 0 20px; color:var(--app-text-primary,#25352d); } +.agent-progress-note p { margin:0; } +.agent-progress-note small, .agent-progress-note.is-superseded { color:var(--ag-muted); font-size:10px; } +.agent-process-notice { color:var(--ag-muted); font-size:10px; margin:10px 0; } +.agent-supplement { margin:14px 0 14px 28px; padding:9px 12px; background:var(--ag-soft); border:1px solid var(--ag-border); border-radius:9px; } +.agent-supplement p { margin:0; white-space:pre-wrap; } +.agent-supplement small { color:#079b57; font-size:10px; } +.agent-final-answer { padding-top:0; } +.agent-markdown > :first-child { margin-top:0; } +.agent-markdown h1, .agent-markdown h2:first-child { font-size:18px; } +.agent-final-answer > small { color:var(--ag-muted); } +.agent-coverage { color:#b47724; } +.agent-coverage-note { color:var(--ag-muted); margin:12px 0; font-size:12px; } +.agent-coverage-note summary { cursor:pointer; } +.agent-coverage-note summary i { margin-right:6px; } +.agent-coverage-note p { margin:4px 0; } +.agent-panel.is-expanded .agent-progress-note { font-size:14px; } +.agent-error details { font-size:10px; margin-top:5px; } +.agent-result-actions button { font-size:12px; color:var(--ag-muted); padding:3px 0; } +.agent-result-actions i { margin-right:5px; } +.agent-error { font-size:11px; color:#b95039; background:#b9503910; padding:9px 12px; border-radius:7px; margin:10px 16px; } +.agent-run .agent-error { margin:10px 0; } +.agent-composer { position:relative; flex-shrink:0; padding:6px 12px 6px; } +.agent-input-box { border:1px solid var(--ag-border); border-radius:10px; padding:9px 10px 7px; background:var(--ag-bg); } +.agent-input-box:focus-within { border-color:#079b5780; box-shadow:0 0 0 2px #079b5708; } +.agent-input-box textarea { display:block; width:100%; min-height:28px; max-height:144px; resize:none; overflow-y:auto; border:0; outline:0; font:inherit; font-size:13px; line-height:1.6; background:transparent; color:inherit; padding:0; box-sizing:border-box; } +.agent-input-box textarea::placeholder { color:var(--ag-muted); } +.agent-input-actions { display:flex; gap:4px; align-items:center; margin-top:3px; min-width:0; } +.agent-input-actions > .agent-model-select { width:110px; min-width:0; flex-shrink:1; } +.agent-input-actions > .agent-effort-select { width:84px; flex-shrink:0; } +.agent-input-actions .ui-select.is-plain .ui-select-trigger { gap:6px; padding:0 5px; font-size:11px; font-weight:400; height:28px; min-height:28px; } +.agent-input-actions > button { flex-shrink:0; width:28px; height:28px; padding:0; color:var(--ag-muted); font-size:12px; } +.agent-input-actions .agent-send { margin-left:auto; color:white; background:#07b65a; width:30px; height:30px; padding:3px; font-size:16px; } +.agent-disclaimer { display:block; text-align:center; color:var(--ag-muted); font-size:10px; margin-top:6px; } +.agent-composer-settings { position:absolute; bottom:calc(100% - 4px); left:12px; right:12px; padding:12px; background:var(--ag-bg); border:1px solid var(--ag-border); border-radius:10px; box-shadow:0 -6px 28px #0001; z-index:5; font-size:12px; } +.agent-composer-settings header { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; } +.agent-composer-settings label { display:block; color:var(--ag-muted); } +.agent-composer-settings label .ui-select { margin-top:6px; } +.agent-composer-settings > button { display:flex; align-items:center; gap:12px; padding:8px 0 0; color:#079b57; } +.agent-source-inspector { flex:0 0 288px; min-width:0; padding:16px; overflow-y:auto; border-left:1px solid var(--ag-border); box-sizing:border-box; font-size:13px; } +.agent-source-inspector header { display:flex; justify-content:space-between; align-items:center; margin-bottom:18px; } +.agent-source-inspector h3 { font-size:17px; font-weight:650; margin:0; } +.agent-source-heading { overflow-wrap:anywhere; margin:0 0 14px; } +.agent-source-heading strong { color:#079b57; } +.agent-source-message { background:var(--ag-soft); padding:12px; margin:10px 0; border-radius:8px; overflow-wrap:anywhere; } +.agent-source-message.is-selected { background:#079b570d; } +.agent-source-message small { display:flex; flex-wrap:wrap; gap:6px; font-size:11px; color:var(--ag-muted); } +.agent-source-message time { margin-left:auto; } +.agent-source-message p { margin:8px 0 0; white-space:pre-wrap; line-height:1.65; max-height:300px; overflow:auto; } +.agent-source-context { margin-top:22px; } +.agent-source-context h4 { font-size:12px; font-weight:600; } +.agent-source-hint { color:var(--ag-muted); font-size:11px; margin:12px 0; } +.agent-source-inspector .agent-source-locate { display:flex; justify-content:center; align-items:center; gap:10px; width:100%; margin-top:20px; background:#07b65a; color:white; padding:9px; } +@container (max-width:330px) { .agent-header { padding-inline:8px; gap:1px; } .agent-input-actions > .agent-model-select { width:90px; } .agent-input-actions > .agent-effort-select { width:72px; } .agent-context { padding-inline:12px; } } +.agent-new-content { align-self:center; border-color:var(--ag-border)!important; color:#079b57!important; box-shadow:0 2px 8px #0001; font-size:10px!important; margin-top:-30px; z-index:1; background:var(--ag-bg)!important; } +.agent-dialog-overlay { position:fixed; inset:0; z-index:200; display:grid; place-items:center; padding:24px; background:#12231b66; backdrop-filter:blur(3px); } +.agent-dialog { width:min(640px,100%); max-height:80vh; overflow:auto; background:var(--app-surface-bg,#fff); color:var(--app-text-primary,#25352d); border-radius:14px; padding:22px; font-size:12px; box-shadow:0 24px 70px #0003; } +.agent-dialog header { display:flex; justify-content:space-between; align-items:center; margin-bottom:14px; } +.agent-dialog h3 { font-size:17px; font-weight:600; margin:0; } +.agent-dialog > p { color:var(--app-text-secondary,#7b8490); } +.agent-dialog input:not([type=checkbox]) { border:1px solid var(--app-border,#e7e9ed); border-radius:7px; background:transparent; color:inherit; padding:8px; width:100%; box-sizing:border-box; } +.agent-scope-list { max-height:330px; overflow:auto; margin:10px 0; } +.agent-scope-list label { display:flex; gap:9px; align-items:center; padding:9px; } +.agent-scope-list input { accent-color:#079b57; } +.agent-scope-list small { color:var(--app-text-secondary,#7b8490); margin-left:auto; font-size:10px; } +.agent-dialog footer { display:flex; justify-content:space-between; border-top:1px solid var(--app-border,#e7e9ed); padding-top:12px; } +.agent-dialog footer button:last-child { background:#079b57; color:white; } +.agent-history-item { display:flex; align-items:center; gap:8px; padding:9px 0; border-bottom:1px solid var(--app-border,#e7e9ed); } +.agent-history-item > button:first-child { flex:1; text-align:left; } +.agent-history-item small { display:block; color:var(--app-text-secondary,#7b8490); font-size:10px; } +.agent-history-item > button:not(:first-child) { font-size:10px; } +.agent-choices button { display:block; border-color:var(--ag-border); margin:8px 0; text-align:left; } +.agent-choices small { display:block; color:var(--ag-muted); } +.agent-evidence { margin:16px 0; color:var(--ag-muted); font-size:11px; } +.agent-evidence summary { cursor:pointer; } +.agent-evidence article { padding:10px; margin:8px 0; border:1px solid var(--ag-border); border-radius:8px; } +.agent-evidence article p { max-height:150px; overflow:auto; white-space:pre-wrap; color:var(--app-text-primary,#25352d); } +.agent-evidence article button { color:#079b57; font-size:10px; } +.agent-evidence-panel { margin:14px 0; padding:12px; border:1px solid var(--ag-border); border-radius:10px; background:var(--ag-soft); font-size:12px; overflow-wrap:anywhere; } +.agent-evidence-panel > p { margin:6px 0; line-height:1.6; } +.agent-evidence-hint { color:var(--ag-muted); } +.agent-evidence-panel .agent-evidence { margin:10px 0 0; } +.agent-source-tags { display:flex; flex-wrap:wrap; gap:5px; margin-top:8px; } +.agent-source-tags span { padding:3px 7px; border:1px solid var(--ag-border); border-radius:5px; color:var(--app-text-primary,#25352d); } +.agent-retrieval-label { display:block; margin-top:3px; color:var(--ag-muted); font-size:11px; } +html[data-theme=dark] .agent-panel { --ag-bg:#222629; --ag-soft:#292e31; --ag-border:#383e42; --ag-muted:#a0a9ad; } +@media(max-width:1000px) { .agent-panel { position:absolute; top:0; right:0; bottom:0; box-shadow:-8px 0 30px #0002; } } +@media(prefers-reduced-motion:reduce) { .agent-panel .fa-spin { animation:none; } } + + + +/* 深色背景下提高交互绿的对比度,选中的引用仍保留明确底色。 */ +html[data-theme=dark] .agent-panel .agent-brand, +html[data-theme=dark] .agent-panel .agent-follow, +html[data-theme=dark] .agent-panel .agent-markdown .agent-ref, +html[data-theme=dark] .agent-panel .agent-source-heading strong, +html[data-theme=dark] .agent-panel .agent-composer-settings > button { color:#70d6a4; } +html[data-theme=dark] .agent-panel .agent-markdown .agent-ref[aria-expanded=true] { color:#fff; background:#087c44; } diff --git a/frontend/assets/css/ai-settings.css b/frontend/assets/css/ai-settings.css new file mode 100644 index 00000000..f54ef788 --- /dev/null +++ b/frontend/assets/css/ai-settings.css @@ -0,0 +1,211 @@ +.ai-settings { + --ais-bg: var(--app-surface-bg, #fff); + --ais-soft: var(--app-surface-soft, #f7f8fa); + --ais-border: var(--app-border, #e7e9ed); + --ais-text: var(--app-text-primary, #20272f); + --ais-muted: var(--app-text-secondary, #79828e); + --ais-green: #079b57; + color: var(--ais-text); font-size: 12px; line-height: 1.5; container-type: inline-size; +} +.ai-settings *, .ai-settings *::before, .ai-settings *::after { box-sizing: border-box; } +.ai-settings h3, .ai-settings h4, .ai-settings h5, .ai-settings p { margin: 0; } +.ai-settings button, .ai-settings input, .ai-settings select { font: inherit; } +.ai-settings button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 6px 11px; color: var(--ais-text); background: var(--ais-bg); border: 1px solid var(--ais-border); border-radius: 6px; cursor: pointer; transition: background .15s, border-color .15s; } +.ai-settings button:hover { background: var(--ais-soft); border-color: #c7d2cb; } +.ai-settings button:disabled { opacity: .45; cursor: not-allowed; } +.ai-settings :is(button,input,select,summary):focus-visible { outline: 2px solid #079b57; outline-offset: 2px; } +.ai-settings label { display: flex; flex-direction: column; gap: 6px; min-width: 0; font-size: 11px; font-weight: 500; } +.ai-settings input:not([type=checkbox]), .ai-settings select { width: 100%; min-width: 0; height: 34px; padding: 0 10px; border: 1px solid var(--ais-border); border-radius: 6px; color: var(--ais-text); background: var(--ais-bg); font-size: 12px; font-weight: 400; } +.ai-settings input::placeholder { color: #9ba3af; } +.ai-settings input[type=checkbox] { accent-color: var(--ais-green); width: 13px; height: 13px; margin: 0; } +.ai-settings .ais-mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; } +.ais-heading { display: flex; gap: 11px; align-items: center; padding: 8px 0 17px; } +.ais-heading-icon { width: 36px; height: 36px; display: grid; place-items: center; flex-shrink: 0; border: 1px solid #d8eee1; border-radius: 10px; color: var(--ais-green); background: #edf8f1; font-size: 15px; } +.ais-heading h3 { font-size: 18px; font-weight: 650; letter-spacing: -.4px; } +.ais-heading p { color: var(--ais-muted); font-size: 11px; margin-top: 3px; } +.ais-local { display: inline-flex; gap: 5px; align-items: center; margin-left: auto; color: var(--ais-muted); font-size: 10px; white-space: nowrap; } +.ais-tabs { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:4px; padding:4px; border:1px solid var(--ais-border); border-radius:9px; background:var(--ais-soft); margin:0 0 22px; } +.ai-settings .ais-tabs button { justify-content:flex-start; gap:9px; min-width:0; min-height:52px; padding:9px 12px; border:1px solid transparent; border-radius:6px; background:transparent; color:var(--ais-muted); text-align:left; } +.ai-settings .ais-tabs button:hover { background:var(--ais-bg); } +.ai-settings .ais-tabs button[aria-selected=true] { color:var(--ais-green); border-color:var(--ais-border); background:var(--ais-bg); box-shadow:0 1px 3px #00000006; } +.ais-tabs .ais-tab-copy { display:flex; flex-direction:column; min-width:0; gap:2px; } +.ais-tab-copy strong { font-size:12px; font-weight:600; } +.ais-tab-copy small { font-size:10px; font-weight:400; color:var(--ais-muted); white-space:nowrap; } +.ai-settings .ais-tabs button>i { font-size:13px; flex-shrink:0; } +@container (max-width:600px) { + .ai-settings .ais-tabs button { padding:9px 6px; justify-content:center; gap:5px; min-height:38px; } + .ais-tab-copy small { display:none; } + .ais-tab-copy strong { font-size:11px; } +} +@container (max-width:420px) { + .ais-tabs { grid-template-columns:repeat(2,minmax(0,1fr)); } + .ai-settings .ais-tabs button { justify-content:flex-start; padding:9px; } + .ais-tab-copy strong { white-space:nowrap; } +} +.ais-defaults { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 12px 14px; border: 1px solid var(--ais-border); border-radius: 8px; background: var(--ais-soft); } +.ais-defaults label { gap: 5px; } +.ais-defaults label > span { display: flex; gap: 6px; align-items: center; color: var(--ais-muted); font-size: 10px; } +.ai-settings .ais-defaults select { height: 29px; border-color: transparent; background: transparent; padding-left: 0; font-weight: 550; } +.ais-profilebar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 18px 0 10px; } +.ais-profiles { display: flex; flex-wrap: wrap; gap: 6px; min-width: 0; } +.ai-settings .ais-profiles button { background: var(--ais-soft); color: var(--ais-muted); border-color: transparent; max-width: 240px; font-size: 11px; } +.ais-profiles button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ai-settings .ais-profiles button[aria-pressed=true] { border-color: #bfe4ce; color: #087b47; background: #eff9f3; } +.ai-settings .ais-add { border-color: transparent; background: transparent; color: var(--ais-green); white-space: nowrap; font-size: 11px; } +.ais-editor { border: 1px solid var(--ais-border); border-radius: 9px; background: var(--ais-bg); } +.ais-editor-heading { display: flex; align-items: center; justify-content: space-between; padding: 13px 16px; border-bottom: 1px solid var(--ais-border); } +.ais-editor-heading h4 { font-size: 13px; font-weight: 650; overflow-wrap: anywhere; } +.ais-editor-heading span { display: block; color: var(--ais-muted); font-size: 10px; margin-top: 2px; } +.ai-settings .ais-icon-button { width: 28px; min-height: 28px; padding: 0; background: transparent; border-color: transparent; color: var(--ais-muted); } +.ai-settings .ais-delete:hover { background: #fff0ed; color: #bf4b43; border-color: transparent; } +.ais-form-body { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; padding: 18px 16px; } +.ais-connection, .ais-model-config { min-width: 0; } +.ais-model-config { border-left: 1px solid var(--ais-border); padding-left: 22px; } +.ais-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 13px; } +.ais-address-row { grid-template-columns: minmax(0, 1.8fr) minmax(0, 1fr); } +.ais-key-label > span { display: flex; align-items: center; gap: 7px; } +.ais-tag { font-size: 9px; font-weight: 400; padding: 1px 5px; border-radius: 4px; color: #07814a; background: #ebf7ef; } +.ais-model-section { margin: 0; } +.ais-section-heading { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 10px; } +.ais-section-heading h5 { font-size: 12px; font-weight: 600; } +.ais-section-heading span { font-size: 10px; color: var(--ais-muted); } +.ai-settings .ais-refresh { font-size: 10px; min-height: 29px; padding: 4px 8px; } +.ais-model-controls { display: grid; grid-template-columns: 1fr; gap: 8px; } +.ais-model-search { position: relative; } +.ais-model-search i { position: absolute; left: 9px; top: 12px; color: var(--ais-muted); font-size: 10px; } +.ai-settings .ais-model-search input { padding-left: 25px; font-size: 10px; } +.ai-settings .ais-manual { display: flex; flex-direction: row; gap: 6px; align-items: center; margin-top: 9px; font-size: 10px; font-weight: 400; color: var(--ais-muted); } +.ai-settings .ais-vision { flex-direction: row; align-items: center; gap: 9px; border-radius: 7px; background: var(--ais-soft); padding: 10px; margin-top: 13px; cursor: pointer; } +.ais-vision-icon { color: var(--ais-green); font-size: 15px; } +.ais-vision-copy { display: flex; flex-direction: column; flex: 1; gap: 2px; } +.ais-vision-copy strong { font-size: 11px; font-weight: 500; } +.ais-vision-copy small { font-size: 10px; font-weight: 400; color: var(--ais-muted); } +.ai-settings input.ais-switch { appearance: none; width: 29px; height: 17px; border-radius: 10px; background: #cbd0d6; position: relative; flex-shrink: 0; cursor: pointer; transition: background .15s; } +.ai-settings input.ais-switch:checked { background: var(--ais-green); } +.ai-settings input.ais-switch::after { content: ''; width: 13px; height: 13px; position: absolute; top: 2px; left: 2px; border-radius: 50%; background: white; box-shadow: 0 1px 2px #0002; transition: transform .15s; } +.ai-settings input.ais-switch:checked::after { transform: translateX(12px); } +.ais-editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 16px; border-top: 1px solid var(--ais-border); background: var(--ais-bg); border-radius: 0 0 9px 9px; } +.ais-editor-footer > span { color: var(--ais-muted); font-size: 9px; display: inline-flex; align-items: center; gap: 5px; } +.ais-editor-footer > div { display: flex; gap: 7px; flex-shrink: 0; } +.ais-editor-footer button { font-size: 11px; } +.ai-settings .ais-primary { background: var(--ais-green); color: white; border-color: var(--ais-green); } +.ai-settings .ais-primary:hover { background: #07894e; } +.ais-footnote { margin: 11px 0 0 !important; color: var(--ais-muted); font-size: 10px; line-height: 1.7; } +.ais-feedback { display: flex; gap: 6px; align-items: center; padding: 8px 10px; margin: 10px 0 !important; border-radius: 6px; font-size: 11px; overflow-wrap: anywhere; } +.ais-feedback.is-error { color: #b33e37; background: #fff1ee; } +.ais-feedback.is-success { color: #087b47; background: #eff9f3; } +.ais-muted { color: var(--ais-muted); font-size: 11px; } +.ais-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 6px 0 24px; } +.ais-metrics > div { padding: 15px; border: 1px solid var(--ais-border); border-radius: 8px; background: var(--ais-soft); } +.ais-metrics span { font-size: 11px; color: var(--ais-muted); } +.ais-metrics strong { display: block; font-size: 25px; font-weight: 600; line-height: 1.3; margin-top: 8px; font-variant-numeric: tabular-nums; letter-spacing: -.8px; } +.ais-metrics small { font-size: 11px; font-weight: 400; margin-left: 6px; color: var(--ais-muted); } +.ais-audit-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; } +.ais-audit-toolbar h4 { font-size: 13px; font-weight: 600; } +.ais-audit-toolbar p { color: var(--ais-muted); font-size: 10px; margin-top: 3px; } +.ais-audit-toolbar > div:last-child { display: flex; gap: 6px; } +.ais-audit-toolbar button { font-size: 10px; } +.ais-table-wrap { border: 1px solid var(--ais-border); border-radius: 8px; overflow-x: auto; } +.ais-audit-table { width: 100%; text-align: left; border-collapse: collapse; table-layout: fixed; font-size: 11px; } +.ais-audit-table th { font-size: 10px; font-weight: 500; color: var(--ais-muted); background: var(--ais-soft); padding: 9px 12px; } +.ais-audit-table th:first-child { width: 45%; }.ais-audit-table th:last-child { width: 12%; } +.ais-audit-table td { border-top: 1px solid var(--ais-border); padding: 12px; vertical-align: top; } +.ais-audit-table td strong { display: block; font-size: 11px; font-weight: 500; overflow-wrap: anywhere; } +.ais-audit-table small { display: block; font-size: 10px; color: var(--ais-muted); margin-top: 4px; } +.ais-status { display: inline-block; font-size: 10px; color: var(--ais-muted); } +.status-success { color: #07814a; }.status-failed { color: #bc4941; } +.ais-token-cell span { display: block; white-space: nowrap; font-variant-numeric: tabular-nums; font-size: 10px; } +.ais-audit-table summary { color: var(--ais-green); cursor: pointer; font-size: 10px; } +.ais-audit-detail { margin-top: 8px; overflow-wrap: anywhere; font-size: 10px; } +.ais-empty { text-align: center; padding: 45px 16px; color: var(--ais-muted); } +.ais-empty > i { font-size: 24px; margin-bottom: 12px; }.ais-empty h4 { color: var(--ais-text); font-weight: 500; }.ais-empty p { margin-top: 6px; font-size: 11px; } +.ai-settings .ais-load-more { width: 100%; margin-top: 12px; } +.ais-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +html[data-theme="dark"] .ai-settings { --ais-bg: #222629; --ais-soft: #292e31; --ais-border: #383e42; --ais-text: #e6eaed; --ais-muted: #a0a9b2; } +html[data-theme="dark"] .ais-heading-icon, html[data-theme="dark"] .ais-tag, html[data-theme="dark"] .ai-settings .ais-profiles button[aria-pressed=true] { background: #173a2b; color: #65d29d; border-color: #2b5c43; } +html[data-theme="dark"] .ais-feedback.is-error { background: #422b2b; color: #ffa59c; }html[data-theme="dark"] .ais-feedback.is-success { background: #173a2b; color: #65d29d; } +@container (max-width: 680px) { + .ais-form-body { grid-template-columns: 1fr; gap: 16px; } + .ais-model-config { padding: 15px 0 0; border-left: 0; border-top: 1px solid var(--ais-border); } +} +@container (max-width: 520px) { + .ais-local { display: none; } + .ais-heading h3 { font-size: 17px; } + .ais-editor-footer { flex-wrap: wrap; }.ais-editor-footer > div { margin-left: auto; } + .ais-model-controls { grid-template-columns: 1fr; }.ais-model-search { max-width: none; } + .ais-audit-toolbar { flex-wrap: wrap; }.ais-metrics > div { padding: 10px; }.ais-metrics strong { font-size: 21px; } +} +@container (max-width: 380px) { + .ais-fields, .ais-defaults { grid-template-columns: 1fr; gap: 10px; }.ais-defaults { padding: 10px; } + .ais-form-body, .ais-editor-heading, .ais-editor-footer { padding-left: 12px; padding-right: 12px; } + .ais-profilebar { align-items: flex-start; }.ais-audit-table { min-width: 430px; } +} +@media (prefers-reduced-motion: reduce) { .ai-settings *, .ai-settings *::after { transition: none !important; animation: none !important; } } + +.ais-service-list { display: grid; gap: 10px; margin-top: 14px; } +.ai-settings .ais-service-card { width: 100%; justify-content: flex-start; gap: 14px; padding: 16px; text-align: left; border-radius: 9px; } +.ai-settings .ais-service-card:hover { border-color: #a8d6bc; background: var(--ais-soft); } +.ais-service-copy { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 4px; } +.ais-service-copy strong { font-size: 13px; font-weight: 600; } +.ais-service-copy small { font-family: ui-monospace, Consolas, monospace; color: var(--ais-muted); font-size: 11px; overflow-wrap: anywhere; } +.ais-service-card > i { color: var(--ais-muted); font-size: 10px; margin-left: 6px; } +.ais-provider-icon { display: inline-grid; place-items: center; flex-shrink: 0; width: 38px; height: 38px; border-radius: 50%; background: #f1f4f8; color: #627184; border: 1px solid #e5e9ed; font-size: 16px; } +.ais-provider-icon[data-provider=deepseek] { color: #536bda; background: #f0f3ff; border-color: #e1e6fc; } +.ais-provider-icon[data-provider=anthropic], .ais-provider-icon[data-provider=claude] { color: #c47b5c; background: #fff4ee; border-color: #f3e5dd; } +.ais-provider-icon[data-provider=kimi] { color: #fff; background: #252b40; border-color: #343d59; } +.ais-provider-mark { display: block; width: 21px; height: 21px; } +.ais-provider-monochrome { background: currentColor; mask: no-repeat center / contain; -webkit-mask: no-repeat center / contain; } +.ais-provider-color { object-fit: contain; } +.ais-provider-icon .ais-provider-avatar { width: 100%; height: 100%; border-radius: inherit; } +.ais-provider-icon[data-provider=openai] { color: #38846c; background: #edf7f2; border-color: #dfeee6; } +.ais-provider-icon[data-provider=gemini] { color: #547bcc; background: #f0f4fd; border-color: #e3eafb; } +.ais-provider-icon[data-provider=qwen], .ais-provider-icon[data-provider=siliconflow] { color: #7961c4; background: #f5f2fd; border-color: #eae4f8; } +.ais-provider-icon[data-provider=zhipu], .ais-provider-icon[data-provider=doubao] { color: #4a79b5; background: #eff5fc; border-color: #dfeafa; } +.ais-provider-icon[data-provider=groq] { color: #bd7652; background: #fdf3ed; border-color: #f4e6dd; } +.ais-provider-icon[data-provider=ollama] { color: #b46a29; background: #fff3df; border-color: #f2dfc3; } +.ais-provider-icon[data-provider=openrouter] { background: #262b20; border-color: #414c31; } +.ais-provider-icon[data-provider=lmstudio] { background: #7139ea; border-color: #814fea; } +.ais-provider-icon[data-provider=custom] { color: #448268; background: #edf6f1; border-color: #dcebe3; } +.ais-dialog-overlay { position: fixed; inset: 0; z-index: 20500; display: flex; align-items: center; justify-content: center; padding: 24px; background: #14221b55; backdrop-filter: blur(3px); } +.ai-settings.ais-dialog { box-sizing: border-box; width: 840px; max-width: 100%; min-height: 0; max-height: calc(100vh - 48px); overflow-y: auto; padding: 24px; border: 1px solid var(--ais-border); border-radius: 14px; background: var(--ais-bg); box-shadow: 0 22px 80px #0003; outline: none; } +.ai-settings.ais-provider-picker { width: 600px; padding: 20px; border-radius: 12px; scrollbar-width: thin; scrollbar-color: var(--ais-border) transparent; } +.ais-provider-picker .ais-dialog-heading { margin-bottom: 16px; gap: 12px; } +.ais-provider-picker .ais-dialog-heading h3 { font-size: 16px; font-weight: 600; letter-spacing: 0; } +.ais-provider-picker .ais-dialog-heading p { font-size: 11px; margin-top: 5px; } +.ai-settings.ais-provider-picker .ais-dialog-heading .ais-icon-button { min-height: 26px; width: 26px; padding: 0; font-size: 14px; } +.ais-dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 24px; } +.ais-dialog-heading h3 { font-size: 20px; font-weight: 650; letter-spacing: -.4px; } +.ais-dialog-heading p { color: var(--ais-muted); font-size: 12px; margin-top: 6px; } +.ai-settings .ais-dialog-heading .ais-icon-button { font-size: 17px; } +.ais-provider-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 4px 12px; } +.ai-settings .ais-provider-search { position: relative; display: block; margin-bottom: 12px; } +.ais-provider-search > i { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); color: var(--ais-muted); pointer-events: none; } +.ai-settings .ais-provider-search input[type=search] { width: 100%; padding-left: 38px; } +.ai-settings .ais-provider-choice { justify-content: flex-start; gap: 10px; padding: 8px 10px; border-color: transparent; min-height: 52px; text-align: left; border-radius: 8px; background: transparent; } +.ais-provider-choice .ais-provider-icon { width: 32px; height: 32px; font-size: 14px; } +.ais-provider-choice .ais-provider-mark { width: 19px; height: 19px; } +.ai-settings .ais-provider-choice:hover { border-color: var(--ais-border); background: var(--ais-soft); } +.ais-provider-choice > span:nth-child(2) { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.ais-provider-choice strong { display: block; font-size: 12px; font-weight: 550; line-height: 18px; } +.ais-provider-choice small { display: block; color: var(--ais-muted); font-size: 10px; margin-top: 1px; line-height: 15px; } +.ais-provider-choice > i { color: var(--ais-muted); font-size: 9px; opacity: .5; } +.ais-provider-choice:hover > i, .ais-provider-choice:focus-visible > i { opacity: 1; } +.ais-selected-provider { display: flex; align-items: center; gap: 11px; } +.ais-editor-heading .ais-provider-icon { display: inline-grid; margin: 0; font-size: 16px; } +.ai-settings .ais-change-provider { border-color: transparent; color: var(--ais-green); font-size: 11px; } +.ais-dialog .ais-address-row { grid-template-columns: 1fr; } +.ais-dialog .ais-form-body { padding-top: 20px; padding-bottom: 20px; } +.ais-dialog .ais-editor-footer { background: var(--ais-soft); } +html[data-theme=dark] .ais-provider-icon { background: #303943; border-color: #43505b; color: #b1c3ee; } +html[data-theme=dark] .ais-provider-icon[data-provider=deepseek] { background: #e9edff; border-color: #c8d2f4; } +html[data-theme=dark] .ais-provider-icon[data-provider=anthropic], html[data-theme=dark] .ais-provider-icon[data-provider=claude] { background: #f9e5da; border-color: #e2c5b4; } +html[data-theme=dark] .ais-provider-icon[data-provider=kimi] { background: #252b40; border-color: #485474; color: #fff; } +html[data-theme=dark] .ais-provider-icon[data-provider=openai] { color: #96cbb6; background: #293d35; border-color: #3b5348; } +html[data-theme=dark] .ais-provider-icon[data-provider=gemini], html[data-theme=dark] .ais-provider-icon[data-provider=zhipu], html[data-theme=dark] .ais-provider-icon[data-provider=doubao] { background: #eaf0fd; border-color: #c9d7ed; } +html[data-theme=dark] .ais-provider-icon[data-provider=qwen], html[data-theme=dark] .ais-provider-icon[data-provider=siliconflow] { background: #eee8fc; border-color: #d2c6e9; } +html[data-theme=dark] .ais-provider-icon[data-provider=groq] { color: #dfb096; background: #44362e; border-color: #614b3c; } +html[data-theme=dark] .ais-provider-icon[data-provider=ollama] { color: #efb875; background: #453625; border-color: #665039; } +html[data-theme=dark] .ais-provider-icon[data-provider=openrouter] { background: #262b20; border-color: #53623d; } +html[data-theme=dark] .ais-provider-icon[data-provider=lmstudio] { background: #7139ea; border-color: #996ff0; } +html[data-theme=dark] .ais-provider-icon[data-provider=custom] { color: #96cbb6; background: #293d35; border-color: #3b5348; } +@media (max-width: 600px) { .ais-dialog-overlay { padding: 12px; }.ai-settings.ais-dialog { padding: 18px; max-height: calc(100vh - 24px); }.ais-provider-grid { grid-template-columns: 1fr; gap: 4px; } } diff --git a/frontend/assets/css/ai.css b/frontend/assets/css/ai.css new file mode 100644 index 00000000..0a92562b --- /dev/null +++ b/frontend/assets/css/ai.css @@ -0,0 +1,24 @@ +.ai-ui { color: #25352d; font-size: 13px; line-height: 1.65; } +.ai-ui label { display: flex; flex-direction: column; gap: 5px; min-width: 0; flex: 1; margin-bottom: 10px; } +.ai-ui input:not([type=checkbox]), .ai-ui select, .ai-ui textarea { width: 100%; min-width: 0; border: 1px solid #dce4df; border-radius: 7px; padding: 7px 9px; background: #fff; color: #25352d; } +.ai-ui input[type=checkbox] { accent-color: #07a956; } +.ai-ui button { border: 1px solid #dce4df; border-radius: 7px; padding: 6px 10px; background: #fff; cursor: pointer; } +.ai-ui button:hover { background: #f0f7f3; } +.ai-ui button:disabled { opacity: .45; cursor: default; } +.ai-ui button:focus-visible, .ai-ui input:focus-visible, .ai-ui select:focus-visible, .ai-ui textarea:focus-visible { outline: 2px solid #07a956; outline-offset: 2px; } +.ai-ui .ai-primary, .ai-ui .ai-selected { color: #fff; background: #079c50; border-color: #079c50; } +.ai-ui .ai-row { display: flex; align-items: center; gap: 9px; margin: 10px 0; } +.ai-ui .ai-wrap { flex-wrap: wrap; } +.ai-ui .ai-check { flex-direction: row; align-items: center; } +.ai-ui .ai-card { padding: 13px; border: 1px solid #e0e8e3; border-radius: 10px; margin: 12px 0; background: #fff; } +.ai-ui .ai-muted { color: #718077; font-size: 12px; } +.ai-ui .ai-error { padding: 8px; border-radius: 6px; background: #fff1f0; color: #ac3030; white-space: pre-wrap; } +.ai-ui .ai-success { color: #087641; } +.ai-ui .ai-source { color: #087641; padding: 1px 5px; margin: 2px; font-size: 11px; } +.ai-ui .ai-prose { white-space: pre-wrap; overflow-wrap: anywhere; } +.ai-ui .ai-title { font-size: 15px; font-weight: 650; margin-bottom: 8px; } +.ai-ui .ai-section-title { font-weight: 600; margin: 12px 0 5px; } +.ai-ui progress { width: 100%; accent-color: #07a956; } +.dark .ai-ui { color: #dce7e0; } +.dark .ai-ui .ai-card, .dark .ai-ui input:not([type=checkbox]), .dark .ai-ui select, .dark .ai-ui textarea, .dark .ai-ui button { background: #242e28; color: #dce7e0; border-color: #435248; } +.dark .ai-ui .ai-selected, .dark .ai-ui .ai-primary { background: #087c44; } diff --git a/frontend/assets/icons/ai-providers/claude-color.svg b/frontend/assets/icons/ai-providers/claude-color.svg new file mode 100644 index 00000000..62dc0db1 --- /dev/null +++ b/frontend/assets/icons/ai-providers/claude-color.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/claude.svg b/frontend/assets/icons/ai-providers/claude.svg new file mode 100644 index 00000000..e29f3282 --- /dev/null +++ b/frontend/assets/icons/ai-providers/claude.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/deepseek-color.svg b/frontend/assets/icons/ai-providers/deepseek-color.svg new file mode 100644 index 00000000..3fc23024 --- /dev/null +++ b/frontend/assets/icons/ai-providers/deepseek-color.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/deepseek.svg b/frontend/assets/icons/ai-providers/deepseek.svg new file mode 100644 index 00000000..dc224e43 --- /dev/null +++ b/frontend/assets/icons/ai-providers/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/doubao.png b/frontend/assets/icons/ai-providers/doubao.png new file mode 100644 index 0000000000000000000000000000000000000000..6a86212c1216bf8948f44ec27d55e36d04f03b27 GIT binary patch literal 17843 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 \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/gemini-color.svg b/frontend/assets/icons/ai-providers/gemini-color.svg new file mode 100644 index 00000000..62681dfa --- /dev/null +++ b/frontend/assets/icons/ai-providers/gemini-color.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/gemini.svg b/frontend/assets/icons/ai-providers/gemini.svg new file mode 100644 index 00000000..87736bbe --- /dev/null +++ b/frontend/assets/icons/ai-providers/gemini.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/groq.svg b/frontend/assets/icons/ai-providers/groq.svg new file mode 100644 index 00000000..7294646b --- /dev/null +++ b/frontend/assets/icons/ai-providers/groq.svg @@ -0,0 +1 @@ +Groq \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/kimi-color.svg b/frontend/assets/icons/ai-providers/kimi-color.svg new file mode 100644 index 00000000..83878fa2 --- /dev/null +++ b/frontend/assets/icons/ai-providers/kimi-color.svg @@ -0,0 +1 @@ +Kimi \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/kimi.svg b/frontend/assets/icons/ai-providers/kimi.svg new file mode 100644 index 00000000..1915850e --- /dev/null +++ b/frontend/assets/icons/ai-providers/kimi.svg @@ -0,0 +1 @@ +Kimi \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/lmstudio.png b/frontend/assets/icons/ai-providers/lmstudio.png new file mode 100644 index 0000000000000000000000000000000000000000..9359c5f7a82407acbc0b60f6e0ba6838e25e02a4 GIT binary patch literal 12829 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 literal 0 HcmV?d00001 diff --git a/frontend/assets/icons/ai-providers/lmstudio.svg b/frontend/assets/icons/ai-providers/lmstudio.svg new file mode 100644 index 00000000..ea0816b6 --- /dev/null +++ b/frontend/assets/icons/ai-providers/lmstudio.svg @@ -0,0 +1 @@ +LM Studio \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/ollama.svg b/frontend/assets/icons/ai-providers/ollama.svg new file mode 100644 index 00000000..cc887e3d --- /dev/null +++ b/frontend/assets/icons/ai-providers/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/openai.svg b/frontend/assets/icons/ai-providers/openai.svg new file mode 100644 index 00000000..78caf4fa --- /dev/null +++ b/frontend/assets/icons/ai-providers/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/openrouter-color.svg b/frontend/assets/icons/ai-providers/openrouter-color.svg new file mode 100644 index 00000000..07aca9ac --- /dev/null +++ b/frontend/assets/icons/ai-providers/openrouter-color.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/openrouter.svg b/frontend/assets/icons/ai-providers/openrouter.svg new file mode 100644 index 00000000..029070c8 --- /dev/null +++ b/frontend/assets/icons/ai-providers/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/qwen-color.svg b/frontend/assets/icons/ai-providers/qwen-color.svg new file mode 100644 index 00000000..f2d0ada1 --- /dev/null +++ b/frontend/assets/icons/ai-providers/qwen-color.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/qwen.svg b/frontend/assets/icons/ai-providers/qwen.svg new file mode 100644 index 00000000..a4bb382a --- /dev/null +++ b/frontend/assets/icons/ai-providers/qwen.svg @@ -0,0 +1 @@ +Qwen \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/siliconcloud-color.svg b/frontend/assets/icons/ai-providers/siliconcloud-color.svg new file mode 100644 index 00000000..6b5f6d80 --- /dev/null +++ b/frontend/assets/icons/ai-providers/siliconcloud-color.svg @@ -0,0 +1 @@ +SiliconCloud \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/siliconcloud.svg b/frontend/assets/icons/ai-providers/siliconcloud.svg new file mode 100644 index 00000000..f06093b6 --- /dev/null +++ b/frontend/assets/icons/ai-providers/siliconcloud.svg @@ -0,0 +1 @@ +SiliconCloud \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/zhipu-color.svg b/frontend/assets/icons/ai-providers/zhipu-color.svg new file mode 100644 index 00000000..0c6e61ce --- /dev/null +++ b/frontend/assets/icons/ai-providers/zhipu-color.svg @@ -0,0 +1 @@ +Zhipu \ No newline at end of file diff --git a/frontend/assets/icons/ai-providers/zhipu.svg b/frontend/assets/icons/ai-providers/zhipu.svg new file mode 100644 index 00000000..f7b12528 --- /dev/null +++ b/frontend/assets/icons/ai-providers/zhipu.svg @@ -0,0 +1 @@ +Zhipu \ No newline at end of file diff --git a/frontend/components/AgentSettings.vue b/frontend/components/AgentSettings.vue new file mode 100644 index 00000000..93173c34 --- /dev/null +++ b/frontend/components/AgentSettings.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/components/AiProviderIcon.vue b/frontend/components/AiProviderIcon.vue new file mode 100644 index 00000000..2eeb005d --- /dev/null +++ b/frontend/components/AiProviderIcon.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/components/AiSettings.vue b/frontend/components/AiSettings.vue new file mode 100644 index 00000000..4259c168 --- /dev/null +++ b/frontend/components/AiSettings.vue @@ -0,0 +1,327 @@ + + + diff --git a/frontend/components/LocalSearchSettings.vue b/frontend/components/LocalSearchSettings.vue new file mode 100644 index 00000000..64f1eb43 --- /dev/null +++ b/frontend/components/LocalSearchSettings.vue @@ -0,0 +1,428 @@ + + + + + + diff --git a/frontend/components/SettingsDialog.vue b/frontend/components/SettingsDialog.vue index 624a4b82..1a07b8e6 100644 --- a/frontend/components/SettingsDialog.vue +++ b/frontend/components/SettingsDialog.vue @@ -4,7 +4,8 @@ class="settings-dialog theme-scope fixed inset-0 z-[20000] flex items-center justify-center bg-black/40 px-2 py-2 backdrop-blur-md sm:px-4 sm:py-8" @click.self="handleClose" > -
+ +