diff --git a/.github/workflows/ai-cross-platform.yml b/.github/workflows/ai-cross-platform.yml new file mode 100644 index 00000000..53a5a534 --- /dev/null +++ b/.github/workflows/ai-cross-platform.yml @@ -0,0 +1,70 @@ +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 + env: + # macOS setup-python builds may lack SQLite loadable-extension support. + # Use uv-managed CPython for both source checks and the frozen runtime. + UV_MANAGED_PYTHON: 'true' + 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 + - name: Install Python with SQLite extension support + run: uv python install 3.11 + - 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/.github/workflows/installer-unicode.yml b/.github/workflows/installer-unicode.yml new file mode 100644 index 00000000..4bab5b6e --- /dev/null +++ b/.github/workflows/installer-unicode.yml @@ -0,0 +1,58 @@ +name: Windows Installer Unicode + +on: + workflow_dispatch: + pull_request: + paths: + - 'desktop/**' + - '.github/workflows/installer-unicode.yml' + - '.github/workflows/release.yml' + - '.gitattributes' + push: + branches: [main] + paths: + - 'desktop/**' + - '.github/workflows/installer-unicode.yml' + - '.github/workflows/release.yml' + - '.gitattributes' + +permissions: + contents: read + +concurrency: + # 不同提交分别完成回归检查,避免连续推送让旧提交留下 Cancelled 状态。 + # 同一提交的重复触发仍可取消,减少重复运行。 + group: installer-unicode-${{ github.workflow }}-${{ github.ref }}-${{ github.sha }} + cancel-in-progress: true + +jobs: + unicode-paths: + name: Windows installer Unicode paths + runs-on: windows-2022 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: desktop/package-lock.json + # 仅测试安装器函数,不构建完整应用,也不需要签名或真实用户数据。 + - name: Install locked test dependencies + working-directory: desktop + run: npm ci --ignore-scripts + - name: Verify Unicode install, upgrade, settings and data migration + working-directory: desktop + shell: pwsh + run: | + node --test ` + tests/desktop-settings.test.cjs ` + tests/output-dir.test.cjs ` + tests/installer-output-dir.test.cjs ` + tests/installer-safety.test.cjs ` + tests/installer-unicode-integration.test.cjs + if ($LASTEXITCODE -ne 0) { + throw 'Windows installer Unicode regression tests failed.' + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7f34d8b..7efd42c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -563,6 +563,11 @@ jobs: shell: pwsh run: | node --test ` + tests/desktop-settings.test.cjs ` + tests/output-dir.test.cjs ` + tests/installer-output-dir.test.cjs ` + tests/installer-safety.test.cjs ` + tests/installer-unicode-integration.test.cjs ` tests/package-config.test.cjs ` tests/native-core-before-pack.test.cjs ` tests/native-core-packaging.test.cjs ` diff --git a/.gitignore b/.gitignore index e95bb3e4..8b42e75a 100644 --- a/.gitignore +++ b/.gitignore @@ -104,6 +104,17 @@ 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-*/ # 高级版演示走片的默认输出目录(website/dev/shot.mjs),只是本地调试产物 pro-shots/ diff --git a/README.md b/README.md index af7d315f..16bf26d9 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,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..961518ef --- /dev/null +++ b/design-qa.md @@ -0,0 +1,231 @@ +# 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 + +# Agent 工具流缩进验收(2026-09-09,选定第二版) + +- final result: passed +- source visual truth path: `C:/Users/123/.codex/generated_images/01a0852e-c1a2-72b1-9595-edc295d3d413/exec-a2e4fda4-fe72-43d6-a82a-88ab4311d6e8.png`。这是用户选定第二版、结合其 Codex 截图修正缩进后的视觉目标。 +- 用户补充参考:`C:/Users/123/AppData/Local/Temp/codex-clipboard-c229914d-a520-42fb-82c3-c72bac24e4e6.png`。 +- implementation screenshot path: `tmp/agent-indent-final-top.png`;补充 `tmp/agent-indent-narrow-final.png`、`tmp/agent-indent-dark.png`。 +- 可运行预览:`http://127.0.0.1:5178/tests/fixtures/agent-redesign.html?design=1`。复用正式 Vue 组件,仅接口和聊天内容为模拟数据。 + +## 比较范围与归一方法 + +本轮只调整现有执行过程、详情缩进和回答层级,保留应用已有标题栏、范围栏、输入区和主题。状态为已完成、工具详情收起、同一约饭问题,完成用时 2 分 21 秒。生成参考为 1617×972 像素,用户 Codex 参考为 1424×856;实现浏览器 viewport 与截图为 1280×720,窄窗为 498×701。截图输出按 CSS 像素观察,不以原始参考图的未知 DPR 推断精确像素比例。 + +整体组合 `tmp/agent-indent-comparison-after.png` 将源图和真实页面等比例放入同宽列。重点组合 `tmp/agent-indent-comparison-focus.png` 同屏包含视觉目标、实际组件和用户 Codex 截图:参考图与 Codex 图按 0.56 倍显示以接近 14px 正文字号,实际组件按原始截图尺寸显示,分别对齐正文区域。比较使用 HTML 展示原图和 CSS 裁切,未修改截图像素。完整页面多出的应用导航和输入控件属于保留的产品 UI;720px 窗口中回答内部滚动、输入区常驻,不要求将全部回答压入一屏。 + +## 发现、修复与复查 + +1. [P2,已修复] 初次全图对照发现预览容器固定 800px 导致输入区超出 720px 视口。测试容器增加视口高度上限;最终输入区下缘约 663px,正文独立滚动。证据:`tmp/agent-indent-comparison-before.png` 与 `tmp/agent-indent-comparison-after.png`。 +2. [P2,已修复] 工具行和进展段落间距偏松。工具行上下内边距从 7px 收紧至 4px,进展外边距从 20/12px 改为 16/8px。重点组合对照复查了行密度与文本层级。 +3. [P2,已修复] 用量入口打断工具过程到最终回答的顺序。将它放到回答操作下方,完成状态保留为低对比度单行。完成后不自动折叠过程,避免丢失选定版的上下文。 +4. 引用交互复查发现新增约饭示例仍复用旧出行示例的来源文本;仅修正设计预览数据,使引用和上下文匹配约饭日期。重新点击后正确显示 9 月 2 日“那就下周三”与 9 月 7 日确认文本。 + +## 视觉与交互结论 + +- 字体:复用既有系统字体;进展正文和工具名称 14px,工具记录使用次级灰色,数量/状态更轻;最终答案保持正文层级和必要加粗。 +- 缩进:DOM 实测正文、工具图标、最终答案 x=260;工具名称、展开详情 x=286,即 26px 缩进。没有卡片外框或整段竖线。 +- 图标:复用 Font Awesome 的搜索、文档和状态图标,不增加图片素材或新图标体系。展开箭头紧随内容。 +- 合并:两次连续相同上下文读取合为一行,展开后两条记录和“复用已读结果”均保留;失败/暂停/运行状态仍能看见。不同范围、会话及跨进展段落不合并。 +- 状态:浏览器实际打开/关闭合并详情,验证来源面板;浅色、深色和窄窗口无水平溢出。深色工具文字实测 rgb(160,169,173),正文区域背景 rgb(34,38,41)。未依赖截图代替交互验证。 +- 前端 19 个测试文件、217 项通过;覆盖手动折叠、运行结束不自动折叠、流式更新保留详情节点、合并边界和失败状态。生产构建成功。日志为 `tmp/agent-indent-tests.log` 和 `tmp/agent-indent-build.log`。 +- 本轮未发起真实模型生成请求,也未重启用户后端;浏览器验证使用现有正式组件与可重复示例接口。测试和构建不能替代真实上游服务端到端验证。 + +无剩余 P0/P1/P2。P3:平台字体重采样与 Font Awesome 轮廓和 Codex 自有图标仍有轻微差别;应用标题栏和现有输入工具保留原产品风格,不声称整页像素级复刻。 + + +# AI 执行过程参考图验收(2026-09-09) + +- source visual truth path: `C:/Users/123/AppData/Local/Temp/codex-clipboard-f84d8198-defb-4bf9-a50b-f7756ad32f76.png`。 +- implementation screenshot path: `tmp/agent-reference-qa/light.png`;补充 `dark.png`、`narrow-dark.png`。 +- 同屏对照:`tmp/agent-reference-qa/comparison.png`,左侧参考,右侧最终组件。 +- 本地预览:`http://127.0.0.1:4173/tests/fixtures/agent-redesign.html?design=1`。直接挂载正式组件,示例接口不调用真实模型。 + +## 尺寸与状态 + +参考图 1614 × 975,等比缩至约 807 × 488;实际浏览器 viewport 为 1280 × 1000,正文阅读列 800 CSS px。实际截图裁切 (220,154)-(1040,700),保留 CSS 像素尺寸,与参考同屏检查。比较状态均为浅色、完成回答、重复读取展开、逐次明细与用量折叠、引用未选中;示例问题与回答对应参考内容。参考未包含应用工具栏和输入区,因此仅在对照图中裁切正文,完整截图保留全部产品界面。浏览器截图有文字重采样,不声称像素级一致。 + +## 调整与复查 + +- 完成状态移到过程顶部,显示总耗时和操作数;搜索词显示为灰色标签,操作行显示耗时和结果数。 +- 相邻同范围操作展开为浅灰分组和节点时间线;首次读取与缓存复用分别显示,缓存结果不重复累加。每次原有诊断与查询详情仍可展开。 +- 用量与读取范围移到答案前的折叠行;绿色引用保留原文预览和定位功能。 +- [P2,已修复] 首轮截图概览与后续操作的间距偏大,已收紧并重新截图。 +- [P2,已修复] 节点连线在第二个圆点前中断,已改为连接相邻节点中心,最终同屏对照确认。 +- 沿用应用字体、明暗主题和 Font Awesome,无新增图像素材;宽度和换行按实际面板自适应。小图标字形、正文行距与参考有轻微差异,保留现有产品排版。 +- 390 × 844 窄屏分组结果自动换行,正文滚动区域无水平溢出;深色引用与时间线可辨认。无剩余 P0 / P1 / P2 视觉问题。 + +## 验证 + +浏览器检查了整体过程折叠、逐次读取明细、用量、绿色引用打开原文侧栏、关闭侧栏、窄屏及深色显示、流式运行状态。预览控制台无 error / warn。自动测试覆盖折叠后保留答案、用量位置、缓存不重复计数、更新保留手动折叠状态及原有失败处理。 + +Vitest 219 项通过,Node 测试 62 项通过。真实账号与模型端到端请求未执行,本报告验收组件与示例交互。 + +final result: passed + +生产静态构建通过,预渲染 34 个路由。日志见 `tmp/agent-reference-qa/build.log`;构建存在现有重复导入和第三方模块指令警告,未阻止产物生成。 + + +## 补充:只显示最终回答 + +按用户追加要求,成功且已有回答时默认将操作、过程说明、用量与详细材料收起,保留“查看执行过程”入口。运行中或没有答案时仍展示过程;错误和恢复操作保持可见。手动展开优先于自动收起。浏览器验证折叠与恢复用量入口,完成态截图 `tmp/agent-reference-qa/final-only.png`;这是对参考图的明确交互变更。相关 11 项执行流测试通过,覆盖默认收起、重新展开、历史轮次及手动选择保持。 + + +## 补充:执行过程与正文的视觉分区 + +用户反馈图:`C:/Users/123/AppData/Local/Temp/codex-clipboard-2913d713-0d7e-4101-bb67-d35607452932.png`。本轮不是复刻红框,而是按反馈修正阅读层级。 + +1. 收起态保留过程入口与最终回答;此前已经支持此交互。 +2. 展开态实测截图 `tmp/agent-reference-qa/process-before.png`:过程没有完整区域边界,阶段小结与回答共用字体和阅读轴,缺少明确的正文起点。 +3. 新版 `tmp/agent-reference-qa/process-after.png`:过程置于有边界的浅灰面板,标题固定为“执行过程”,右侧明确显示展开或收起;中途文字标为“阶段小结”。回答位于面板外,间隔 24px,使用“最终回答”标题、15px 正文和 1.85 行高,过程说明为 13px / 1.7。运行及失败状态分别标为“正在回答”“未完成的回答”,避免误标为最终结果。 + +同屏对照 `tmp/agent-reference-qa/process-comparison.png`,均为 1280 × 1000 viewport,裁切实际正文区域;两次自然滚动位置有差异,不作像素级一致声明。对照后发现所有逐次读取默认展开会增加面板高度,已改为摘要优先、逐次明细按需展开,再次截图复查。窄屏深色检查见 `process-dark-narrow.png`(该图记录逐次明细展开状态);480px 窗口下过程与回答均无水平溢出。过程次级文字使用主题主色与背景混合,未通过低透明度淡化整个容器。 + +验证:整体展开/收起、逐次明细、窄屏引用弹层、主题切换,控制台无 error / warn;Vitest 全量 221 项通过,最后摘要默认值调整后执行流 12 项再次通过。错误提示留在折叠区之外,用户草稿保留。没有进行真实账号或模型请求,也不以截图宣称完整无障碍合规。本轮视觉分区无剩余 P0 / P1 / P2 问题。 + + +## 补充:收起入口扁平化 + +按用户反馈去掉收起态的满宽卡片、底色、边框与粗标题,改为内容宽度的灰色文字入口,箭头紧跟摘要。展开态保持现有区域边界。浏览器截图 `tmp/agent-reference-qa/flat-collapsed.png`;390px 深色窄屏实测入口高 30px、宽 241px,无横向溢出,收起背景透明且边框为 0,点击展开恢复面板背景和边框。本次仅调整 CSS,无功能逻辑变更,未重复运行组件测试。 + + +## 待确认:紧凑回答工具栏 + +将复制改为带可访问名称和悬浮提示的图标,出处与“部分资料未读”合并同一工具栏。未读提示仍明确可见,点击后显示原有全部说明;运行中已有回答时仍可查阅未读说明。截图 `tmp/agent-reference-qa/compact-footer.png`。390px 深色窗口实测三个按钮同一纵坐标,工具栏高 28px,无横向溢出。相关执行流、出处、引用和聊天组件 39 项测试通过。按用户要求,本轮仅完成本地修改和验证,未经确认不推送。 + + +## 待确认:运行中的状态反馈 + +用户反馈图 `C:/Users/123/AppData/Local/Temp/codex-clipboard-014c6e2b-ee45-4fee-ad07-606a83cc62e0.png`。检查发现 ChatAgentPanel 已每 1.5 秒更新计时;原界面把当前阶段放在过程面板外,初始阶段又被记录过滤规则排除,导致面板只显示用量。现在将当前真实阶段、总耗时、本步耗时与已读取数量合到轻量入口中,保留旋转图标;无操作记录时不自动展开空面板,用户仍可主动查看用量。实际操作出现后可查看过程,运行时使用侧边细线代替完整大卡片。已有停止与补充入口保留。尊重减少动态效果设置,不加入虚假的完成百分比。 + +预览新增等待首个工具→搜索→生成回答的模拟阶段(只改测试数据),修正模拟运行继承历史耗时的问题。浏览器观察到阶段从“理解问题与读取范围”切换到“搜索相关聊天记录”,计时从 0 秒到 7 秒、当前步骤为 3 秒,初始面板隐藏,图标 animationName 为 fa-spin。截图 `tmp/agent-reference-qa/live-waiting.png`、`live-narrow-dark.png`;390px 窗口下入口约 57px 高且无横向溢出。执行流、聊天及面板 52 项测试通过;未进行真实模型服务或网络卡顿的端到端测试,不将界面改进等同于后台执行加速。连同此前紧凑工具栏修改等待用户确认,尚未提交或推送。 + + +## 待确认:会话列表后台运行状态与悬停 + +用户截图中的转圈原来绑定当前选中的运行会话,切换后指示消失。现在按最新任务 ID 维护每个会话的状态;列表接口新增 `latest_run_status`,只返回状态,不带任务回答、证据或消息。事件更新后台状态,列表可见或有已知后台任务时约每 6 秒静默校正;暂时请求失败保留最后已知状态,下轮重试。状态事件优先于请求发出前的列表快照,旧任务的终态不会覆盖同会话的新任务;账号切换清空缓存并拒绝旧账号事件。静默刷新保留既有会话顺序,避免鼠标下的条目突然换位,手动刷新仍按服务端顺序展示。 + +悬停使用 140ms 颜色与透明度过渡;选中底色保持较深,转圈和更多按钮分别位于固定的第二行与第一行。按钮显隐不改变标题可用宽度。键盘焦点和菜单打开时也显示更多按钮,触屏保持可见,减少动态效果模式关闭过渡和转圈动画但保留文字状态。 + +浏览器:`tmp/agent-reference-qa/background-running.png` 记录切换到“上周还有哪些待办?”后,“南京出行梳理”仍为运行状态;CSS animationName 为 fa-spin,随后完成事件使图标消失而当前选择不变。`thread-hover.png` 记录非选中行悬停:按钮 opacity 从 0 到 1,标题坐标始终 (91.14,263.56),悬停底色为 4% 主文字色、选中态为 9%,无位移。悬停用鼠标事件验证,未通过修改页面伪造 hover 样式。 + +前端 Vitest 225 项通过;最后排序保护调整后聊天组件 20 项再次通过。后端 Agent 测试 24 项通过,包含 6 种任务状态、缺失任务、跨账号及跨会话引用保护。首次 Python 测试结束时遇到系统 pytest-current 临时目录权限错误,改用工作区独立临时目录后完整通过。实际模型服务不在本轮验证范围,浏览器使用示例接口。本轮连同此前两项修改仍留在本地,等待用户确认后才能推送。 + +## 待实机验收:分段步骤漏显示与最近消息数量 + +用户截图 `codex-clipboard-fdcdf62a-844a-4737-974f-1a3ec41f31f2.png` 中展开过程只有用量。读取实际应用保存的最近任务后确认,后台 timeline 包含理解问题、读取、分段分析、核查等 completed status 记录;AgentRun 原来只渲染 running status,且按照文字过滤当前阶段时还会误删已完成的同名阶段。现改为保留完成、暂停等阶段的名称、状态和固定耗时,仅隐藏与入口重复的当前 running status;阶段小结不再按文字相等误删。无历史步骤时,用量入口不显示多余分割线。截图 `tmp/agent-reference-qa/stages-visible.png` 为示例预览,验证实际返回的 status 结构可以显示,不代表真实模型测试。 + +排查指定测试句时发现 ContextIntent 没有最近 N 条字段。新增 message_count,通过固定截止时间的最近消息读取器分页,包含日期限制时也取窗口内最新 N 条;限制前先去重,少于 N 条正常结束。读取范围详情明确标出条数。前端 226 项通过,后端 context/message-pages/protocol 共 65 项通过,覆盖 0/27/100/231 条、无起始时间/有时间窗口、跨页续读、最新与最早消息区别和完整 100 条分析流程。首次新增流程用例的模拟发现过多触发模拟摘要解析失败,调整为稀疏发现后重新执行完整后端测试通过。 + +尚未完成用户要求的真实桌面输入测试:Computer Use 发现目标 Electron 窗口,但激活失败,刷新窗口后重试仍返回 `failed to activate captured window`;截图显示任务管理器与 QQ 遮挡目标。已请求用户将目标应用置前,未通过其他桌面自动化绕过故障,未声称指定指令已在实机执行。前端开发预览已热更新;后端数量限制在当前已运行进程中尚未重新加载,实机验收前需要重新启动后端。本轮所有修改保持本地,未经用户确认不推送。 + + +## 待确认:执行前后统一与 Agent 进展提示 + +针对用户两张运行态、完成态截图,移除以 is-running 为条件的整套面板布局覆盖。所有状态统一使用透明背景、无卡片边框、普通字重的执行过程入口,以及同一条左侧细线和紧凑步骤行。完成时只更新状态和记录,不恢复大卡片。保留成功默认收起、用户主动展开时维持展开的交互。完成步骤的状态仍可被辅助技术读取,视觉上通过对勾表示,省去每行重复的“已完成”。 + +在过程末尾加入带旋转标记的“AI 助手”当前动作与实际已读/已分析数量;折叠历史后该提示仍可见,完成或失败后移除。等待超过 30 秒的提示归入同一区域。没有编造模型旁白,现有模型 progress 阶段小结继续保留。 + +验证了同一运行先主动展开、再完成的路径:入口横坐标均为 336px,内边距均为 4px 0;过程主体横坐标均为 343px,内边距均为 2px 0 4px 10px,外框均无边框且背景透明。入口截图前有鼠标悬停底色,完成后指针已移到预览按钮,该颜色差异属于悬停。截图 unified-running.png、unified-completed.png 存在 tmp/agent-reference-qa。390px 深色下过程宽 301px,面板 scrollWidth 341px 与实际宽度 341px 相符,无横向溢出;截图 unified-narrow-dark.png。控制台无 error/warn。执行流及聊天组件 36 项通过,包含折叠后仍显示进展、计时更新、完成消失、失败可见、真实分析数量和等待说明归属。 + +本轮使用浏览器示例验证视觉状态转换,没有进行新的真实模型测试;前一轮桌面激活故障导致的实机验收仍待完成。未提交或推送。 + +## 待确认:会话菜单改为锚定浮层 + +用户截图 codex-clipboard-242196fc-0556-466a-a6a8-6cb305414e7f.png 中,重命名和删除菜单直接插入 article,撑高选中行并推开后续会话。现改为独立的原生 popover 顶层浮层,宽 176px,紧凑图标与文字,重命名与删除确认宽 260px;继承当前主题,深色删除色使用项目已有的浅红色。保留确认删除,默认聚焦取消。浮层锚定更多按钮,视口不足时翻转和限宽;外部点击、外部滚动、窗口变化和切换会话会关闭,支持方向键、Escape 与焦点返回。 + +浏览器使用实际 Vue 组件及示例数据。首次观察发现页面仍持有旧组件脚本,刷新后确认 popover-open 为 true。1200×900 下两条会话打开前后 top 分别为 253.99/313.33px、height 均为 57.34px,完全不变。390×844 深色下菜单宽 176px、重命名宽 260px 且左边界为 8px,均在视口内;1200×400 下重命名表单向上展开,底边 241.33px,与按钮顶边 245.33px 相隔 4px。键盘方向移动、Escape、删除取消与外部点击均实测通过;控制台无 error/warn。截图位于 tmp/agent-reference-qa/thread-menu-desktop.png、thread-menu-narrow-dark.png、thread-menu-rename.png、thread-menu-bottom-edge.png。窄屏截图中的预览工具栏文字换行问题属于测试页面原有布局,本次未修改该工具栏。 + +新增菜单组件 6 项测试,聊天组件 20 项回归测试通过。检查范围为菜单交互,不包含真实模型执行;前述实机模型验收仍待完成。修改本地保存,未经用户确认不推送。 + +## PR #142 提交前检查(2026-09-09) + +用户已明确授权将以上本地修改提交到 PR #142。提交目录已快进同步该 PR 最新的安装器与收藏修复(989818b),未覆盖这些改动。最终目录的前端 Vitest 233 项、Node 基础测试 62 项及后端 Agent/上下文/消息分页 67 项全部通过,git diff --check 通过。沿用上一轮实际组件浏览器的视觉检查结果;真实模型桌面输入测试仍未完成,不以模拟测试替代该项结论。 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/installer-custom.nsh b/desktop/scripts/installer-custom.nsh index c725714e..0e59621f 100644 --- a/desktop/scripts/installer-custom.nsh +++ b/desktop/scripts/installer-custom.nsh @@ -185,13 +185,23 @@ Function WDA_InitOutputDirSelection Call WDA_UseCurrentUserAppData Call WDA_PrepareOutputDirScript StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}" - nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$PLUGINSDIR\wda-output-dir.ps1" -Mode Read -DefaultSettingsPath "${WDA_DEFAULT_SETTINGS_PATH}" -DefaultOutputPath "${WDA_DEFAULT_OUTPUT_DIR}" -LegacySettingsPath1 "${WDA_PRODUCT_SETTINGS_PATH}" -LegacySettingsPath2 "${WDA_FILENAME_SETTINGS_PATH}"' + Delete "$PLUGINSDIR\wda-output-dir-result.txt" + nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "$PLUGINSDIR\wda-output-dir.ps1" -Mode Read -ResultPath "$PLUGINSDIR\wda-output-dir-result.txt" -DefaultSettingsPath "${WDA_DEFAULT_SETTINGS_PATH}" -DefaultOutputPath "${WDA_DEFAULT_OUTPUT_DIR}" -LegacySettingsPath1 "${WDA_PRODUCT_SETTINGS_PATH}" -LegacySettingsPath2 "${WDA_FILENAME_SETTINGS_PATH}"' Pop $0 Pop $1 ${If} $0 == "0" - ${AndIf} $1 != "" - StrCpy $WDA_SelectedOutputDir "$1" + ; 路径通过 Unicode 文件传递,避免控制台编码影响中文和生僻字。 + ClearErrors + FileOpen $2 "$PLUGINSDIR\wda-output-dir-result.txt" r + ${IfNot} ${Errors} + FileReadUTF16LE $2 $1 + FileClose $2 + ${If} $1 != "" + StrCpy $WDA_SelectedOutputDir "$1" + ${EndIf} + ${EndIf} ${EndIf} + Delete "$PLUGINSDIR\wda-output-dir-result.txt" Call WDA_RestoreInstallShellContext FunctionEnd diff --git a/desktop/scripts/installer-output-dir.ps1 b/desktop/scripts/installer-output-dir.ps1 index 2a2eb2d8..0193e248 100644 --- a/desktop/scripts/installer-output-dir.ps1 +++ b/desktop/scripts/installer-output-dir.ps1 @@ -1,4 +1,4 @@ -param( +param( [Parameter(Mandatory = $true)] [ValidateSet('Read', 'Write', 'RemoveLegacyLinks', 'DeleteCustom')] [string] $Mode, @@ -7,6 +7,7 @@ param( [string] $LegacySettingsPath1 = '', [string] $LegacySettingsPath2 = '', [string] $SelectedOutputPath = '', + [string] $ResultPath = '', [string] $CandidateLinkPath1 = '', [string] $CandidateLinkPath2 = '', [string] $PathFile = '', @@ -38,7 +39,8 @@ function Get-InstallerOutputDirectory { } try { - $json = Get-Content -LiteralPath $candidate -Raw | ConvertFrom-Json + # 配置由 Electron 以无 BOM 的 UTF-8 写入,不能使用系统默认代码页。 + $json = Get-Content -LiteralPath $candidate -Raw -Encoding UTF8 | ConvertFrom-Json if ($null -eq $json) { continue } @@ -73,6 +75,11 @@ function Get-InstallerOutputDirectory { function Read-InstallerOutputDirectory { $result = Get-InstallerOutputDirectory + if (-not [string]::IsNullOrWhiteSpace($ResultPath)) { + # NSIS 显式读取 UTF-16LE 文件,避免子进程标准输出被按本地代码页解码。 + [System.IO.File]::WriteAllText($ResultPath, $result, [System.Text.Encoding]::Unicode) + return + } [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::Write($result) } @@ -178,7 +185,7 @@ function Write-InstallerOutputDirectory { continue } try { - $existing = Get-Content -LiteralPath $candidate -Raw | ConvertFrom-Json + $existing = Get-Content -LiteralPath $candidate -Raw -Encoding UTF8 | ConvertFrom-Json if ($null -eq $existing) { continue } @@ -242,7 +249,7 @@ function Remove-InstallerCustomOutputDirectory { return } - $target = (Get-Content -LiteralPath $PathFile -Raw).Trim() + $target = (Get-Content -LiteralPath $PathFile -Raw -Encoding UTF8).Trim() $defaults = @($DefaultOutputPath, $LegacyOutputPath1, $LegacyOutputPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } $isDefault = $false 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/installer-output-dir.test.cjs b/desktop/tests/installer-output-dir.test.cjs index a27662aa..90d0e854 100644 --- a/desktop/tests/installer-output-dir.test.cjs +++ b/desktop/tests/installer-output-dir.test.cjs @@ -49,6 +49,61 @@ test("installer writes desktop settings as UTF-8 without a BOM", () => { assert.doesNotMatch(settingsScript, /\[System\.IO\.File\]::WriteAllText\(\$DefaultSettingsPath/); }); +test("PowerShell source keeps its UTF-8 BOM so Windows PowerShell can parse Chinese comments", () => { + // Windows PowerShell 5.1 对脚本源码也按 BOM 判断编码。 + assert.deepEqual([...fs.readFileSync(settingsScriptPath).subarray(0, 3)], [0xef, 0xbb, 0xbf]); +}); + +test("installer text reads declare UTF-8 even when the host default code page is UTF-8", () => { + // UTF-8 系统上隐式解码也可能通过运行测试,因此还需固定文件读取的编码约定。 + const reads = settingsScript.split(/\r?\n/).filter((line) => /\bGet-Content\b/.test(line) && !/^\s*#/.test(line)); + assert.ok(reads.length > 0); + for (const line of reads) assert.match(line, /-Encoding\s+UTF8\b/i); +}); + +test( + "installer preserves Unicode settings when updating and returns an explicit UTF-16LE path file", + { skip: process.platform !== "win32" }, + () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "wda-installer-unicode-")); + const settingsPath = path.join(root, "desktop-settings.json"); + const resultPath = path.join(root, "result.txt"); + const outputDir = String.raw`F:\软件备份\𠮷\wechat-data-analysis`; + const selectedOutput = String.raw`F:\聊天记录 & 备份\输出📁`; + const commonArgs = ["-DefaultSettingsPath", settingsPath, "-DefaultOutputPath", path.join(root, "default")]; + try { + fs.writeFileSync(settingsPath, JSON.stringify({ outputDir, label: "中文配置" }), "utf8"); + const firstRead = runInstallerSettingsHelper(["-Mode", "Read", ...commonArgs]); + assert.equal(firstRead.status, 0, firstRead.stderr); + assert.equal(firstRead.stdout, outputDir); + + const write = runInstallerSettingsHelper([ + "-Mode", "Write", ...commonArgs, "-SelectedOutputPath", selectedOutput, + ]); + assert.equal(write.status, 0, write.stderr); + assert.deepEqual(JSON.parse(fs.readFileSync(settingsPath, "utf8")), { + outputDir, label: "中文配置", pendingOutputDir: selectedOutput, + }); + + const read = runInstallerSettingsHelper(["-Mode", "Read", ...commonArgs, "-ResultPath", resultPath]); + assert.equal(read.status, 0, read.stderr); + assert.equal(read.stdout, ""); + const bytes = fs.readFileSync(resultPath); + assert.deepEqual([...bytes.subarray(0, 2)], [0xff, 0xfe]); + assert.equal(bytes.toString("utf16le").replace(/^\uFEFF/, ""), selectedOutput); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + } +); + +test("installer reads the Unicode result file instead of using captured stdout as a path", () => { + const body = installerScript.match(/Function WDA_InitOutputDirSelection(?[^]*?)FunctionEnd/)?.groups?.body; + assert.ok(body); + assert.match(body, /-ResultPath "\$PLUGINSDIR\\wda-output-dir-result\.txt"/); + assert.match(body, /FileReadUTF16LE \$2 \$1/); +}); + test("installer keeps filename-based AppData as a legacy read and uninstall alias", () => { assert.match(installerScript, /WDA_FILENAME_SETTINGS_PATH/); assert.match(installerScript, /WDA_FILENAME_OUTPUT_DIR/); diff --git a/desktop/tests/installer-unicode-integration.test.cjs b/desktop/tests/installer-unicode-integration.test.cjs new file mode 100644 index 00000000..2d3e7c94 --- /dev/null +++ b/desktop/tests/installer-unicode-integration.test.cjs @@ -0,0 +1,134 @@ +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 { spawnSync } = require("node:child_process"); +const { writeDesktopSettingsFileAtomic, parseDesktopSettingsText } = require("../src/desktop-settings.cjs"); + +const desktopRoot = path.resolve(__dirname, ".."); +const helperPath = path.join(desktopRoot, "scripts", "installer-output-dir.ps1"); +const installerSource = fs.readFileSync(path.join(desktopRoot, "scripts", "installer-custom.nsh"), "utf8"); +const powershellPath = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + +async function resolveCompiler() { + if (process.env.NSIS_MAKENSIS) return { path: process.env.NSIS_MAKENSIS }; + // 使用锁定的打包器解析工具及 NSISDIR,兼容新旧缓存布局;工具缺失时下载,失败则测试失败。 + const { getMakeNsisPath } = require("app-builder-lib/out/toolsets/windows"); + const { build } = require("../package.json"); + return getMakeNsisPath(build.toolsets?.nsis, build.nsis?.customNsisBinary); +} + +function run(command, args, { env = {}, timeout = 30000, label = path.basename(command) } = {}) { + const startedAt = Date.now(); + const result = spawnSync(command, args, { + encoding: "utf8", windowsHide: true, timeout, env: { ...process.env, ...env }, + }); + assert.equal(result.status, 0, [ + `${label}: elapsed=${Date.now() - startedAt}ms, timeout=${timeout}ms, status=${result.status}, signal=${result.signal}`, + result.error?.message, result.stderr, result.stdout, + ].filter(Boolean).join("\n")); + return result; +} + +// 测试只编译目录初始化和保存函数;所有配置与输出均位于独立临时目录。 +test("compiled NSIS and PowerShell preserve Unicode paths through install and upgrade", { + skip: process.platform !== "win32", +}, async () => { + const compiler = await resolveCompiler(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "wda-nsis-unicode-")); + try { + const configRoot = path.join(root, "中文配置 & 空格"); + fs.mkdirSync(configRoot); + const settingsPath = path.join(configRoot, "desktop-settings.json"); + const legacyPath = path.join(configRoot, "legacy.json"); + const resultPath = path.join(root, "actual.txt"); + const defaultOutput = path.join(configRoot, "默认输出📁"); + const exePath = path.join(root, "probe.exe"); + const quote = (value) => `"${value.replaceAll("$", "$$")}"`; + const functions = ["WDA_InitOutputDirSelection", "WDA_WritePendingOutputDirSetting"].map((name) => { + const match = installerSource.match(new RegExp(`Function ${name}[^]*?FunctionEnd`)); + assert.ok(match, name); + return match[0]; + }); + const script = [ + "Unicode true", "RequestExecutionLevel user", "SilentInstall silent", "!include LogicLib.nsh", + `OutFile ${quote(exePath)}`, 'Name "Unicode path probe"', "Var WDA_SelectedOutputDir", + `!define WDA_DEFAULT_SETTINGS_PATH ${quote(settingsPath)}`, + `!define WDA_DEFAULT_OUTPUT_DIR ${quote(defaultOutput)}`, + `!define WDA_PRODUCT_SETTINGS_PATH ${quote(legacyPath)}`, + `!define WDA_FILENAME_SETTINGS_PATH ${quote(path.join(root, "missing.json"))}`, + "Function WDA_UseCurrentUserAppData", "FunctionEnd", + "Function WDA_RestoreInstallShellContext", "FunctionEnd", + "Function WDA_PrepareOutputDirScript", "InitPluginsDir", + `File /oname=$PLUGINSDIR\\wda-output-dir.ps1 ${quote(helperPath)}`, "FunctionEnd", + ...functions, + "Section", "Call WDA_InitOutputDirSelection", "Call WDA_WritePendingOutputDirSetting", + `FileOpen $3 ${quote(resultPath)} w`, "FileWriteUTF16LE $3 $WDA_SelectedOutputDir", "FileClose $3", + "SectionEnd", + ].join("\n"); + const scriptPath = path.join(root, "probe.nsi"); + fs.writeFileSync(scriptPath, "\uFEFF" + script, "utf8"); + run(compiler.path, ["/V2", scriptPath], { env: compiler.env }); + + const chineseOutput = path.join(configRoot, "软件备份", "𠮷", "wechat-data-analysis"); + const pendingOutput = path.join(configRoot, "聊天记录 & 空格-$folder-$(1+1)-`tick", "输出📁"); + const cases = [ + { name: "首次安装", expected: defaultOutput }, + { name: "Electron 无 BOM 配置升级", settings: { outputDir: chineseOutput, label: "中文配置" }, expected: chineseOutput }, + { name: "已选择待迁移目录", settings: { outputDir: chineseOutput, pendingOutputDir: pendingOutput }, expected: pendingOutput }, + { name: "旧版有 BOM 配置", settings: { outputDir: chineseOutput }, bom: true, expected: chineseOutput }, + { name: "旧版配置迁移", legacy: { outputDir: pendingOutput, label: "保留设置" }, expected: pendingOutput }, + { name: "恢复默认目录", settings: { outputDir: chineseOutput, pendingOutputDir: "" }, expected: defaultOutput }, + ]; + for (const fixture of cases) { + fs.rmSync(settingsPath, { force: true }); + fs.rmSync(legacyPath, { force: true }); + if (fixture.settings) { + writeDesktopSettingsFileAtomic(settingsPath, fixture.settings); + if (fixture.bom) fs.writeFileSync(settingsPath, "\uFEFF" + fs.readFileSync(settingsPath, "utf8")); + } + if (fixture.legacy) writeDesktopSettingsFileAtomic(legacyPath, fixture.legacy); + // 连续运行两次,确认保存后再次回填不会二次转码。 + for (let attempt = 0; attempt < 2; attempt++) { + fs.rmSync(resultPath, { force: true }); + // CI 中探针及其 PowerShell 子进程可能启动较慢;保留超时和全部结果断言,不自动重试。 + run(exePath, [], { timeout: 120000, label: `${fixture.name}(第 ${attempt + 1} 次)` }); + assert.equal(fs.readFileSync(resultPath, "utf16le"), fixture.expected, fixture.name); + const expectedSettings = { ...fixture.settings, ...fixture.legacy, + pendingOutputDir: fixture.expected === defaultOutput ? "" : fixture.expected }; + assert.deepEqual(parseDesktopSettingsText(fs.readFileSync(settingsPath, "utf8")), expectedSettings, fixture.name); + } + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("uninstall helper reads UTF-8 paths and preserves default and unrelated directories", { + skip: process.platform !== "win32", +}, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "wda-uninstall-unicode-")); + try { + const defaultOutput = path.join(root, "默认输出"); + const customOutput = path.join(root, "聊天记录 & 备份📁"); + const unrelatedOutput = path.join(root, "其他文件"); + const pathFile = path.join(root, "output-location.path"); + for (const directory of [defaultOutput, customOutput, unrelatedOutput]) { + // 先验证实际删除目标始终位于本测试的临时目录中。 + assert.ok(path.resolve(directory).startsWith(path.resolve(root) + path.sep)); + fs.mkdirSync(directory); + fs.writeFileSync(path.join(directory, "keep.txt"), "测试文件", "utf8"); + } + for (const target of [defaultOutput, customOutput]) { + fs.writeFileSync(pathFile, target + "\n", "utf8"); + run(powershellPath, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", helperPath, + "-Mode", "DeleteCustom", "-DefaultOutputPath", defaultOutput, "-PathFile", pathFile]); + assert.equal(fs.existsSync(target), target === defaultOutput); + assert.equal(fs.readFileSync(path.join(defaultOutput, "keep.txt"), "utf8"), "测试文件"); + assert.equal(fs.readFileSync(path.join(unrelatedOutput, "keep.txt"), "utf8"), "测试文件"); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/desktop/tests/output-dir.test.cjs b/desktop/tests/output-dir.test.cjs index b36188fc..179207b8 100644 --- a/desktop/tests/output-dir.test.cjs +++ b/desktop/tests/output-dir.test.cjs @@ -77,6 +77,25 @@ test("migrateOutputDirectory switches empty source to a new directory", async () } }); +test("migrateOutputDirectory preserves Unicode directory names and file contents", async () => { + const root = makeTempDir(); + const currentDir = path.join(root, "原始聊天记录"); + const nextDir = path.join(root, "软件备份 & 空格", "𠮷-输出📁"); + const relativeFile = path.join("databases", "中文账号", "消息.db"); + const contents = Buffer.from([0, 255, 1, 128, 34, 10]); + try { + fs.mkdirSync(path.dirname(path.join(currentDir, relativeFile)), { recursive: true }); + fs.writeFileSync(path.join(currentDir, relativeFile), contents); + const result = await migrateOutputDirectory({ currentDir, nextDir }); + assert.equal(result.changed, true); + assert.deepEqual(fs.readFileSync(path.join(nextDir, relativeFile)), contents); + assert.deepEqual(fs.readFileSync(path.join(result.backupDir, relativeFile)), contents); + assert.equal(getEffectiveOutputDirPath({ dataDir: root, settingsOutputDir: nextDir }), nextDir); + } finally { + cleanupDir(root); + } +}); + test("migrateOutputDirectory blocks non-empty targets", async () => { const root = makeTempDir(); const currentDir = path.join(root, "current-output"); 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-chat-ui-validation.md b/docs/ai-chat-ui-validation.md new file mode 100644 index 00000000..b7562e1e --- /dev/null +++ b/docs/ai-chat-ui-validation.md @@ -0,0 +1,57 @@ +# AI 对话界面与 Vue 接入验收 + +日期:2026-09-09。 + +## 实现 + +- 使用官方 assistant-ui Vue primitives,复用现有消息、引用、工具详情、输入区和后端;移除 React 桥接。 +- 展开视图默认显示左侧会话导航,支持搜索、新建、切换、改名、删除及失败重试;窄侧栏使用会话抽屉。 +- 收紧标题、读取范围、消息和工具过程的间距;移除重复运行状态,工具详情可展开,用量集中展示。 +- 右侧面板可拖动宽度并记忆;保留键盘调整及恢复默认宽度。 +- 保留本轮已实现的 models.dev / 上游能力填入与手动覆盖、下拉内搜索、选中文字拖出弹窗不误关闭,以及 Agent 人为额度移除。 + +## 自动验证 + +| 范围 | 结果 | 记录 | +| --- | --- | --- | +| 全部前端 Vitest | 18 个文件,212 项通过 | `tmp/assistant-vue-all-tests.log` | +| 前端 Node 测试 | 62 项通过 | `tmp/agent-node-tests.log` | +| 全部 AI 后端及模型目录测试 | 156 项通过 | `tmp/agent-backend-all-tests.log` | +| Nuxt 生产构建 | 成功,产物约 6.07 MB | `tmp/assistant-vue-build.log` | +| 依赖核对 | 无直接 React / React DOM / assistant-ui React 依赖 | `frontend/package.json` 和锁文件 | + +前端测试使用真实 Vue primitives 与 external-store 运行时,不替换为假组件。覆盖流式增量更新保持工具展开节点、停止与继续、直接切换会话、旧消息清理、仅客户端挂载,以及 IME 输入、幂等提交、异步切换、账号隔离、历史加载失败和改名/删除失败恢复。另覆盖宽度保存、边界与拖动清理;模型元数据、下拉搜索和弹窗选择行为纳入全部前端测试。 + +后端覆盖 AI 服务、模型适配、Agent、上下文、消息分页、模型目录等测试。使用工作区独立临时目录运行,避免系统 pytest 临时目录的权限清理错误。构建保留项目已有的重复 `formatInt` 自动导入及较大分块提示。 + +## 浏览器交互 + +- 真实应用:默认展开左侧导航,读到现有 5 个 AI 会话;选中会话恢复 2 条消息,当前项高亮,Vue 容器已加载,无界面错误。 +- 真实应用:创建专用空白测试会话,改名后标题与列表同步;确认删除后恢复为原有 5 项,测试会话已清理。 +- Vue 迁移后的真实历史恢复检查中,前端开发服务退出造成请求失败;恢复前端服务并重试后列表与消息加载正常,后端服务正常。 +- 示例页:流式生成、停止、继续到完成;工具调用与回答显示正常。示例数据不调用模型。 +- 示例页:展开视图左栏宽 232 像素,浅色/深色可读;读取范围、输入区和引用继续使用现有 UI。 +- 示例页:引用可打开原文与上下文;主区域无横向溢出。 +- 示例页:侧栏从 320 拖至 480 像素,宽度正确变化;窄模式会话抽屉宽 280 像素,显示两条示例会话。 + +本轮界面验收没有发起新的真实模型生成请求;真实应用验证会话存储和恢复,生成、停止及继续交互使用可重复的模拟流,并由后端自动测试覆盖对应接口。 + +## 依赖说明 + +官网 Vue 文档可用,但上游包仍为未发布的私有预览包。本项目使用有 MIT 许可的固定官方源码快照,并固定配套运行时依赖;来源及升级步骤见 `frontend/vendor/assistant-ui-vue/README.md`。这不是已发布稳定版本的承诺。后续正式包发布后,可替换本地依赖继续复用现有 UI。 + +## 流式过程与来源标记追加修复 + +针对过程消息出现 `(source: 622367649b6ca270ffad893a)` 的问题,过程与最终回答共用 Markdown 来源渲染。兼容标准 `[[ID]]`、英文括号、中文括号及 `[source: ID]`;仅对后端返回的已验证来源显示可点击编号。未知来源显示“来源待核实”,流式尚未闭合的标记等待后续分片;代码块和行内代码保持原样。 + +后端来源清单补充过程消息引用到的证据,包括默认前 20 条预览之外的来源,仍通过当前运行的证据库核验。已有服务进程需重启后加载这项后端修复。 + +执行过程改为沿正文排列的紧凑工具行和进展文本,取消整段竖线;实时状态在内容末尾独立显示,折叠工具历史后仍可见。全部来源入口合并到“用量与读取范围”中,减少独立占行。 + +追加验证:全部前端 216 项通过(`tmp/agent-stream-tests.log`);Agent 与上下文后端共 30 项通过(`tmp/agent-stream-backend.log`、`tmp/agent-stream-context.log`);独立目录生产构建通过(`tmp/agent-stream-build.log`),开发服务保持运行。浏览器实测过程编号打开正确原文、实时状态和工具折叠、浅色/深色窄侧栏无横向溢出。模拟流验证未调用真实模型。 + +## 选定第二版后的缩进与层级调整 + +按用户提供的 Codex 截图修正:正文、工具图标、最终回答共用左边线;工具名称与展开详情缩进 26px。相邻相同工具、会话、查询、范围和输入版本的调用合并显示,详情保留每次调用及缓存复用;失败和进行中状态仍显露。工具数量、结果数量和耗时主要放入详情,用量入口移到回答下方。过程默认保留紧凑记录,完成或滚动不再自动收起,流式更新保留手动选择。 + +全部前端 217 项通过(`tmp/agent-indent-tests.log`);生产构建通过(`tmp/agent-indent-build.log`)。浏览器核对浅色、深色、498px 窄窗、工具详情及来源面板;实际测量正文/图标/答案 x=260,名称/详情 x=286。预览使用示例接口,本轮未新增真实模型调用。视觉对照和修订记录见项目根目录 `design-qa.md` 的最新验收。 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..cb80a9f3 --- /dev/null +++ b/docs/ai-macos-compatibility.md @@ -0,0 +1,85 @@ +# 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 +export UV_MANAGED_PYTHON=true +uv python install 3.11 +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 +``` + +AI 跨平台 CI 同样设置 `UV_MANAGED_PYTHON=true` 并安装 uv 管理的 Python 3.11,使源码检查与 PyInstaller 冻结检查共用支持 SQLite 扩展的解释器。仅指定 Python 3.11 版本不足以保证此能力:macOS 上 `actions/setup-python` 提供的解释器也可能缺少 `enable_load_extension`。参见 [uv 的托管 Python 设置](https://docs.astral.sh/uv/reference/environment/#uv_managed_python)。 + +模型已下载时,运行检查器可加 `--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..086efcd7 --- /dev/null +++ b/docs/chat-agent.md @@ -0,0 +1,105 @@ +# 聊天 Agent + +跨平台运行策略、Mac 实机结果及未覆盖项目见 [macOS AI 兼容性与验收](ai-macos-compatibility.md)。 + +聊天页右上角 AI 默认进入对话模式;原消息总结、自动任务、关注提醒位于「工具」。侧栏和大视图共享状态。 + +## 使用 + +- 默认跟随当前聊天;点图钉可固定当前 AI 对话。查看来源时自动固定,避免原文跳转带走正在进行的 AI 对话。 +- 可连续提问,处理中发送的内容记为补充,在下一次动作前应用;输入法确认键不发送,Shift+Enter 换行。 +- 「读取范围」可手动选择会话;明确要求搜索其他群或点名会话时才扩大授权,范围保留在当前 AI 对话。 +- 历史可以新建、重命名和删除;与微信聊天记录分开保存,账号之间隔离。 +- Agent 不再设置工具调用、模型请求、媒体分析数量和整轮运行时间额度,历史额度配置也不再生效。AI 服务设置只保留模型服务、本地检索和用量记录。 +- 手动停止或应用重启后,点击继续查找会保留已有证据与用量记录。缓存页无需重复调用视觉模型。 +- 用量关联每轮运行 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 面板和自定义选择器测试。 +- 自动测试全部使用模拟响应;真实模型验收应使用用户指定的问题和会话,查看本次用量记录。 + +## 执行对话流与协议修复 + +工具调用按时间显示,展开可查看范围、页码位置和覆盖情况;关键发现穿插简短进展说明。过程默认保留紧凑记录,完成和滚动不会自动收起;用户可手动折叠,流式更新保留选择。历史每轮可以重新加载过程。 + +决策根据 models.dev 的工具能力选择工具调用或 JSON 动作协议;未知能力沿用运行时探测。上游明确拒绝强制工具选择时改用 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;本次不新增上传入口。 + +上下文窗口、输出能力与图片能力通过 [models.dev 公共 API](https://models.dev/api.json) 按服务商和模型 ID 匹配,自定义接口仅接受唯一的精确同名匹配,界面标明原厂参考参数。获取上游模型列表时,也读取其中明确提供的上下文、输出、模态和能力字段并在本机缓存;缺少的字段由 models.dev 补充。优先级为:手动设置 > 上游返回 > models.dev。自动填充后仍可编辑上下文、图片开关,以及展开项中的最大输出、工具、结构化输出等能力;手动值单独保存,刷新和重新打开均不覆盖,可点击“恢复自动识别”清除。切换模型或接口后重新识别,不沿用前一个模型的手动值。 + +模型配置展示输入/输出模态、工具、推理、结构化输出、温度、附件、开放权重、价格、日期与供应商 Logo,并标注参数来源。目录缓存一天,离线沿用上次成功数据;未收录模型也允许手动配置。 + +不再使用整轮可配置的 12,000 输入额度。单次请求根据模型窗口预留输出与封装空间;未知窗口以 32,768 字节作为初始分段大小,并在上游报告窗口不足时缩小。未知分词器时用 UTF-8 字节上界保守计量文本;图片独立预留空间,不把 Base64 当成文本 Token。实际 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`。记录核对覆盖范围、引用存在性和用量一致性,不将这些程序检查当作语义提取绝无遗漏的证明。 + +### 对话界面与侧栏宽度 + +对话消息容器改为官方 `@assistant-ui/vue` 原生组件:`AuiProvider`、`ThreadPrimitiveRoot`、`ThreadPrimitiveViewport`、`ThreadPrimitiveMessages` 和 `MessagePrimitiveRoot`。通过官方 external-store 接口适配现有本地对话和 Agent 流;输入、补充要求、幂等提交、原文引用、工具详情及会话持久化继续复用现有 Vue 组件与服务。运行时只在客户端挂载,消息按 ID 更新,关闭面板时释放订阅,无 React 根节点或跨框架 Teleport。 + +官方 Vue 包尚未发布到 npm,本项目固定保存提交 `3a45a01c0d6141102638ecd4f32d1af4d01fb510` 的官方源码,通过 `file:vendor/assistant-ui-vue` 接入;core/store/tap 固定为 0.3.17 / 0.3.12 / 0.9.16。来源、许可、升级方法见 `frontend/vendor/assistant-ui-vue/README.md`。该版本属于预览集成,不能当作已发布的稳定 Vue 包。共享运行时按官方方式使用 tap standalone shim,已移除 React、React DOM 和 assistant-ui React 依赖。 + +布局参考 Codex:用户消息采用紧凑圆角气泡,回答直接排版;执行过程按时间顺序显示工具调用和进展。工具图标与正文左对齐,名称及展开详情缩进 26px;相邻同工具、同会话、同查询和范围的调用合并为一行,展开仍可分别查看结果、耗时和复用情况。用量与覆盖情况放在回答下方;失败、暂停及未读取资料仍明确展示。默认保留紧凑过程,完成或滚动不改变用户的展开选择。 + +侧栏左边缘支持鼠标或触摸拖动、本地记忆宽度、双击恢复 440 像素;聚焦分隔条后使用左右方向键调整,Shift 加速,Home/End 调到边界,Enter 恢复默认。宽度根据窗口及其他侧栏自动收敛,展开大视图后收起可恢复原宽度。测试见 `assistant-thread.test.js`、`agent-panel-resize.test.js` 和现有 Agent 交互测试;预览页 `tests/fixtures/agent-redesign.html` 支持无模型调用的流式演示。 + + +展开大视图后默认显示左侧 AI 会话列表,支持搜索、新建、切换、重命名、删除和加载失败重试;删除前在行内确认。列表显示当前账号的会话及所属聊天,切换已有 AI 会话时固定读取范围;草稿按会话保存。收起为窄侧栏后可从顶部会话按钮打开抽屉,再次展开恢复左侧导航。异步读取按账号和请求版本隔离,避免快速切换或改名期间旧响应覆盖新状态。 + +本次界面与 Vue 接入验收见 `docs/ai-chat-ui-validation.md`。 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/export-broker-busy-investigation-20260909.md b/docs/export-broker-busy-investigation-20260909.md new file mode 100644 index 00000000..86fce18c --- /dev/null +++ b/docs/export-broker-busy-investigation-20260909.md @@ -0,0 +1,92 @@ +# 导出完成后 native broker BUSY:定位与复现 + +## 结论 + +导出在写完消息后生成完整性签名,调用 `seal_export_manifest()`。原实现要求 +`managed_native_core_operation(export_only=True)`,会将已运行的数据库 broker +停掉并切换为仅导出模式。只要另一个线程还有数据库查询等 native 操作, +`ensure_native_core_broker()` 就会抛出 BUSY,整个导出因此失败。 + +这是进程模式切换与并发操作的兼容性问题。失败发生在真正调用 native 签名接口之前, +与导出的附件内容无关;关闭媒体导出也不能解决。 + +## 日志证据 + +输入:用户提供的 `08_wechat_tool.log`。该文件追加了多个启动周期,不能只根据首行版本归因。 + +| 日志行 | 时间(2026-09-08) | 事实 | +| --- | --- | --- | +| 5 | 13:55:57 | 启动版本为 2.3.0 | +| 29 | 13:59:51 | 随后启动版本为 2.4.0 | +| 840 | 14:07:16 | 87 条消息、2 个媒体写完后 BUSY,与截图对应 | +| 941 | 14:09:59 | 只导出文本、关闭媒体后仍然 BUSY | +| 1431 | 14:12:15 | 再次导出失败,同一错误 | +| 1467 | 14:14:22 | 重新启动 2.4.0 | +| 1989 | 14:16:13 | 重启后 105 条消息、4 个媒体写完,再次 BUSY | + +四次异常均为 `BUSY with 1 active operation(s); cannot switch to export-only mode.`。 +调用链为:`ChatExportManager._run_job` → `write_zip_integrity_sidecars` → +`seal_entries` → `seal_export_manifest` → `managed_native_core_operation` → +`ensure_native_core_broker`。 + +界面 100% 对应会话处理已经完成;完整性签名和最终发布仍在后面执行,所以会出现 +“100% 后失败”。该问题不需要旧进程残留即可触发,重启不会改变这一调用关系。 + +## 版本差异与回退解释 + +上游发版顺序为 [2.2.1](https://github.com/LifeArchiveProject/WeChatDataAnalysis/releases/tag/v2.2.1) +→ [2.3.0](https://github.com/LifeArchiveProject/WeChatDataAnalysis/releases/tag/v2.3.0) +→ [2.4.0](https://github.com/LifeArchiveProject/WeChatDataAnalysis/releases/tag/v2.4.0)。 +日志能确认失败发生在 2.4.0;用户实际回退的版本尚未直接确认。 + +比较这些标签,`native_core_broker.py`、`native_core_export.py`、`export_integrity.py` +的相关逻辑没有变化,但 2.4.0 新增了两项关键改动: + +- `ccf73c4`:增加实时消息 SSE 接口。全账号模式每轮调用 native 会话查询,结束后只等待 5 毫秒。 +- `72a5d35`:前端从 `/chat/realtime/stream` 改连 + `/chat/realtime/messages?...&interval_ms=5`,并固定使用全账号范围。 + +对应代码为 `frontend/stores/chatRealtime.js` 的 `openStream()`,以及 +`routers/chat_realtime_sse.py` 的轮询循环。查询通过 `native_core_realtime._query_once()` +持有数据库操作租约。这里的 5 毫秒是每轮完成后的等待时间,并非查询固定每秒执行 200 次。 + +**代码支持的推断:** 新的高频查询显著增加签名与数据库操作重叠的机会,放大了既有缺陷。 +重启后恢复实时连接会再次触发;回退 2.3.0 则恢复旧的实时更新方式。这与用户反馈相符。 +现有日志只记录活跃操作数量,没有记录具体持有者或 SSE 连接状态,因此尚不能把四次失败 +全部断言为该轮询线程所致,也不能据此声称 2.3.0 在其他并发场景下绝不会失败。 + +## 复现与修复 + +回归测试:`tests/test_native_core_export_integration.py::test_zip_seal_reuses_database_broker`。 +它使用独立后台线程持有真实 Python broker 租约,通过事件固定并发时序;主线程写入 +87 条测试消息后走真实 ZIP 完整性调用链。进程对象和 wechatdb 签名、加密接口使用测试替身。 + +修改前,持有一个数据库租约的场景在同一行抛出与用户完全相同的 BUSY。 +修改后,忙碌和空闲数据库模式均可完成 ZIP 签名、导出加密;签名异常也不会泄漏租约。 +另有解密回归覆盖后台数据库操作仍在执行时的成功与 native 异常两条路径, +确认失败不发布结果、临时文件被清理、源文件保留且不误释放数据库租约。 + +修复在 `managed_native_core_operation()` 增加 `prefer_export_only`: + +- 已有存活 broker 时复用其模式,不为签名、加密、解密强制重启。 +- 无存活 broker 时仍启动仅导出模式,不要求存在数据库目录。 +- 模式选择与租约获取共用锁;严格 `export_only=True` 的原有检查保留。 +- 原有 native 签名及授权校验保留,不跳过签名或吞掉错误。 + +验证命令(仓库根目录下,`--basetemp` 应选择新的测试目录): + +```powershell +.venv/Scripts/python.exe -m pytest tests/test_native_core_broker_lifecycle.py tests/test_native_core_export_integration.py tests/test_chat_export_cancel_responsiveness.py tests/test_native_core_voice_asr.py tests/test_native_core_realtime.py -q --basetemp tmp/pytest-export-busy-check +``` + +结果:85 passed,4 subtests passed。 + +再次复核时,将 Git HEAD 中未修改的两个业务文件加载到独立临时目录,运行同一组 +“后台数据库占用时签名 / 解密”用例:两个用例均在原模式切换检查处抛出与用户一致的 BUSY。 +加载当前修复后,这两个用例通过,完整相关测试集再次得到 85 passed、4 subtests passed。 +对照过程没有回退或覆盖工作区源码。 + +本机没有 `wechatdb_broker.exe`,额外运行 `tests/test_export_integrity.py` 时, +5 项依赖真实 native 签名的用例因组件缺失失败。当前完成的是代码调用链和确定性并发复现, +尚未完成用户数据库、发布版 native 组件和桌面界面的端到端复测。发布前应使用正式组件, +开启实时消息后重复验证 TXT/HTML ZIP 导出、签名有效性及导出后的实时读取。 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/docs/windows-path-encoding.md b/docs/windows-path-encoding.md new file mode 100644 index 00000000..93ae8562 --- /dev/null +++ b/docs/windows-path-encoding.md @@ -0,0 +1,24 @@ +# Windows 路径编码约定 + +中文路径必须在首次选择、保存配置、升级回填和迁移数据后保持原字符串。 + +- Electron 配置与 `output-location.path` 使用 UTF-8;PowerShell 读取时必须显式指定 `-Encoding UTF8`。 +- 含中文的 Windows PowerShell 5.1 脚本源码保留 UTF-8 BOM。配置文件使用无 BOM 的 UTF-8,两者用途不同。 +- NSIS 与 PowerShell 的路径返回值通过 UTF-16LE 文件及 `FileReadUTF16LE` 传递,不能把未经明确解码的标准输出直接当成路径。 +- 不通过猜测编码、忽略解码错误或更改用户系统代码页来修复路径。 + +## 验证 + +在 Windows 上执行 `npm ci --ignore-scripts`(工作目录为 `desktop`),然后执行: + +```powershell +node --test tests/desktop-settings.test.cjs tests/output-dir.test.cjs tests/installer-output-dir.test.cjs tests/installer-safety.test.cjs tests/installer-unicode-integration.test.cjs +``` + +真实 NSIS 测试使用 package-lock 锁定的 electron-builder 获取编译器,自动处理其缓存布局与 NSISDIR;获取或运行失败会使测试失败,Windows 上不会因缺少编译器而跳过。离线环境可通过 `NSIS_MAKENSIS` 指定完整编译器路径,并按需设置 `NSISDIR`。 + +测试覆盖中文、生僻字、emoji、空格、PowerShell 特殊符号、首次安装、已有配置升级、旧版 BOM 配置、旧配置迁移、恢复默认目录、重复回填保存,以及数据文件内容和卸载目录边界。 + +相关 PR 和 main 推送由 `Windows Installer Unicode` 工作流检查;Windows 发布流程也运行同一组测试,失败时不会继续发布。PR 检查是否强制阻止合并取决于仓库的分支保护设置。 + +2.3 和 2.4 曾包含相同的编码缺陷:首次选择中文目录可正确保存,但安装器再次读取时产生乱码。因此回归验证必须覆盖再次读取,不能只检查首次选择成功。 diff --git a/frontend/assets/css/agent.css b/frontend/assets/css/agent.css new file mode 100644 index 00000000..fd1bd607 --- /dev/null +++ b/frontend/assets/css/agent.css @@ -0,0 +1,374 @@ +.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); position:relative; width:var(--agent-panel-width,440px); max-width:calc(100vw - 24px); min-height:0; min-width: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-resizer { position:absolute; left:-4px; top:0; bottom:0; width:9px; z-index:10; cursor:col-resize; touch-action:none; } +.agent-resizer::after { content:""; position:absolute; left:3px; top:0; bottom:0; width:2px; background:transparent; transition:background .15s; } +.agent-resizer:hover::after, .agent-resizer:focus-visible::after, .agent-panel.is-resizing .agent-resizer::after { background:var(--app-text-secondary,#7b8490); } +.agent-resizer:focus-visible { outline:2px solid #079b57; outline-offset:-2px; } +.agent-assistant-thread, .agent-thread-root { display:flex; flex-direction:column; min-height:0; flex:1; } +.agent-message { min-width:0; } +.agent-shell { display:flex; flex:1; min-height:0; min-width:0; position:relative; } +.agent-main { display:flex; flex:1; flex-direction:column; min-height:0; min-width:0; } +.agent-thread-list { display:flex; flex-direction:column; flex:0 0 232px; min-height:0; min-width:0; background:var(--ag-soft); padding:12px 10px; border-right:1px solid var(--ag-border); } +.agent-thread-list > header { display:flex; align-items:center; justify-content:space-between; min-height:34px; padding:0 4px; margin-bottom:12px; } +.agent-thread-list > header strong { display:flex; align-items:center; gap:9px; font-size:14px; font-weight:600; } +.agent-thread-list > header button, .agent-thread-list-heading button { color:var(--ag-muted); } +.agent-thread-list .agent-thread-new { display:flex; align-items:center; gap:10px; width:100%; text-align:left; font-size:13px; padding:9px; margin-bottom:10px; } +.agent-thread-search { display:flex; align-items:center; gap:8px; padding:7px 10px; border:1px solid var(--ag-border); border-radius:8px; color:var(--ag-muted); font-size:11px; } +.agent-thread-search input { width:100%; min-width:0; border:0; outline:0; color:inherit; background:transparent; font:inherit; } +.agent-thread-search:focus-within { outline:2px solid var(--ag-muted); outline-offset:1px; } +.agent-thread-list-heading { display:flex; align-items:center; justify-content:space-between; padding:18px 8px 6px; font-size:11px; color:var(--ag-muted); } +.agent-thread-items { flex:1; min-height:0; overflow-y:auto; scrollbar-width:thin; } +.agent-thread-item { position:relative; border-radius:8px; margin:2px 0; } +.agent-thread-item.is-current { background:color-mix(in srgb,var(--app-text-primary,#191919) 7%,transparent); } +.agent-thread-item:hover { background:color-mix(in srgb,var(--app-text-primary,#191919) 4%,transparent); } +.agent-thread-list .agent-thread-select { display:block; width:100%; padding:9px 30px 9px 10px; text-align:left; min-width:0; } +.agent-thread-select > span { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; } +.agent-thread-select > small { display:flex; align-items:center; gap:8px; color:var(--ag-muted); font-size:10px; margin-top:3px; } +.agent-thread-select > small i { margin-left:auto; } +.agent-thread-list .agent-thread-more { position:absolute; top:7px; right:2px; padding:5px; color:var(--ag-muted); } +.agent-thread-management { position:fixed; inset:auto; z-index:100; box-sizing:border-box; margin:0; padding:4px; overflow:auto; border:1px solid var(--ag-border); background:var(--ag-bg); color:var(--app-text-primary,#25352d); border-radius:8px; box-shadow:0 4px 16px #00000016,0 1px 3px #0000000a; font-size:12px; line-height:1.5; } +.agent-thread-management::backdrop { background:transparent; } +.agent-thread-management button { display:flex; align-items:center; gap:9px; width:100%; min-height:34px; padding:7px 10px; border-radius:4px; text-align:left; font:inherit; } +.agent-thread-management button i { width:14px; text-align:center; color:var(--ag-muted); } +.agent-thread-management button:hover, .agent-thread-management button:focus-visible { background:var(--ag-soft); } +.agent-thread-management button:focus-visible { outline:2px solid var(--ag-muted); outline-offset:-2px; } +.agent-thread-management .is-destructive, .agent-thread-management .is-destructive i { color:var(--app-danger,#bd4d40); } +html[data-theme="dark"] .agent-thread-management .is-destructive, html[data-theme="dark"] .agent-thread-management .is-destructive i { color:var(--app-danger,#ff9189); } +.agent-thread-management.is-confirming { padding:12px; } +.agent-thread-management label, .agent-thread-management strong { display:block; margin-bottom:8px; font-size:12px; font-weight:600; } +.agent-thread-management input { width:100%; min-width:0; box-sizing:border-box; padding:7px; font:inherit; background:var(--ag-bg); color:inherit; border:1px solid var(--ag-border); border-radius:5px; } +.agent-thread-management input:focus-visible { outline:2px solid var(--ag-muted); outline-offset:1px; } +.agent-thread-management p { margin:0 0 12px; font-size:12px; color:var(--ag-muted); } +.agent-thread-menu-actions { display:flex; flex-direction:row-reverse; gap:6px; margin-top:12px; } +.agent-thread-menu-actions button { justify-content:center; width:auto; min-height:30px; padding:5px 9px; background:var(--ag-soft); } +.agent-thread-empty, .agent-thread-error { padding:10px; color:var(--ag-muted); font-size:12px; } +.agent-thread-error { color:#b95039; } +.agent-thread-list > footer { display:flex; flex-direction:column; padding:12px 5px 0; gap:8px; border-top:1px solid var(--ag-border); font-size:12px; } +.agent-thread-list > footer button { display:flex; align-items:center; gap:10px; text-align:left; } +.agent-thread-list > footer span { color:var(--ag-muted); font-size:10px; padding:0 8px; } +.agent-panel:not(.is-expanded) .agent-thread-list { position:absolute; inset:0 auto 0 0; width:min(280px,100%); z-index:12; box-shadow:8px 0 30px #0002; } +.agent-context-label { font-size:10px; color:var(--ag-muted); white-space:nowrap; } +.agent-loading { padding:10px 20px; font-size:12px; color:var(--ag-muted); } +.agent-welcome-symbol { font-size:30px; } +.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-welcome-symbol, .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:10px 16px; min-height:54px; box-sizing:border-box; flex-shrink:0; } +.agent-header strong { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; font-size:14px; 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:0 20px 10px; height:30px; box-sizing:border-box; flex-shrink:0; } +.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:800px; margin:0 auto; width:100%; } +.agent-panel.is-expanded .agent-workspace.has-source .agent-reading { max-width:800px; } +.agent-conversation { flex:1; min-height:0; overflow-y:auto; padding:24px 20px; scrollbar-width:thin; overflow-wrap:anywhere; overscroll-behavior:contain; } +.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 22px auto; width:fit-content; max-width:88%; box-sizing:border-box; background:var(--ag-soft); border:0; padding:10px 14px; border-radius:14px; 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 32px; } +.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-wait-note { color:var(--ag-muted); font-size:11px; margin:10px 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:fit-content; max-width:100%; align-items:center; flex-wrap:wrap; gap:10px; color:var(--ag-muted)!important; padding:4px 0!important; margin:0 0 6px; font-size:13px!important; line-height:1.6; text-align:left; } +.agent-process-toggle small { margin-left:2px; white-space:nowrap; font-size:12px; } +.agent-process-toggle > i:last-child { font-size:10px; } +.agent-process { padding:0; margin:0 0 4px; } +.agent-tool { margin:2px -12px; padding:0 12px; border-radius:9px; color:var(--ag-muted); font-size:14px; } +.agent-tool[open] { background:var(--ag-soft); } +.agent-tool > summary { display:flex; align-items:center; gap:12px; list-style:none; min-height:40px; padding:8px 0; cursor:pointer; min-width:0; } +.agent-tool > summary::-webkit-details-marker, .agent-tool-attempt-row::-webkit-details-marker { display:none; } +.agent-tool > summary > i:first-child { width:16px; flex:none; font-size:15px; text-align:center; } +.agent-tool-title { display:flex; align-items:center; gap:10px; min-width:0; color:var(--app-text-primary,#25352d); } +.agent-tool-label { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.agent-tool-query, .agent-tool-count { font-size:12px; color:var(--ag-muted); padding:1px 8px; border-radius:8px; background:color-mix(in srgb,var(--ag-muted) 7%,var(--ag-bg)); line-height:1.6; } +.agent-tool-query { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:180px; } +.agent-tool-count { flex:none; white-space:nowrap; } +.agent-tool-summary { font-size:12px; white-space:nowrap; font-variant-numeric:tabular-nums; } +.agent-tool.is-grouped > summary > .agent-tool-summary { margin-left:auto; } +.agent-tool > summary > .agent-tool-outcome { flex:none; font-size:12px; white-space:nowrap; } +.agent-tool > summary > .agent-tool-chevron { flex:none; font-size:10px; width:10px; transition:transform .15s; } +.agent-tool[open] > summary > .agent-tool-chevron { transform:rotate(-90deg); } +.agent-tool > summary:hover { color:var(--app-text-primary,#25352d); } +.agent-tool summary:focus-visible, .agent-run-metadata > summary:focus-visible { outline:2px solid var(--ag-muted); outline-offset:3px; border-radius:4px; } +.agent-tool.is-running > summary > i:first-child { color:var(--app-text-primary,#25352d); } +.agent-tool.is-failed > summary, .agent-tool.is-failed .agent-tool-label { color:#b95039; } +.agent-tool-detail { margin:0 0 0 28px; padding:2px 0 12px; font-size:12px; overflow-wrap:anywhere; } +.agent-tool-attempt + .agent-tool-attempt { margin-top:10px; } +.agent-tool-detail header { display:flex; flex-wrap:wrap; align-items:center; gap:6px 12px; margin-bottom:6px; } +.agent-tool-detail header strong { font-weight:500; color:var(--app-text-primary,#25352d); } +.agent-tool-detail p { margin:4px 0; white-space:pre-wrap; } +.agent-tool-timeline { margin-left:34px; padding:3px 0 15px; } +.agent-tool-timeline .agent-tool-attempt { position:relative; padding:0 0 8px 20px; margin:0; } +.agent-tool-timeline .agent-tool-attempt:not(:last-child)::before { content:''; position:absolute; left:0; top:10px; bottom:-10px; width:1px; background:color-mix(in srgb,var(--ag-muted) 30%,transparent); } +.agent-tool-timeline .agent-tool-attempt:last-child { padding-bottom:0; } +.agent-tool-attempt-row { display:flex; align-items:baseline; flex-wrap:wrap; gap:2px 18px; list-style:none; cursor:pointer; min-height:24px; line-height:1.8; } +.agent-tool-attempt-row strong { font-weight:400; min-width:72px; } +.agent-tool-attempt-row .agent-tool-node { position:absolute; top:8px; left:-4px; font-size:6px; color:color-mix(in srgb,var(--ag-muted) 55%,var(--ag-bg)); } +.agent-tool-inspection[open] > summary { margin-bottom:8px; } +.agent-tool-inspection[open] { padding-bottom:4px; } +.agent-progress-note { line-height:1.7; margin:16px 0 8px; 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 16px 10px; } +.agent-input-box { border:1px solid var(--ag-border); border-radius:18px; padding:14px 14px 10px; background:var(--ag-soft); box-shadow:0 2px 8px #00000003; } +.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:10px; 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:var(--ag-bg); background:var(--app-text-primary,#25352d); width:30px; height:30px; border-radius:50%; padding:3px; font-size:15px; } +.agent-input-actions .agent-send:hover { background:var(--app-text-secondary,#5f5f5f); } +.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; } } +@container (max-width:700px) { .agent-panel.is-expanded .agent-thread-list { position:absolute; inset:0 auto 0 0; width:232px; z-index:12; box-shadow:8px 0 30px #0002; } .agent-panel.is-expanded .agent-welcome { display:block; } .agent-panel.is-expanded .agent-welcome > button { margin:8px 0; min-height:0; } } +@container (max-width:380px) { .agent-context-label { display:none; } } +@media(prefers-reduced-motion:reduce) { .agent-panel .fa-spin, .agent-shimmer { animation:none; } .agent-resizer::after, .agent-tool-chevron { transition:none!important; } } + + + +/* 深色背景下提高交互绿的对比度,选中的引用仍保留明确底色。 */ +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; } + +/* 执行记录沿用正文阅读轴;当前状态独立显示,折叠历史时也能看到进展。 */ +.agent-process-toggle .agent-shimmer { animation:none; background:none; color:inherit; -webkit-text-fill-color:currentColor; } +.agent-stream-status { display:flex; align-items:center; gap:8px; margin:14px 0 18px; color:var(--ag-muted); font-size:13px; } +.agent-stream-status time { font-size:10px; opacity:.7; font-variant-numeric:tabular-nums; } +.agent-status-dot { width:6px; height:6px; flex:none; border-radius:50%; background:currentColor; animation:agent-status-pulse 1.6s ease-in-out infinite; } +@keyframes agent-status-pulse { 50% { opacity:.3; } } +.agent-materials-link { display:block; color:var(--ag-muted); font-size:11px; margin:8px 0; text-decoration:underline; text-underline-offset:3px; } +.agent-ref-unresolved { font-size:.85em; color:var(--ag-muted); } +.agent-progress-note .agent-markdown > :first-child { margin-top:0; } +.agent-progress-note .agent-markdown > :last-child { margin-bottom:0; } +@container (max-width:480px) { .agent-tool-query { max-width:110px; } .agent-tool-outcome { overflow:hidden; text-overflow:ellipsis; max-width:60px; } } +@media(prefers-reduced-motion:reduce) { .agent-status-dot { animation:none; } } + +.agent-process > .agent-progress-note:first-child { margin-top:0; } +.agent-run-metadata { color:var(--ag-muted); font-size:12px; margin:4px 0 18px; } +.agent-run-metadata > summary { display:flex; align-items:center; gap:12px; list-style:none; cursor:pointer; width:fit-content; max-width:100%; min-height:28px; } +.agent-run-metadata > summary::-webkit-details-marker { display:none; } +.agent-run-metadata > summary > i:first-child { width:16px; text-align:center; font-size:12px; } +.agent-run-metadata > summary > i:last-child { font-size:10px; } +.agent-run-metadata[open] > summary > i:first-child { transform:rotate(90deg); } +.agent-run-metadata[open] { padding-bottom:12px; } +.agent-markdown .agent-ref { min-width:24px; min-height:22px; padding:2px 7px; margin:0 4px; background:#e5f7ef; color:#078653; border-radius:6px; font-size:12px; } +html[data-theme=dark] .agent-panel .agent-markdown .agent-ref { background:#173c2b; } +@container (max-width:480px) { + .agent-tool > summary { gap:9px; flex-wrap:wrap; } + .agent-tool-title { gap:7px; flex:1; } + .agent-tool-summary { font-size:11px; } + .agent-tool.is-grouped > summary > .agent-tool-summary { flex-basis:calc(100% - 28px); margin-left:25px; order:2; } + .agent-tool-label { white-space:normal; } + .agent-tool-query { max-width:85px; } + .agent-tool-attempt-row { column-gap:12px; } +} + + +/* 执行前后共用轻量阅读轴,状态变化不替换卡片、边框或标题层级。 */ +.agent-process-panel { --ag-muted:color-mix(in srgb,var(--app-text-primary,#191919) 68%,var(--ag-bg)); width:100%; max-width:100%; background:transparent; border:0; border-radius:0; margin-bottom:18px; } +.agent-process-panel > .agent-process-toggle { display:flex; width:fit-content; max-width:100%; margin:0; padding:4px 0!important; gap:8px; border:0; border-radius:4px; min-height:30px; background:transparent; } +.agent-process-title { color:var(--ag-muted); font-weight:400; font-size:12px; white-space:nowrap; } +.agent-process-toggle .agent-process-meta { margin:0; font-size:12px; font-weight:400; white-space:normal; } +.agent-process-disclosure { display:none; } +.agent-process-body { margin:6px 0 0 7px; border-left:1px solid var(--ag-border); padding:2px 0 4px 10px; background:transparent; } +.agent-process-body.is-collapsed { margin:2px 0 0; padding:0; border:0; } +.agent-live-step { display:flex; align-items:baseline; gap:10px; margin:6px 8px; padding:5px 0; color:var(--ag-muted); font-size:12px; line-height:1.65; } +.agent-live-step > i { width:14px; flex:none; color:#079b57; } +.agent-live-step > div { min-width:0; } +.agent-live-caption { font-size:11px; margin-right:8px; color:var(--ag-muted); } +.agent-live-step .agent-stream-status { display:inline; margin:0; color:var(--app-text-primary); font-size:13px; overflow-wrap:anywhere; } +.agent-live-hint { margin:3px 0 0; font-size:12px; } +.agent-live-step > time { flex:none; font-size:11px; font-variant-numeric:tabular-nums; } +html[data-theme=dark] .agent-live-step > i { color:#63d99a; } +.agent-process-body .agent-tool { margin:2px 0; padding:0 8px; font-size:13px; } +.agent-process-body .agent-tool-title { color:var(--ag-muted); } +.agent-process-body .agent-tool[open] { background:var(--ag-bg); } +.agent-process-body .agent-tool > summary { gap:10px; } +.agent-process-body .agent-progress-note { margin:14px 8px; padding:2px 0 2px 12px; border-left:2px solid var(--ag-border); color:var(--ag-muted); } +.agent-process-body .agent-progress-caption { display:block; margin-bottom:5px; color:var(--ag-muted); font-size:12px; font-weight:600; } +.agent-panel .agent-process-body .agent-progress-note .agent-markdown { font-size:13px; line-height:1.7; color:var(--ag-muted); } +.agent-process-body .agent-process-notice { margin:10px 8px; font-size:12px; line-height:1.65; } +.agent-stage-row { display:flex; align-items:baseline; flex-wrap:wrap; gap:4px 10px; padding:5px 8px; color:var(--ag-muted); font-size:12px; line-height:1.65; } +.agent-stage-row > i { width:14px; flex:none; text-align:center; font-size:10px; } +.agent-stage-row > span { overflow-wrap:anywhere; } +.agent-stage-row > small { font-size:11px; font-variant-numeric:tabular-nums; } +.agent-stage-row .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; } +.agent-stage-row.is-running > i { color:#079b57; } +.agent-process-body .agent-process:empty { display:none; } +.agent-process-body:has(.agent-process:empty) .agent-run-metadata { margin-top:0; padding-top:0; border-top:0; } +.agent-process-body .agent-run-metadata { margin:12px 8px 0; padding-top:10px; border-top:1px solid var(--ag-border); } +.agent-answer-heading { margin:0 0 12px; font-size:14px; line-height:1.5; font-weight:600; color:var(--app-text-primary); } +.agent-panel .agent-final-answer .agent-markdown { font-size:15px; line-height:1.85; } +@container (max-width:480px) { + .agent-process-toggle .agent-process-meta { flex:1; min-width:0; text-align:left; } + .agent-process-body { padding-left:6px; } + .agent-process-body .agent-tool { padding:0 6px; } +} + + +/* 回答辅助操作合为一行,说明仅在主动查看时占用正文空间。 */ +.agent-run .agent-result-actions { align-items:center; flex-wrap:wrap; gap:4px 12px; margin-top:8px; } +.agent-run .agent-result-actions button { display:inline-flex; align-items:center; gap:5px; min-height:28px; padding:2px 0; font-size:12px; white-space:nowrap; } +.agent-run .agent-result-actions i { margin:0; } +.agent-run .agent-result-actions .agent-copy-action { justify-content:center; width:28px; margin-left:-6px; } +.agent-run .agent-result-actions .agent-coverage-action { padding-left:12px; border-left:1px solid var(--ag-border); border-radius:0; } +.agent-coverage-explanation { margin:6px 0 0; padding:4px 0 4px 12px; border-left:2px solid var(--ag-border); color:var(--ag-muted); font-size:12px; line-height:1.65; } +.agent-coverage-explanation p { margin:4px 0; } + + +/* 会话状态与管理入口各占固定位置,悬停只改变颜色,不移动文字。 */ +.agent-thread-item { transition:background-color .14s ease; } +.agent-thread-item.is-current:hover, .agent-thread-item.is-current:focus-within { background:color-mix(in srgb,var(--app-text-primary,#191919) 9%,transparent); } +.agent-thread-item:not(.is-current):focus-within { background:color-mix(in srgb,var(--app-text-primary,#191919) 4%,transparent); } +.agent-thread-list .agent-thread-select { padding-right:40px; border-radius:8px; background:transparent; } +.agent-thread-list .agent-thread-select:hover { background:transparent; } +.agent-thread-select > small > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.agent-thread-running { position:absolute; right:12px; top:32px; display:grid; place-items:center; width:14px; height:14px; color:var(--ag-muted); font-size:11px; pointer-events:none; } +.agent-thread-list .agent-thread-more { top:4px; right:5px; width:26px; height:26px; display:grid; place-items:center; border-radius:5px; opacity:0; transition:opacity .14s ease,background-color .14s ease; } +.agent-thread-item:hover .agent-thread-more, .agent-thread-item:focus-within .agent-thread-more, .agent-thread-item.has-menu .agent-thread-more { opacity:1; } +.agent-thread-list .agent-thread-more:hover { background:color-mix(in srgb,var(--app-text-primary,#191919) 9%,transparent); } +@media (hover:none) { .agent-thread-list .agent-thread-more { opacity:1; } } +@media (prefers-reduced-motion:reduce) { .agent-thread-item, .agent-thread-list .agent-thread-more { transition:none; } } diff --git a/frontend/assets/css/ai-settings.css b/frontend/assets/css/ai-settings.css new file mode 100644 index 00000000..be2d6230 --- /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(3,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; } +.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-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-capability-options { margin-top:14px; } +.ais-capability-options summary { cursor:pointer; color:var(--ais-muted); font-size:12px; } +.ais-capability-options label { margin-top:12px; } +.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 00000000..6a86212c Binary files /dev/null and b/frontend/assets/icons/ai-providers/doubao.png differ diff --git a/frontend/assets/icons/ai-providers/doubao.svg b/frontend/assets/icons/ai-providers/doubao.svg new file mode 100644 index 00000000..5a1169c8 --- /dev/null +++ b/frontend/assets/icons/ai-providers/doubao.svg @@ -0,0 +1 @@ +Doubao \ 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 00000000..9359c5f7 Binary files /dev/null and b/frontend/assets/icons/ai-providers/lmstudio.png differ 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/AiModelMetadata.vue b/frontend/components/AiModelMetadata.vue new file mode 100644 index 00000000..ae31bedd --- /dev/null +++ b/frontend/components/AiModelMetadata.vue @@ -0,0 +1,41 @@ + + + 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..8ab40f59 --- /dev/null +++ b/frontend/components/AiSettings.vue @@ -0,0 +1,387 @@ + + + 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" > -
+ +