diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 909243c7e..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: CI - -on: - pull_request: - paths: - - ".github/workflows/ci.yml" - - "src-tauri/**" - - "src-tauri/Cargo.lock" - push: - branches: - - main - paths: - - ".github/workflows/ci.yml" - - "src-tauri/**" - - "src-tauri/Cargo.lock" - workflow_dispatch: - -permissions: - contents: read - -env: - CARGO_NET_RETRY: 10 - RUSTUP_MAX_RETRIES: 10 - CARGO_TERM_COLOR: always - -jobs: - windows-openclaw-regression: - name: Windows OpenClaw Regression - runs-on: windows-2022 - timeout-minutes: 45 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: x86_64-pc-windows-msvc - - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - with: - workspaces: src-tauri - shared-key: ci-windows-openclaw - cache-on-failure: true - - - name: Run OpenClaw install regression tests - shell: pwsh - run: cargo test -p lime-core openclaw_install --manifest-path "src-tauri/Cargo.toml" diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 259c5c168..a2f350a45 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -53,9 +53,8 @@ jobs: fi node scripts/quality-task-selector.mjs "${args[@]}" >> "$GITHUB_OUTPUT" - # 当前仓库的 Rust 回归验证仍以既有 `.github/workflows/ci.yml` - # 中的 Windows OpenClaw regression 为主。这里先收口前端/契约/一致性 - # 质量信号,后续再逐步补齐可稳定运行的 Rust 质量矩阵。 + # 这里先收口前端/契约/一致性质量信号,后续再逐步补齐可稳定运行的 + # Rust 质量矩阵。 integrity: name: Integrity diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 71e25046f..881415907 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,13 +1,13 @@ -## Lime v1.33.0 +## Lime v1.34.0 发布日期:`2026-05-11` 递交范围:完整 dirty worktree,包含 tracked、deleted 与新增文件;`.lime/` 等本地运行态忽略目录不纳入发布提交。 -> 发布说明:上一版 release tag 为 `v1.32.0`。本版按用户要求升级到 `v1.33.0`,并把本轮待递交的全部源码、文档、脚本、测试与发布证据索引纳入 release notes。 +> 发布说明:上一版 release tag 为 `v1.33.0`。本版按用户要求升级到 `v1.34.0`,并把当前完整 dirty worktree 的源码、文档、脚本、测试与发布说明索引纳入 release notes。 ### 发布概览 -- 应用版本从 `1.32.0` 升级到 `1.33.0`,同步 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`、`packages/lime-cli-npm/package.json` 与 `@limecloud/lime-cli` 发布示例。 +- 应用版本从 `1.33.0` 升级到 `1.34.0`,同步 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json`、`packages/lime-cli-npm/package.json` 与 `@limecloud/lime-cli` wrapper 版本。 - Agent QC 保留 scenario / GUI flow / Evidence Pack schema、qcloop job / status / preflight / export / release-summary / audit 脚本作为本地与人工证据工具;按本版要求从 GitHub Actions 发布/夜间/合同验证链路移除。 - Agent Runtime 与 Team / Workspace 主链补齐 approval sandbox、Claw chat ready streaming、tool surface、service skill entry、browser runtime、knowledge GUI 与 GUI owner 检查脚本,降低只靠浅层 smoke 误判的风险。 - i18n 主链从 legacy DOM Patch 迁到 key-based resources:新增 `createI18n`、namespace loader、locale resources、格式化工具与覆盖测试,旧 patch 文件移入 `src/i18n/legacy-patch/` 并从 current 入口退场。 @@ -52,7 +52,7 @@ ### 校验状态 -- `npm run verify:app-version`:通过,版本一致性为 `1.33.0`。 +- `npm run verify:app-version`:待本轮版本更新后复跑确认版本一致性为 `1.34.0`。 - `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:通过。 - `cargo test --manifest-path "src-tauri/Cargo.toml" --workspace --locked`:通过;使用 `CARGO_HOME=/tmp/lime-cargo-home-v133` 与 `CARGO_TARGET_DIR=src-tauri/target-release-v133-temp-home` 避开本机全局 Cargo 缓存损坏。 - `npm run lint:rust`:通过;已修复 `lime-media-runtime` 大 Err variant clippy warning,并在后续 MCP / server / agent 测试稳定性修复后复跑通过。 @@ -64,7 +64,7 @@ --- -**完整变更**: `v1.32.0` -> `v1.33.0` +**完整变更**: `v1.33.0` -> `v1.34.0` ## Lime v1.32.0 diff --git a/a2ui-hitl-mcp-second-turn-eval.json b/a2ui-hitl-mcp-second-turn-eval.json new file mode 100644 index 000000000..525199b10 --- /dev/null +++ b/a2ui-hitl-mcp-second-turn-eval.json @@ -0,0 +1,17 @@ +{ + "generatedAt": "2026-05-11T01:42:37.680Z", + "url": "http://127.0.0.1:1420/", + "title": "Lime", + "groupCount": 0, + "firstGroupIndex": null, + "secondGroupIndex": null, + "checks": { + "distinctGroups": false, + "firstGroupDoesNotContainSecondPrompt": false, + "secondGroupDoesNotContainFirstPrompt": false, + "secondGroupHasRuntimeFlowOrMeta": false, + "noOldPendingVisible": true + }, + "groups": [], + "bodyPreview": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\nPPT大纲需求沟通\n现在\n新对话\n13分钟前\n新对话\n15分钟前\n新对话\n17分钟前\n新对话\n17分钟前\nAgent QC approval resolved 1778461404847\n39分钟前\nAgent QC approval denied 1778461394810\n39分钟前\n新对话\n42分钟前\n新对话\n43分钟前\n新对话\n49分钟前\n查看更多对话\n归档\n开\n开源使用\n本地可用\n选择工作区\n新对话\n创作\n\n青柠一下,灵感即来\n\n说一句目标,Lime 就接着帮你做。\n\n文案、图片、视频、搜索和网页任务围绕同一目标持续推进,并沉淀上下文、偏好和做法。\n\n帮我做一份 PPT 大纲\ntab\n添加资料\n高级设置\n当前模型\ndeepseek-v4-pro\n引导帮助\n写作\n添加资料\nPPT\n调研报告\n需求分析\n视频\n设计\nExcel\n编程\n更多做法\n回看最近结果 · PPT大纲需求沟通\n向下滑,看看 Lime 可以帮你做什么\n↓\n你可以从这些任务开始\n\n往下看更多任务样例;真正执行仍会回到生成里继续补充。\n\n拆解一条爆款内容\n把一条高表现内容拆成标题钩子、结构节奏、素材手法和可复用套路,方便快速复刻。\n复盘这个账号/项目\n围绕目标、已有结果和下一步动作判断当前该怎么推进,适合内容账号、项目推进和运营回看。\n脚本转口播/字幕稿\n把现有脚本整理成更适合口播和字幕的版本,补齐停顿、节奏和适合出镜表达的语气。\n每日趋势摘要\n先收一版内容趋势、热点方向和值得继续跟进的切口,适合每天快速开工前拉一遍。\n内容主稿生成\n围绕目标受众、表达结构和关键信息,先生成一版可继续迭代的内容首稿。\n长文转多平台发布稿\n把长文、资料或文章整理成多平台可直接发布的版本,不用再自己拆标题和结构。\n复制轮播帖\n拆解参考轮播帖的结构、文风和卖点,再输出一版可继续改写的轮播内容。\n视频配音\n围绕视频素材、语言要求与旁白意图,先在本地工作区整理一版可继续加工的配音稿。\n视频配音成其他语言\n先整理配音脚本和语言要求,输出一版可继续进入配音流程的执行稿。\n文章转 Slide 视频提纲\n把文章拆成镜头化结构,先生成一版适合做 Slide 视频的提纲。\n账号增长跟踪\n围绕参考账号和目标平台先整理一版增长打法,再持续跟踪内容节奏、关键指标和提醒条件。" +} \ No newline at end of file diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md index 325456698..afff92380 100644 --- a/docs/aiprompts/README.md +++ b/docs/aiprompts/README.md @@ -72,7 +72,7 @@ - **改 subagent / automation / execution tracker / scheduler taxonomy**:先读 `task-agent-taxonomy.md` - **讨论 `/goal`、Managed Objective 或跨 turn 目标续跑**:先读 `task-agent-taxonomy.md` 与 `query-loop.md`,再读 `../research/codex-goal/README.md` 与 `../roadmap/managed-objective/README.md` - **讨论 Coding Agent、Skill Forge 或能力生成 draft**:先读 `query-loop.md` 与 `skill-standard.md`,再读 `../research/pi-mono-coding-agent/README.md` 与 `../roadmap/skill-forge/coding-agent-layer.md` -- **改 channels / browser connector / DevBridge / OpenClaw remote runtime**:先读 `remote-runtime.md` +- **改 channels / browser connector / DevBridge remote runtime**:先读 `remote-runtime.md` - **改记忆来源链 / working memory / durable memory / Team Memory / compaction**:先读 `memory-compaction.md` - **改 FileArtifact / artifact sidecar / versions / file checkpoint / evidence 中的文件快照**:先读 `persistence-map.md` - **改 session detail / thread read / requestTelemetry / evidence / history-record**:先读 `state-history-telemetry.md` diff --git a/docs/aiprompts/overview.md b/docs/aiprompts/overview.md index 650c05765..870f99127 100644 --- a/docs/aiprompts/overview.md +++ b/docs/aiprompts/overview.md @@ -83,7 +83,7 @@ lime/ | `src-tauri/src/services/mcp_service.rs` | MCP 服务器与工具管理 | | `src-tauri/src/commands/gateway_channel_cmd.rs` | 多渠道远程入口、Webhook/Tunnel 与渠道运行时 | | `src-tauri/src/commands/browser_connector_cmd.rs` + `src-tauri/src/commands/webview_cmd.rs` | 浏览器连接器、ChromeBridge 与远程浏览器接入 | -| `src-tauri/src/commands/telegram_remote_cmd.rs` + `src-tauri/src/dev_bridge.rs` + `src-tauri/src/services/openclaw_service/*` | 单通道旧入口、开发桥与本地 Gateway 兼容支撑 | +| `src-tauri/src/commands/telegram_remote_cmd.rs` + `src-tauri/src/dev_bridge.rs` | 单通道旧入口与开发桥兼容支撑 | ### 基础设施层 @@ -107,7 +107,7 @@ lime/ | `agent/` | Aster Agent 运行时桥接、会话管理与状态读模型装配 | | `skills/` | Skills 标准集成、动态加载与执行回调 | | `providers/` | 多 Provider 认证与请求发送 | -| `services/` | 心跳、OpenClaw、浏览器窗口、MCP 等业务服务 | +| `services/` | 心跳、浏览器窗口、MCP 等业务服务 | | `converter/` | 协议兼容与转换 | | `server/` | HTTP Server 与 REST 能力 | | `terminal/` | 已移除;不要恢复旧终端/PTTY 模块 | diff --git a/docs/aiprompts/remote-runtime.md b/docs/aiprompts/remote-runtime.md index 182dcf217..a32eefec1 100644 --- a/docs/aiprompts/remote-runtime.md +++ b/docs/aiprompts/remote-runtime.md @@ -5,11 +5,11 @@ 本文件定义 Lime 当前 `Remote / SDK / Server Mode` 的唯一 remote runtime 事实源,主要回答: - 哪些远程入口才算当前产品主链 -- `消息渠道`、`浏览器连接器 / ChromeBridge`、`OpenClaw`、`DevBridge`、`telegram_remote` 分别属于哪一层 +- `消息渠道`、`浏览器连接器 / ChromeBridge`、`DevBridge`、`telegram_remote` 分别属于哪一层 - 哪些路径负责真实 ingress / control plane,哪些只是安装壳、调试桥或兼容入口 - 后续新增 remote 能力应该往哪里收敛,而不是继续长平级旁路 -它是 **远程入口与控制面的 current 文档**,不是 OpenClaw 页面说明,也不是单条渠道命令的局部注释。 +它是 **远程入口与控制面的 current 文档**,不是单条渠道命令的局部注释。 ## 什么时候先读 @@ -18,7 +18,6 @@ - 调整 `gateway_channel_*`、`gateway_tunnel_*`、渠道 probe 或多账号渠道运行时 - 调整浏览器连接器、ChromeBridge、远程调试入口或 browser backend policy - 调整 `DevBridge` HTTP 桥、浏览器 dev shell 的后端接通逻辑 -- 调整 `OpenClaw` Gateway、Dashboard、安装与运行态管理 - 调整单通道 Telegram 远程触发入口,或评估是否应该继续保留它 如果一个需求同时碰到“远程触发 + 浏览器接入”“渠道入口 + 本地 Gateway”“调试桥 + 产品运行时”中的两项以上,默认属于本主链。 @@ -33,9 +32,8 @@ 1. `消息渠道 runtime` 是当前 IM 远程入口主链 2. `浏览器连接器 / ChromeBridge` 是当前浏览器侧远程 transport 主链 -3. `OpenClaw` 只作为本地 Gateway / 安装 / Dashboard 兼容壳,不再定义新的 remote 真相 -4. `DevBridge` 只作为 debug-only 开发桥,不再冒充产品 remote runtime -5. `telegram_remote_cmd` 是旧单通道入口,不再继续扩成长期主链 +3. `DevBridge` 只作为 debug-only 开发桥,不再冒充产品 remote runtime +4. `telegram_remote_cmd` 是旧单通道入口,不再继续扩成长期主链 固定规则只有一句: @@ -83,30 +81,15 @@ - `browser_connector_cmd.rs` 只负责设置与安装入口 - 真正的 session / backend / remote debugging 状态继续由 `webview_cmd.rs`、`browser_runtime_cmd.rs` 暴露 -### 3. `OpenClaw` 本地 Gateway 壳 +### 3. 已删除的本地 Gateway 兼容壳 -- `src/lib/api/openclaw.ts` -- `src-tauri/src/commands/openclaw_cmd.rs` -- `src-tauri/src/services/openclaw_service/*` -- `src/components/openclaw/*` - -当前这里负责: - -1. 本地安装、升级、环境检查 -2. 本地 Gateway 进程拉起、停止、重启 -3. Dashboard URL、健康检查、运行环境选择 -4. 兼容页面与过渡操作壳 +OpenClaw 安装、Gateway、Dashboard 与运行态管理模块已经判定为 `dead` 并从前端入口、Tauri 命令、DevBridge dispatcher、mock 与 core helper 中移除。 固定规则: -- `OpenClaw` 当前不是 remote runtime 的唯一事实源 -- 它管理的是“本地 Gateway 壳与安装体验”,不是远程 session/control plane 真相 -- 后续如果保留,只允许继续做 compat 支撑,不再扩成第二套产品 remote coordinator - -这一步里“`OpenClaw` 属于 compat”是基于现有仓库实现和产品规划推断: - -- 代码上它主要暴露安装、环境、Gateway、Dashboard 与 runtime candidate 管理 -- 产品规划里它已被明确定位为“过渡安装入口”,不是长期一级产品导航 +- 不再恢复 `src/components/openclaw/*`、`src/lib/api/openclaw.ts`、`src-tauri/src/commands/openclaw_cmd.rs` 或 `src-tauri/src/services/openclaw_service/*` +- 不再把本地 Gateway / Dashboard 壳当成 remote runtime 或一级系统入口 +- 如需新增远程入口,只能回到 `消息渠道 runtime` 或 `浏览器连接器 / ChromeBridge` current 主链 ### 4. `DevBridge` @@ -167,30 +150,30 @@ - `src-tauri/src/dev_bridge.rs` - `src-tauri/src/dev_bridge/*` - `src/lib/dev-bridge/*` -- `src/lib/api/openclaw.ts` -- `src-tauri/src/commands/openclaw_cmd.rs` -- `src-tauri/src/services/openclaw_service/*` -- `src/components/openclaw/*` 保留原因: - `DevBridge` 仍是浏览器开发模式的必要桥接层 -- `OpenClaw` 仍承接本地 Gateway、安装、升级与 Dashboard 过渡体验 退出条件: - `DevBridge` 继续只做开发态桥接,不再承担产品 remote 叙事 -- `OpenClaw` 若继续保留,也只能作为本地壳与兼容入口,不再扩成第二套 remote control plane ### `deprecated` - `src-tauri/src/commands/telegram_remote_cmd.rs` - 任何继续把单渠道 bot runtime 直接定义为 remote 总入口的新实现 -- 任何继续把 `OpenClaw` 页面或 `DevBridge` HTTP 桥当成产品 remote runtime 真相的新实现 +- 任何继续把 `DevBridge` HTTP 桥当成产品 remote runtime 真相的新实现 ### `dead` -- 当前 `M3` 不新增远程面的强制删除项;本轮先完成 current 事实源收口,不做额外硬删 +- `src/components/openclaw/*` +- `src/lib/api/openclaw.ts` +- `src/lib/api/openclawDashboardWindow.ts` +- `src-tauri/src/commands/openclaw_cmd.rs` +- `src-tauri/src/services/openclaw_service/*` +- `src-tauri/src/dev_bridge/dispatcher/openclaw.rs` +- `src-tauri/crates/core/src/openclaw_install.rs` ## 最低验证要求 @@ -200,7 +183,7 @@ - 改渠道命令 / tunnel / webhook:`npm run test:contracts` 与相关渠道定向测试 - 改浏览器连接器 / ChromeBridge:浏览器相关定向测试或 `verify:gui-smoke` - 改 `DevBridge`:至少补桥接定向测试 -- 改 `OpenClaw`:至少补相关页面或命令定向测试 +- 触碰已删除 OpenClaw surface:至少运行 `npm run governance:legacy-report` 与 `npm run test:contracts`,确认没有命令或入口回流 ## 这一步如何服务主线 @@ -211,7 +194,7 @@ - 解释 IM 远程入口时,回到 `gateway_channel_*` - 解释远程浏览器 transport 时,回到 `browser connector / ChromeBridge` - 解释开发态浏览器接通时,回到 `DevBridge` compat -- 解释本地 Gateway / Dashboard 壳时,回到 `OpenClaw` compat +- 解释已删除本地 Gateway / Dashboard 壳时,回到 OpenClaw `dead` 分类与治理目录册 - 解释旧 Telegram 单通道触发时,视为 `deprecated` 这样后续 `M4 Memory / Compaction` 和 `M5 State / History / Telemetry` 就不必继续被 remote 入口语言打断。 diff --git a/docs/develop/lime-borrow-codex-engineering-practices.md b/docs/develop/lime-borrow-codex-engineering-practices.md index dd817ba5b..af0a42a9f 100644 --- a/docs/develop/lime-borrow-codex-engineering-practices.md +++ b/docs/develop/lime-borrow-codex-engineering-practices.md @@ -140,7 +140,7 @@ Codex 值得借鉴的,不是 Bazel、不是纯 Rust、也不是它的体量; 当前 GitHub Actions 里: -- `ci.yml` 主要覆盖 `src-tauri/**` 改动下的 **Windows OpenClaw regression** +- `quality.yml` 主要覆盖前端、契约与一致性质量信号 - `deploy-docs.yml` 只负责文档站点 - `release.yml` 只负责发布 @@ -189,7 +189,7 @@ Lime 现在更像是“积木已经有了,但还没拼成系统”。 - Tauri command 参数结构 - 前后端共享数据结构 - bridge 交互协议 -- OpenClaw / Gateway / Workspace 相关状态快照 +- Gateway / Workspace 相关状态快照 - 插件 / MCP 相关 manifest 或接口字段 Codex 的经验告诉我们: @@ -274,8 +274,6 @@ Codex 很重要的一个做法,是把很多检查统一收口到一个最终 - `src/lib/dev-bridge/**` - `src/lib/tauri-mock/**` -- `src/components/openclaw/**` -- `src-tauri/src/services/openclaw_service/**` - `src-tauri/src/app/**` - Workspace / browser-runtime / MCP / plugin 高风险路径 @@ -390,7 +388,6 @@ Lime 现在涉及: - bridge - 浏览器 runtime - Provider / Gateway -- OpenClaw 安装与运行态 - 文档站点 这些链路任何一处变化,都可能需要在正式 tag 前先经过一次“真实用户环境暴露”。 @@ -498,9 +495,8 @@ Codex 借鉴给我们的,不是“让 AI 代替工程”,而是: 优先建议覆盖: 1. `safeInvoke` / Tauri command 契约 -2. OpenClaw 安装与运行态相关路径 -3. bridge / headless / smoke 相关路径 -4. Workspace 准备态相关 smoke +2. bridge / headless / smoke 相关路径 +3. Workspace 准备态相关 smoke 可复用现有脚本: diff --git a/docs/iteration-notes/README.md b/docs/iteration-notes/README.md index f318328ae..465f12efd 100644 --- a/docs/iteration-notes/README.md +++ b/docs/iteration-notes/README.md @@ -18,4 +18,3 @@ ## 当前条目 -- `openclaw-windows-detection-followup.md`:OpenClaw 在 Windows 下“已安装但未检测到”的后续迭代记录 diff --git a/docs/iteration-notes/openclaw-windows-detection-followup.md b/docs/iteration-notes/openclaw-windows-detection-followup.md deleted file mode 100644 index abc0d8aed..000000000 --- a/docs/iteration-notes/openclaw-windows-detection-followup.md +++ /dev/null @@ -1,59 +0,0 @@ -# OpenClaw Windows 检测问题后续记录 - -## 状态 - -- 结论:纳入下个迭代 -- 范围:`OpenClaw` Windows 安装检测与诊断可观测性 -- 优先级:中高 - -## 背景 - -用户反馈在 Windows 环境下已经安装了 `OpenClaw`,但应用内仍显示“未检测到 OpenClaw”。 - -这类问题通常不是“未安装”,而是“当前进程没有正确解析到 `openclaw` 命令”,典型场景包括: - -- 安装完成后应用进程未刷新到最新 `PATH` -- `openclaw` 安装在 `npm` 全局目录,但该目录未进入当前进程可见路径 -- `npm config get prefix`、`where openclaw` 与应用内补充搜索目录之间存在不一致 - -## 现象 - -- 页面提示:`未检测到 OpenClaw` -- 用户实际情况:系统中已完成 `OpenClaw` 安装 -- 用户感知:会误以为需要重复安装,或者认为安装功能失效 - -## 影响 - -- 容易触发重复安装操作 -- 会降低 Windows 用户对安装流程稳定性的信任 -- 故障定位成本高,用户需要手工提供 `where openclaw`、`npm config get prefix` 等信息 - -## 本次已确认的处理方向 - -建议下个版本正式带上以下能力: - -1. Windows 检测前主动刷新当前进程 `PATH` -2. 将 `npm` 全局前缀目录纳入 `openclaw` 命令补充搜索范围 -3. 当检测到 npm 包已存在但命令未生效时,显示“待刷新”而不是“未检测到” -4. 在安装页直接展示诊断信息,包括: - - `npm` 命令路径 - - `npm global prefix` - - `OpenClaw` 包路径 - - `where openclaw` 命中结果 - - 补充搜索目录 - - 补充目录中的 `openclaw` 命中结果 - -## 建议验收标准 - -- Windows 已安装 `OpenClaw` 但当前进程未命中命令时: - - 不再提示继续重复安装 - - 页面显示“待刷新”或等价状态 - - 页面给出明确的重新检测/重启应用引导 -- 诊断面板可直接暴露关键路径信息,便于用户截图反馈 -- `cargo test windows_` 相关回归通过 -- OpenClaw 前端页面测试通过 - -## 建议补充 - -- 后续可将这组诊断信息并入“故障诊断导出 JSON” -- 若后续还有类似问题,可统一沉淀为“命令可见性诊断”能力,而不是仅服务于 `OpenClaw` diff --git a/docs/roadmap/i18n/prd.md b/docs/roadmap/i18n/prd.md index 6e1c7bc68..dce797c69 100644 --- a/docs/roadmap/i18n/prd.md +++ b/docs/roadmap/i18n/prd.md @@ -758,6 +758,40 @@ Patch Layer 不能无限期作为事实源。建议退出条件: 77. Skills 工作台 CuratedTask presentation helper 已支持调用方注入本地化 copy,并在 `SkillsWorkspacePage` 内接入 `skills.workspace.curatedTask.*` key,覆盖结果模板推荐卡的 required / output / destination / follow-up 摘要、最近启动参数摘要、参考对象摘要和 recent prefill hint;存量 Agent Chat 输入能力区、Memory suggestion panel 与启动弹窗不传 copy 时仍保持旧中文兜底,避免本轮扩大到 CuratedTask 模板定义与 Agent Chat 深迁移。 78. 设置页 Agent / Providers 详情配置面板空态与 Lime Hub 登录提示已迁移到 `settings.providers.setting.*` namespace key,覆盖未选中模型时的标题说明、未登录 Provider badge、登录说明和登录按钮;本条只收口 `ProviderSetting` 的外壳入口状态,API Key 表单、模型优先级、接口获取模型、删除确认和连接测试反馈仍按后续独立小刀迁移。 79. CuratedTask 启动弹窗内部 chrome 已迁移到 `agent` namespace 的 `curatedTask.launcher.*` key,覆盖 readiness pill、启动说明、产出预告、契约摘要前缀、最近判断横幅、结果基线卡 label、输入区标题 / 必填 badge、参考对象区标题说明 / 加载 / 空态 / 错误 / 已选摘要 / 标签摘要、输出区标题与底部操作;模板标题、summary、字段 label / placeholder / helperText、输出契约、结果去向和 review suggestion text 仍作为 CuratedTask 模板 / runtime 数据保留,不在本条误改为 UI copy。 +80. 设置页 Agent / Providers 详情配置主体静态表单文案已迁移到 `settings.providers.setting.body.*` namespace key,覆盖显式缓存 badge、获取 API Key / 删除配置按钮、API Key label / placeholder / aria、模型优先级标题说明、接口获取模型入口、接口模型提示、筛选 placeholder、主模型 badge / 设置 / 移除 aria、空模型提示、手动添加模型和连接测试按钮 / 不可测试原因;本条只收口 `ProviderSetting` 主体静态表单,删除确认、接口获取结果、连接测试结果和错误反馈等动态文案仍按后续独立小刀迁移。 +81. Skills 工作台能力草案面板内部 chrome 与 presentation helper 已迁移到 `agent` namespace 的 `capabilityDraft.panel.*` key,覆盖面板标题说明、刷新、项目缺失 / 加载 / 空态 / 错误、状态 label / description、目标 / 权限 / 文件 / 验证 / 注册字段前缀、权限 / 文件 / 验证 / 注册摘要、guard 提示、验证证据标题与 evidence key label、验证 / 注册动作和注册成功反馈;`CapabilityDraftPanel` 以 copy 注入复用 domain presentation,存量非面板调用方仍保留默认中文兜底。 +82. 设置页 Agent / Providers 详情配置删除确认与连接测试反馈已迁移到 `settings.providers.setting.feedback.*` namespace key,覆盖缺少 API Key 添加能力错误、删除配置确认 / 成功 / 失败兜底、连接测试前置校验、成功耗时提示、成功兜底与失败兜底;本条只收口 `ProviderSetting` 内删除和测试连接反馈,不触碰 `ModelAddPanel` 添加流程,也不把接口获取模型 helper 的 runtime / protocol message 误改为 UI copy。 +83. 设置页 Agent / Providers 详情配置接口获取模型动态反馈已迁移到 `settings.providers.setting.feedback.modelFetch.*` namespace key,覆盖协议不支持说明、缺 API Key、接口失败 / 空实时列表 / 空模型、Fal / Responses 特例、确认已有全部模型、接口返回模型数量和异常兜底;`providerModelFetchHelpers` 支持可选 copy 注入,`ProviderSetting` 传入本地化 copy,存量 `ModelAddPanel` 调用仍保持默认兜底,避免本轮扩大到添加流程。 +84. Skills 工作台 Workspace 已注册能力面板已迁移到 `agent` namespace 的 `capabilityDraft.registeredPanel.*` key,覆盖面板标题 / 刷新 / 空态 / 错误、注册卡片 badge / 字段 / provenance、只读 HTTP approval artifact、session credential / input / submission contract 摘要、runtime enable 操作、Agent envelope draft presentation 与 Managed Job / Completion Audit 展示;`agentEnvelopeDraftPresentation` 与 `workspaceSkillAgentAutomationDraft` 均改为支持调用方注入本地化 copy,存量非面板调用方仍保留默认中文兜底。 +85. 设置页 Agent / Providers 添加流程静态 chrome 与模型配置控件已迁移到 `settings.providers.modelAdd.*` namespace key,覆盖服务商目录分类、目录错误兜底、自定义供应商入口、返回 / 获取 API Key / 完成添加 / 保存并测试、配置标题、自定义供应商名称与 API 格式字段、API Key label / placeholder / aria、模型优先级标题说明、接口模型筛选 / 添加全部 / 空态、主模型 / 设为主模型 / 移除、手动模型输入和添加按钮;本条只收口 `ModelAddPanel` 静态表单 chrome,不覆盖校验错误、提交反馈、接口获取模型动态状态或模板目录数据。 +86. 设置页 Agent / Providers 添加流程校验错误、提交失败兜底与接口获取模型动态反馈已迁移到 `settings.providers.modelAdd.feedback.*` namespace key,覆盖 `ModelAddPanel` 的表单校验、API Base URL 自动修正提示、缺密钥 / 缺 URL、协议不支持、Responses / Fal 特例、接口失败 / 空结果 / 返回数量 / 已包含全部模型和测试连接激活失败兜底;`providerModelFetchHelpers` 的 copy 注入已在添加流程侧启用,存量非本地化调用方仍保留默认兜底。 +87. 设置页 Agent / Providers 添加流程目录辅助文案已迁移到 `settings.providers.modelAdd.catalog.*` namespace key,覆盖系统目录 provider 的 API Base URL 空值占位、模型 registry 补充目录描述、推荐 / 国内 / 海外 / 按量 API / Coding Plan / 订阅套餐 badge;本条只收口目录 chrome 的派生辅助文案,不覆盖 `FEATURED_TEMPLATES` 的模板名称 / 描述或 provider/resource runtime 返回数据。 +88. 设置页 Agent / Providers 添加流程内置模板名称 / 描述与目录加载错误兜底已迁移到 `settings.providers.modelAdd.catalog.*` namespace key,覆盖 12 个 `FEATURED_TEMPLATES` 的展示名称和描述、选择模板后的默认供应商名称、本地化图标 fallback、配置页标题插值,以及系统目录加载失败默认文案;本条不覆盖系统目录 provider/resource runtime 返回的名称、模型或说明。 +89. 设置页 Agent / Providers 添加流程 provider type label 已迁移到 `settings.providers.modelAdd.catalog.providerType.*` namespace key,覆盖系统目录 description 中的协议类型前缀和配置页 API 格式只读展示;本条只在 `ModelAddPanel` 内注入本地化展示 copy,不改 shared `providerConfigUtils` 的默认中文兜底,避免扩大到其他调用面。 +90. 设置页 Agent / Providers 接口获取模型 helper 默认中文兜底已完成 current UI 调用面审计:`buildResponsesModelFetchStatus` / `buildFalModelFetchStatus` 当前只有 `ModelAddPanel` 与 `ProviderSetting` 两个 UI 调用点,且两侧均传入 `modelFetchStatusCopy`;`providerModelFetchHelpers.ts` 内保留的默认中文只作为非本地化调用方兜底,不再列为 Providers current 可见文案缺口。 +91. Skills 工作台 Managed Job 初始值与 automation prompt 已迁移到 `agent` namespace 的 `skills.workspace.managedJob.initialValues.*` key,覆盖草案名称、description 来源 / Skill / provenance / 默认暂停说明、Managed Objective、success criteria,以及自动化 prompt 正文;`workspaceSkillAgentAutomationDraft` 通过 copy 注入保持存量非 UI 调用方中文兜底,`SkillsWorkspacePage` 负责把 current UI 文案接入 i18n resources。 +92. 设置页 Agent / Providers 添加流程 provider/resource runtime 数据已完成边界审计:系统目录 `SystemProviderCatalogItem.name/api_host`、模型 registry 的 `EnhancedModelMetadata.provider_name/id` 与 API Host / 模型 ID 均属于 catalog/model registry 事实数据,不再作为静态 UI key-based i18n 缺口;若未来需要多语种 provider catalog,应进入独立 catalog 数据本地化设计,而不是塞入 `settings.json`。 +93. 侧边栏邀请入口与邀请弹窗已迁移到 `navigation.sidebar.invite.*` namespace key,覆盖头部邀请按钮 title / aria / tooltip、弹窗关闭 aria、品牌 eyebrow、标题、默认 headline / rules、未登录连接云端说明、加载态、错误重试、邀请码 / 下载地址 / 邀请链接 / 奖励字段、复制操作和复制成功 / 空内容 / 剪贴板失败 toast;奖励金额改为按当前 i18n locale 格式化数字,云端返回的 share headline / rules / shareText 仍作为运行时事实数据保留。 +94. 侧边栏外观快捷面板已迁移到 `navigation.sidebar.appearance.*` namespace key,覆盖外观入口 title / aria / tooltip、弹窗标题与摘要、主题分组、主题模式 label / description / 切换 aria、配色分组、随机配色按钮、11 个配色预设 label / description 与切换 aria;本条只收口 `AppSidebar` 快捷面板展示,不改 `themeMode.ts` / `colorSchemes.ts` 作为外观配置事实数据,也不扩大到设置页 Appearance 主体。 +95. 侧边栏账号套餐 usage 已完成边界审计:`AppSidebar` 中账号卡片的静态操作文案 `freePlan`、`viewPlanDetails`、`viewDetails` 已在 `navigation.sidebar.account.*`;已登录卡片展示的 `currentPlan` / `creditsSummary` 来自 `OemCloudProviderOfferSummary` 云端运行时字段,测试里的 `0 / 20 积分 已用0%` 只是 mock runtime 值;`parseAccountUsagePercent()` 仅用于进度条解析,不产生用户可见文案,因此不再作为侧栏静态 i18n 缺口。 +96. CuratedTask 模板定义与 Agent Chat 输入能力区结果模板主调用面已迁移到 `agent` namespace 的 `curatedTask.templates.*` key,覆盖 6 个模板的 title / summary / output hint / result destination / category label、必填字段 label / placeholder / helperText、optional references、output contract、follow-up action label / promptHint,以及 recent badge / action / status 和推荐 reason label / summary;`listCuratedTaskTemplates` / `findCuratedTaskTemplateById` / `listFeaturedHomeCuratedTaskTemplates` 支持 `CuratedTaskTemplateCopy` 注入,`CharacterMentionPanel`、`EmptyState`、`CuratedTaskLauncherDialog` 与 `SkillsWorkspacePage` 均接入 key-based copy。模板执行 prompt 正文仍按 runtime 语义保留,不在本条误改为静态 UI 文案;Agent Chat 输入能力区 section heading / banner / helper chrome 已在条目 100 收口。 +97. Workspace 图片素材 `ImageGallery` 已迁移到 `workspace` namespace 的 `workspace.imageGallery.*` key,覆盖加载态、搜索 placeholder / 清空搜索 aria、网格 / 列表视图 aria、图片数量与已选数量摘要、图片分类筛选、空态标题说明和底部双击插入提示;图片计数改为复用 `src/i18n/format.ts` 的 `formatNumber()`,按当前 i18n locale 格式化。素材名称、标签、尺寸、预览路径和 `src/types/gallery-material.ts` 内的默认中文分类名称仍作为 runtime / 兼容兜底数据保留,不在本条扩大到素材库全类型迁移。 +98. Browser Runtime 工作台外壳已迁移到 `workspace` namespace 的 `workspace.browserRuntime.*` key,覆盖独立模式标题 / 说明、刷新会话按钮、刷新失败消息、运行 Profile / 附着 Chrome 统计、附着 Chrome 说明,以及工作台级提示条关闭 aria / title;Profile 名称、Chrome 会话详情、子面板事件消息和站点脚本参数仍保持 runtime / 子组件事实源,不在本条扩大到 Browser Runtime 全面板迁移。 +99. Browser Runtime 环境预设子面板已迁移到 `workspace` namespace 的 `workspace.browserEnvironment.*` key,覆盖面板标题说明、资料启动环境选择器、刷新 / 新建 / 显示归档操作、活跃预设计数、表单字段 label / placeholder、保存 / 创建 / 更新 / 取消、空态、当前启动环境 / 已归档 badge、预设 meta 前缀与未设置 / 从未兜底、归档 / 恢复 / 编辑操作,以及读取 / 保存 / 归档 / 恢复 / 校验反馈;预设名称、说明、代理、时区、locale、UA、视口等配置值仍作为用户或 runtime 数据保留,不在本条做数据内容翻译。 +100. Agent Chat 输入能力区 section / banner / helper chrome 已迁移到 `agent` namespace 的 `inputCapabilities.*` key,覆盖 slash / mention 的结果模板、最近调用、暂未接入、工作台操作、提示 / 状态分组、可用 / 已安装 Skills、协作角色 heading,统一调用注册表 banner,最近判断推荐 banner,最近输入描述,结果基线摘要,搜索 placeholder、空态、去技能中心操作与面板 helper 文案;`buildInputCapabilitySectionsCopy` 负责把 UI copy 注入 `buildInputCapabilitySections`,`CharacterMentionPanel` 负责从 `useTranslation("agent")` 接入。`SERVICE_SKILL_GROUP_META` 的站点 / 品牌名、运行时 skill/catalog 名称和模板执行 prompt 正文仍作为数据或运行时语义保留,不在本条误改为静态 UI copy。 +101. Browser Runtime 资料管理子面板已迁移到 `workspace` namespace 的 `workspace.browserProfile.*` key,覆盖已保存资料标题说明、启动环境选择器、刷新 / 新建 / 显示归档操作、活跃资料与当前 Chrome 附着统计、附着模式可用性提示、表单字段 label / placeholder、运行模式 label / description / 扩展提示、保存 / 创建 / 更新 / 取消、空态、资料卡片的运行模式 / 桥接 / 归档 badge、站点 / 默认地址 / 最近使用 / 当前页面 meta、标签页面板 chrome 与切换操作,以及读取 / 保存 / 归档 / 恢复 / 启动反馈;资料名称、profile key、站点范围、启动 URL、说明、页面标题、标签页标题 / URL 与运行时桥接错误匹配仍作为用户或 runtime 数据保留,不在本条误改为静态 UI copy。 +102. Skills 工作台 Workspace Skill runtime enable 首发提示正文已迁移到 `agent` namespace 的 `skills.workspace.runtimeEnable.prompt.*` key,覆盖自动发送给 Agent 的 initial user prompt 三段正文:本回合启用哪个本地 Skill、先读取 Skill 说明与约束、信息不足时只追问最少必要问题并禁止创建自动化 / 定时任务 / marketplace 发布;`buildWorkspaceRuntimeEnablePrompt()` 支持 copy 注入,`SkillsWorkspacePage` 从 `useTranslation("agent")` 接入。绑定目录、Skill 名称与 `project:` 仍作为运行时事实数据插值,不在资源中硬编码。 +103. Skills 执行对话框 chrome 已迁移到 `agent` namespace 的 `skills.executionDialog.*` key,覆盖执行模式 badge、workflow 标识、参数提示 label、缺省说明、输入 label / placeholder、Provider 选择 label / helper / auto placeholder、加载失败、进度、执行中、取消 / 关闭 / 执行 / 重试按钮,以及成功 / 失败 / 输出区提示;`SkillExecutionDialog`、`SkillDetailHeader`、`ExecutionConfig` 与 `ExecutionResultDisplay` 均从 `useTranslation("agent")` 获取 copy。Skill 名称、description、`when_to_use`、`argument_hint`、workflow step 与 provider id 仍作为 runtime / Skill 包数据保留,不在资源中硬编码。 +104. Browser Runtime Debug 面板外壳 / 状态 / 最小人工控制已开始迁移到 `workspace` namespace 的 `workspace.browserRuntimeDebug.*` key,覆盖独立与嵌入外壳标题说明、独立窗口 / 高级调试 / 连接 / 接管 / 恢复画面 / 刷新状态等主操作、会话状态 label 与默认 description、实时画面 loading / waiting / no-session 占位、当前页面 label / URL 兜底 / 最近错误前缀、独立窗口与系统浏览器打开反馈、最小人工控制标题说明 / 启用状态 / 输入 placeholder / 发送与滚动操作;高级审计面板、附着 Chrome fallback panel、CDP 标签页选择、Console / Network 事件列表和 `BrowserSiteAdapterPanel` 仍按后续独立小刀迁移。 +105. Skill 脚手架启动弹窗 chrome 已迁移到 `agent` namespace 的 `skills.scaffoldDialog.*` key,覆盖弹窗标题 / 说明、来源结果提示、创建位置、项目级 / 用户级选项与说明、目录 / 名称 / 描述字段 label 与 placeholder、目录名帮助说明、必填校验错误,以及取消 / 带回创作输入 / 创建 / 创建中按钮;目录名、Skill 名称、描述、来源摘要仍作为用户或 runtime 数据保留,不在资源中硬编码。 +106. Browser Runtime existing session hook 消息边界已迁移到 `workspace` namespace 的 `workspace.browserExistingSession.feedback.*` key,`useExistingSessionAttachPanel` 与 `useExistingSessionProfileManager` 只接收调用方注入的 typed copy,不再在 hook 内硬编码读取附着状态、读取当前页面、读取标签页、切换标签页和附着当前 Chrome 的用户可见反馈;`BrowserRuntimeDebugPanel` 与 `BrowserProfileManager` 分别从 `useTranslation("workspace")` 注入 copy。Profile 名称、启动 URL、环境限制说明、标签页标题和底层错误消息仍作为 runtime / 用户数据插值,不在资源中硬编码。 +107. SceneApp 最近判断建议横幅的 runtime suggestion 已迁移到 `agent` namespace 的 `reviewFeedback.*` key,`buildReviewFeedbackProjection()` 支持 `ReviewFeedbackProjectionCopy` 注入,覆盖当前任务继续建议、推荐任务切换建议、任务标题连接符、横幅 badge、最近判断标题、续接按钮与 SceneApp 结果基线 helper;`SceneAppReviewFeedbackBanner` 与 `SceneAppExecutionSummaryCard` 均从 `useTranslation("agent")` 构建 copy 并渲染本地化 chrome。信号标题、summary、推荐任务标题仍作为 runtime / 模板数据保留,不在资源中硬编码;General Workbench 调用面已在条目 109 收口。 +108. Browser Site Adapter 面板 chrome 与执行反馈已迁移到 `workspace` namespace 的 `workspace.browserSiteAdapter.*` key,覆盖工作台 / Debug 标题说明、适配器与资料计数、刷新 / 加载 / 空态、推荐适配器区、适配器详情、关键词与 Profile Key 表单、目录状态、执行资料摘要、attached session 提示、参数 JSON、执行按钮、结果状态 / 错误码 / 建议 / 结构化数量摘要、保存到资源项目 / 写回当前主稿面板、保存 / 打开操作和 run / save feedback;数量与同步时间改为复用 `src/i18n/format.ts` 的 `formatNumber()` / `formatDate()`。适配器名称、domain、description、capabilities、auth hint、recommendation reason、profile / project / content 标题、source URL、后端错误和 result data 仍作为 runtime 数据保留,不在资源中硬编码。 +109. General Workbench 最近判断建议横幅的 runtime suggestion 已迁移到 `agent` namespace 的 `reviewFeedback.*` key,`GeneralWorkbenchWorkflowPanel` 从 `useTranslation("agent")` 构建 `ReviewFeedbackProjectionCopy` 并传入 `buildReviewFeedbackProjection()`,覆盖当前进展与运行详情里的最近判断 badge、标题、suggestionText、续接按钮和通用工作台 helper;新增 `reviewFeedback.helper.generalWorkbench` 并补齐 5 个 locale。信号标题、summary、推荐任务标题与当前任务标题仍作为 runtime / 模板数据插值,不在资源中硬编码。 +110. Browser Runtime Debug 面板审计与附着 Chrome 子面板已迁移到 `workspace` namespace 的 `workspace.browserRuntimeDebug.audit.*`、`attachTabs.*`、`attachFallback.*`、`attachPage.*` 与共享 `value.*` key,覆盖最近启动 / 动作审计标题、启动成功 / 失败、目标 / 资料缺失兜底、复用 / 新建会话、尝试次数、附着 Chrome fallback 状态、当前窗口标签页读取 / 切换、当前页面摘要读取、未同步 / 未记录标题 / URL / 断开状态和刷新操作;审计中的 URL、session/profile id、动作名、端口、页面标题、Markdown 摘要与底层错误仍作为 runtime 数据或用户数据插值,不在资源中硬编码。CDP 托管标签页选择、会话信息 label、Console / Network 事件列表和截图 / 实时帧长尾仍按后续独立小刀迁移。 +111. Browser Runtime Debug 面板 CDP 托管会话高级区已迁移到 `workspace` namespace 的 `workspace.browserRuntimeDebug.cdp.*`、`sessionInfo.*` 与 `events.*` key,覆盖 Profile 会话 / CDP 标签页选择、未发现标签页、刷新标签页、打开中 / 重新附着 / 在 Chrome 中继续、会话信息标题、Session / Target / 状态 / 控制模式 / WS / 最后帧 label、Console / Network 标题、事件数量和空态;事件数量改为复用 `src/i18n/format.ts` 的 `formatNumber()`,按当前 i18n locale 格式化。profile key、PID、target 标题 / URL / id、session id、target id、控制模式枚举、WebSocket URL、事件类型 / level / URL / status 仍作为 runtime 数据保留,不在资源中硬编码。 + +112. Browser Runtime existing session 附着 Chrome presentation 已迁移到 `workspace` namespace 的 `workspace.browserExistingSession.presentation.*` key,覆盖附着模式检查 / 等待 / 读取 / 已连接状态、fallback placeholder、读取 / 刷新 / 标签页动作、嵌入控制提示与实时视图提示;`buildExistingSessionAttachPresentation()` 只接收调用方注入的 `ExistingSessionAttachPresentationCopy`,不再在 presentation helper 内硬编码用户可见中文,`BrowserRuntimeDebugPanel` 从 `useTranslation("workspace")` 构造 copy。Profile key、页面标题 / URL、标签页标题和底层错误仍作为 runtime / 用户数据保留,不在资源中硬编码。 本轮验证记录: @@ -853,10 +887,136 @@ Patch Layer 不能无限期作为事实源。建议退出条件: 88. 2026-05-11 Skills CuratedTask presentation helper 本地化 copy 迁移续测:`src/components/agent/chat/utils/curatedTaskTemplates.ts` 新增 `CuratedTaskPresentationCopy` 注入,保持默认中文兜底;`src/components/skills/SkillsWorkspacePage.tsx` 在 Skills 工作台传入 `skills.workspace.curatedTask.*` copy,覆盖结果模板推荐卡摘要、最近启动参数 / 参考对象摘要与 recent prefill hint,并同步 5 个 locale。`npm test -- "src/components/agent/chat/utils/curatedTaskTemplates.test.ts" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、41 个用例;`npx eslint "src/components/agent/chat/utils/curatedTaskTemplates.ts" "src/components/agent/chat/utils/curatedTaskTemplates.test.ts" "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析、定向 `git diff --check` 与 `npm run typecheck` 通过,最新资源口径为 5 个 locale、6 个 namespace、2731 个 source key。`npm run verify:local` / GUI smoke 未作为本轮通过证据;GUI smoke 仍按记录 39 / 75 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为 CuratedTask helper 本地化阻塞项。 89. 2026-05-11 Settings Providers 详情配置外壳入口态迁移续测:`src/components/api-key-provider/ProviderSetting.tsx` 接入 `useTranslation("settings")` 并收口 `settings.providers.setting.*` 的空态与登录提示 key,覆盖未选中模型时的标题说明、Lime Hub 未登录 badge、登录说明和登录按钮;`ProviderSetting.ui.test.tsx` 补稳定 `react-i18next` mock,沿用空态与登录提示断言。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2736 个 source key 结构一致;`npm test -- "src/components/api-key-provider/ProviderSetting.ui.test.tsx" "src/components/api-key-provider/ProviderSetting.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、34 个用例;`npx eslint "src/components/api-key-provider/ProviderSetting.tsx" "src/components/api-key-provider/ProviderSetting.ui.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过。`npm run verify:local` / GUI smoke 未作为本轮通过证据;本轮避开 Skills / Chrome Relay / Automation 实现文件,仅触碰 Providers 详情配置外壳入口态与 settings 资源。 90. 2026-05-11 CuratedTask 启动弹窗内部 chrome 迁移续测:`src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx` 接入 `useTranslation("agent")` 并收口 `curatedTask.launcher.*`,覆盖 readiness、说明、契约摘要、最近判断、结果基线、输入区、参考对象区、输出区与底部操作;`CuratedTaskLauncherDialog.test.tsx` 与 `EmptyState.test.tsx` 补稳定 `react-i18next` mock,避免测试受全局默认语言漂移影响。`npm test -- "src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx" "src/components/agent/chat/components/EmptyState.test.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、102 个用例;`npx eslint "src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx" "src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx" "src/components/agent/chat/components/EmptyState.test.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析、定向 `git diff --check` 与 `npm run typecheck` 通过,最新资源口径为 5 个 locale、6 个 namespace、2775 个 source key。`npm run verify:local` / GUI smoke 未作为本轮通过证据;GUI smoke 仍按记录 39 / 75 受 Agent streaming runtime blocker 影响,本轮未把该无关失败作为启动弹窗 chrome 本地化阻塞项。 +91. 2026-05-11 Settings Providers 详情配置主体静态表单迁移续测:`src/components/api-key-provider/ProviderSetting.tsx` 继续收口 `settings.providers.setting.body.*`,覆盖显式缓存、获取 API Key、删除配置、API Key 输入、模型优先级、接口模型筛选、主模型操作、模型空态 / 添加和连接测试不可用原因,并同步 5 个 `settings.json` 资源。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、2802 个 source key 结构一致;`npm test -- "src/components/api-key-provider/ProviderSetting.ui.test.tsx" "src/components/api-key-provider/ProviderSetting.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、34 个用例;`npx eslint "src/components/api-key-provider/ProviderSetting.tsx" "src/components/api-key-provider/ProviderSetting.ui.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过。`npm run verify:local` / GUI smoke 未作为本轮通过证据;本条只覆盖 Providers 详情配置主体静态表单,动态反馈 / 确认文案仍待后续迁移。 +92. 2026-05-11 Skills 能力草案面板内部文案迁移续测:`src/features/capability-drafts/components/CapabilityDraftPanel.tsx` 接入 `useTranslation("agent")` 并收口 `capabilityDraft.panel.*`,覆盖面板 chrome、状态摘要、字段前缀、guard、evidence label、操作与注册反馈;`src/features/capability-drafts/domain/capabilityDraftPresentation.ts` 新增 `CapabilityDraftPresentationCopy`,让面板通过 copy 注入本地化 presentation,同时保持默认中文兜底。`npm test -- "src/features/capability-drafts/domain/capabilityDraftPresentation.test.ts" "src/features/capability-drafts/components/CapabilityDraftPanel.test.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、44 个用例;`npx eslint "src/features/capability-drafts/domain/capabilityDraftPresentation.ts" "src/features/capability-drafts/domain/capabilityDraftPresentation.test.ts" "src/features/capability-drafts/components/CapabilityDraftPanel.tsx" "src/features/capability-drafts/components/CapabilityDraftPanel.test.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、2857 个 source key。`npm run typecheck` 已尝试但被隔壁并行脏改阻塞:`src/components/memory/MemoryPage.test.tsx` 的 `react-i18next` mock 中 `defaultValue` / interpolation 类型推断不匹配;该失败不来自本切片。`npm run verify:local` / GUI smoke 未作为本轮通过证据。 +93. 2026-05-11 Settings Providers 详情配置删除确认与连接测试反馈迁移续测:`src/components/api-key-provider/ProviderSetting.tsx` 继续收口 `settings.providers.setting.feedback.*`,覆盖 API Key 添加能力缺失错误、删除确认 / 成功 / 失败兜底、连接测试缺模型 / 缺密钥 / 成功耗时 / 成功兜底 / 失败兜底,并同步 5 个 `settings.json` 资源。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、2868 个 source key 结构一致;`npm test -- "src/components/api-key-provider/ProviderSetting.ui.test.tsx" "src/components/api-key-provider/ProviderSetting.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、34 个用例;`npx eslint "src/components/api-key-provider/ProviderSetting.tsx" "src/components/api-key-provider/ProviderSetting.ui.test.tsx" --max-warnings 0` 与定向 `git diff --check` 通过。`npm run typecheck` 本轮已执行但未通过,失败点在非本刀文件 `src/features/capability-drafts/components/WorkspaceRegisteredSkillsPanel.tsx` 的 i18n key 类型参数不匹配;此前并行 typecheck 日志也曾在 `src/components/memory/MemoryPage.test.tsx` 的 mock typing 处失败。本条不触碰 CapabilityDraft、Memory 或 `agent.json` 并行改动。 +94. 2026-05-11 Settings Providers 详情配置接口获取模型动态反馈迁移续测:`src/components/api-key-provider/ProviderSetting.tsx` 继续收口 `settings.providers.setting.feedback.modelFetch.*`,覆盖自动获取模型的协议不支持、缺密钥、Responses / Fal 特例、接口失败、空结果、已包含全部结果、接口返回数量和异常兜底;`src/components/api-key-provider/providerModelFetchHelpers.ts` 新增可选 copy 注入,保持 `ModelAddPanel` 默认调用兼容。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3029 个 source key 结构一致;`npm test -- "src/components/api-key-provider/ProviderSetting.ui.test.tsx" "src/components/api-key-provider/ProviderSetting.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、34 个用例;`npx eslint "src/components/api-key-provider/ProviderSetting.tsx" "src/components/api-key-provider/providerModelFetchHelpers.ts" "src/components/api-key-provider/ProviderSetting.ui.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本轮通过证据;本条只覆盖 `ProviderSetting` 详情配置接口获取模型反馈,不扩大到 `ModelAddPanel` 添加流程。 +95. 2026-05-11 Skills Workspace 已注册能力面板迁移续测:`src/features/capability-drafts/components/WorkspaceRegisteredSkillsPanel.tsx` 接入 `useTranslation("agent")` 并收口 `capabilityDraft.registeredPanel.*`,覆盖已注册能力面板 chrome、卡片 summary、registration provenance、只读 HTTP approval artifact、Agent envelope draft、Managed Job、Completion Audit 与 runtime enable 操作;`src/features/capability-drafts/agentEnvelopeDraftPresentation.ts` 与 `src/features/capability-drafts/workspaceSkillAgentAutomationDraft.ts` 新增 copy 注入以保留非面板调用方默认中文兜底。`npm test -- "src/features/capability-drafts/agentEnvelopeDraftPresentation.test.ts" "src/features/capability-drafts/workspaceSkillAgentAutomationDraft.test.ts" "src/features/capability-drafts/components/WorkspaceRegisteredSkillsPanel.test.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 6 个文件、56 个用例;`npx eslint "src/features/capability-drafts/agentEnvelopeDraftPresentation.ts" "src/features/capability-drafts/workspaceSkillAgentAutomationDraft.ts" "src/features/capability-drafts/components/WorkspaceRegisteredSkillsPanel.tsx" "src/features/capability-drafts/components/WorkspaceRegisteredSkillsPanel.test.tsx" --max-warnings 0`、`npm run typecheck`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、3029 个 source key。`npm run verify:local` / GUI smoke 未作为本轮通过证据;本条仍不覆盖 Managed Job 初始值、automation prompt 与模型执行提示正文长尾。 +96. 2026-05-11 Settings Providers 添加流程静态 chrome 迁移续测:`src/components/api-key-provider/ModelAddPanel.tsx` 接入 `useTranslation("settings")` 并收口 `settings.providers.modelAdd.*`,覆盖目录分类、自定义 Provider 入口、返回 / 获取 API Key / 完成添加、配置标题、供应商名称、API 格式、API Key、模型优先级、接口模型筛选、主模型操作、手动模型输入和保存测试按钮;本条不覆盖添加流程校验错误、提交结果、接口获取模型动态反馈或模板目录数据。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3065 个 source key 结构一致;`npm test -- "src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx" "src/components/api-key-provider/ProviderSetting.ui.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、35 个用例;`npx eslint "src/components/api-key-provider/ModelAddPanel.tsx" "src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本轮通过证据;本轮未触碰 CapabilityDraft、Memory、QC scripts 或 `agent.json` 并行文件。 +97. 2026-05-11 Settings Providers 添加流程动态反馈迁移续测:`src/components/api-key-provider/ModelAddPanel.tsx` 继续收口 `settings.providers.modelAdd.feedback.*`,覆盖添加流程表单校验、接口获取模型的协议不支持 / 缺凭证 / Responses / Fal 特例 / 空结果 / 返回数量 / 已存在结果 / 异常兜底,以及测试连接并激活失败兜底;同步 5 个 `settings.json` 资源。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3093 个 source key 结构一致;`npm test -- "src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx" "src/components/api-key-provider/ProviderSetting.ui.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、35 个用例;`npx eslint "src/components/api-key-provider/ModelAddPanel.tsx" "src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本轮通过证据;本轮仍避开 CapabilityDraft、Memory、QC scripts 与 `agent.json` 并行文件。 +98. 2026-05-11 Settings Providers 添加流程目录辅助文案迁移续测:`src/components/api-key-provider/ModelAddPanel.tsx` 新增 `ModelAddCatalogCopy` 并把 catalog / registry description 与目录 badge 从硬编码中文收口到 `settings.providers.modelAdd.catalog.*`,同步 5 个 `settings.json` 资源。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3102 个 source key 结构一致;`npm test -- "src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx" "src/components/api-key-provider/ProviderSetting.ui.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、35 个用例;`npx eslint "src/components/api-key-provider/ModelAddPanel.tsx" "src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本轮通过证据;本轮仍未触碰 CapabilityDraft、Memory、QC scripts 或 `agent.json` 并行文件。 +99. 2026-05-11 Settings Providers 添加流程内置模板名称迁移续测:`src/components/api-key-provider/ModelAddPanel.tsx` 继续让 `ModelAddCatalogCopy` 注入内置模板名称 / 描述、图标 fallback、选择模板后的默认供应商名称和目录加载失败默认文案;同步 5 个 `settings.json` 资源。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3127 个 source key 结构一致;`npm test -- "src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx" "src/components/api-key-provider/ProviderSetting.ui.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、35 个用例;`npx eslint "src/components/api-key-provider/ModelAddPanel.tsx" "src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本轮通过证据;本轮仍只触碰 Providers 添加流程与 settings 资源,避开 CapabilityDraft、Memory、QC scripts 与 `agent.json` 并行文件。 +100. 2026-05-11 全量本地校验收口证据:隔壁进程使用 `LIME_AGENT_QC_PROVIDER="custom-f74b38b5-6d3f-44ef-aa0f-99d70c3482ed" LIME_AGENT_QC_MODEL="sensenova-6.7-flash-lite" npm run verify:local` 通过,完整日志为 `.lime/qc/verify-local-2026-05-11-sensenova-final-attempt.log`,末尾记录 `[local-ci] 本地校验完成。`;本次统一入口覆盖 `verify:app-version`、`lint`、`typecheck`、54 批 smart vitest、`verify:gui-smoke`,其中 `smoke:claw-chat-ready-streaming` 使用 live runtime `sensenova-6.7-flash-lite` 通过,`smoke:knowledge-gui` 与 `smoke:design-canvas` 通过,i18n Patch metrics 报告为 `no-hit`(`.lime/i18n/patch-metrics-report.json`,替换节点数 0、命中文本段数 0、退出候选 yes)。该证据覆盖此前 Agent streaming runtime blocker,不代表剩余长尾文案已经全部迁移;若最终合并前工作区继续变化,仍需基于届时 diff 复跑统一入口。 +101. 2026-05-11 Settings Providers 添加流程 provider type label 迁移续测:`src/components/api-key-provider/ModelAddPanel.tsx` 继续让 `ModelAddCatalogCopy` 注入 `providerTypeLabel`,覆盖系统目录 provider description 与配置页只读 API 格式展示;同步 5 个 `settings.json` 资源。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3154 个 source key 结构一致;`npm test -- "src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx" "src/components/api-key-provider/ProviderSetting.ui.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、35 个用例;`npx eslint "src/components/api-key-provider/ModelAddPanel.tsx" "src/components/api-key-provider/ApiKeyProviderSection.ui.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本轮通过证据;本轮继续只触碰 Providers 添加流程与 settings 资源。 +102. 2026-05-11 Settings Providers 接口获取模型 helper 调用面审计:通过只读脚本确认 `src/components/api-key-provider/ModelAddPanel.tsx` 与 `src/components/api-key-provider/ProviderSetting.tsx` 的 `buildResponsesModelFetchStatus` / `buildFalModelFetchStatus` 调用均传入 `modelFetchStatusCopy`(输出 `responsesCopy=true falCopy=true`),并确认 `providerModelFetchHelpers.ts` 外的调用面只剩这两个 current UI 文件;本条为文档审计,不新增代码或资源,`git diff --check -- "docs/roadmap/i18n/prd.md"` 作为本条收口校验。 +103. 2026-05-11 Skills Managed Job 初始值与 automation prompt 迁移续测:`src/features/capability-drafts/workspaceSkillAgentAutomationDraft.ts` 新增 `WorkspaceSkillManagedAutomationInitialValuesCopy`,让 Managed Job 草案名称、description、Managed Objective、success criteria 与 automation prompt 支持调用方 copy 注入;`src/components/skills/SkillsWorkspacePage.tsx` 接入 `skills.workspace.managedJob.initialValues.*`,并同步 5 个 `agent.json` 资源,存量非 UI 调用方仍保留默认中文兜底。`npm test -- "src/features/capability-drafts/workspaceSkillAgentAutomationDraft.test.ts" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 4 个文件、42 个用例;`npx eslint "src/features/capability-drafts/workspaceSkillAgentAutomationDraft.ts" "src/features/capability-drafts/workspaceSkillAgentAutomationDraft.test.ts" "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析、`npm run typecheck` 与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、3154 个 source key。记录 105 已基于本条新增改动后的工作区补齐 `npm run verify:local` / GUI smoke 全量通过证据。 +104. 2026-05-11 Settings Providers runtime 数据边界审计:通过只读检索确认 `ModelAddPanel` 中剩余 provider/resource 字段来自 `SystemProviderCatalogItem.name/api_host`、`EnhancedModelMetadata.provider_name/id`、API Host、模型 ID 与 `RESOURCE_PROVIDER_*` 映射;这些字段是运行时 catalog/model registry 事实数据或配置值,不属于当前静态 UI 文案迁移范围。本条只更新 PRD 边界,`git diff --check -- "docs/roadmap/i18n/prd.md"` 作为收口校验。 +105. 2026-05-11 Managed Job 初始值迁移后的全量本地校验:使用 `LIME_AGENT_QC_PROVIDER="custom-f74b38b5-6d3f-44ef-aa0f-99d70c3482ed" LIME_AGENT_QC_MODEL="sensenova-6.7-flash-lite" npm run verify:local` 通过,完整日志为 `.lime/qc/verify-local-2026-05-11-i18n-managed-job-initial-values.log`,末尾记录 `[local-ci] 本地校验完成。`;本次统一入口覆盖 `verify:app-version`、`lint`、`typecheck`、54 批 smart vitest 与 `verify:gui-smoke`,其中 `smoke:claw-chat-ready-streaming` 继续使用 live runtime `sensenova-6.7-flash-lite` 通过,`smoke:knowledge-gui`、`smoke:design-canvas` 通过,i18n Patch metrics 报告仍为 `no-hit`(替换节点数 0、命中文本段数 0、退出候选 yes)。 +106. 2026-05-11 Navigation 邀请入口 / 弹窗迁移续测:`src/components/AppSidebar.tsx` 收口 `navigation.sidebar.invite.*`,覆盖邀请按钮、弹窗 chrome、未登录 / 加载 / 错误状态、邀请码与奖励字段、复制按钮和 toast;`src/components/AppSidebar.test.tsx` 新增英文 locale 回归,验证邀请入口、弹窗字段、奖励单位与复制成功 toast 均来自 `navigation` namespace。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3181 个 source key 结构一致;`npm test -- "src/components/AppSidebar.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、61 个用例;`npx eslint "src/components/AppSidebar.tsx" "src/components/AppSidebar.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本轮通过证据;本轮刻意避开 Skills、CapabilityDraft、QC scripts、`agent.json` 和 Providers 并行文件。 +107. 2026-05-11 Navigation 外观快捷面板迁移续测:`src/components/AppSidebar.tsx` 收口 `navigation.sidebar.appearance.*`,覆盖外观入口、弹窗摘要、主题模式、配色预设与随机配色按钮;`src/components/AppSidebar.test.tsx` 新增英文 locale 回归,验证 `Quick appearance switch`、`Appearance`、`Theme`、`Color`、`Follow system · Ink Green`、`Switch theme to Dark` 与 `Switch color scheme to Ocean` 均来自 `navigation` namespace。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3219 个 source key 结构一致;`npm test -- "src/components/AppSidebar.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、62 个用例;`npx eslint "src/components/AppSidebar.tsx" "src/components/AppSidebar.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本轮通过证据;本条刻意不碰设置页 Appearance 主体与 `themeMode.ts` / `colorSchemes.ts` 事实数据。 +108. 2026-05-11 Navigation 账号套餐 usage 边界审计:只读复核 `src/components/AppSidebar.tsx` 的 `resolveAccountPlanSummary()`、`parseAccountUsagePercent()`,以及 `src/lib/api/oemCloudControlPlane.ts` 的 `OemCloudProviderOfferSummary.currentPlan` / `creditsSummary` 解析,确认账号 usage 展示来自云端运行时字段,不属于静态 UI 文案;`navigation.sidebar.account.freePlan` / `viewPlanDetails` / `viewDetails` 已是 key-based 资源。本条只更新 PRD 边界,`git diff --check -- "docs/roadmap/i18n/prd.md"` 作为收口校验。 +109. 2026-05-11 CuratedTask 模板定义与 Agent Chat 输入能力区结果模板主调用面迁移续测:`src/components/agent/chat/utils/curatedTaskTemplates.ts` 新增 `CuratedTaskTemplateCopy` / `buildCuratedTaskTemplateCopy`,让模板 title、summary、字段、输出契约、结果去向、follow-up label / promptHint 与推荐 reason 支持 key-based copy 注入;`src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx`、`src/components/agent/chat/components/EmptyState.tsx`、`src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx` 与 `src/components/skills/SkillsWorkspacePage.tsx` 接入 `curatedTask.templates.*`,并同步 5 个 `agent.json` 资源。`npm test -- "src/components/agent/chat/utils/curatedTaskTemplates.test.ts" "src/components/agent/chat/skill-selection/inputCapabilitySections.test.ts" "src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx" "src/components/agent/chat/components/EmptyState.test.tsx" "src/components/agent/chat/skill-selection/CharacterMention.test.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 8 个文件、177 个用例;`npx eslint "src/components/agent/chat/utils/curatedTaskTemplates.ts" "src/components/agent/chat/utils/curatedTaskTemplates.test.ts" "src/components/agent/chat/skill-selection/inputCapabilitySections.ts" "src/components/agent/chat/skill-selection/inputCapabilitySections.test.ts" "src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx" "src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx" "src/components/agent/chat/components/EmptyState.tsx" "src/components/skills/SkillsWorkspacePage.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析、`npm run typecheck` 与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、3369 个 source key。`npm run verify:local` / GUI smoke 未在本条之后复跑;执行时隔壁 qcloop GUI worker 正在运行,本轮不抢 GUI owner,最终合并前需基于最新 diff 复跑统一入口。 +110. 2026-05-11 Workspace 图片素材 `ImageGallery` 迁移续测:`src/components/workspace/media/ImageGallery.tsx` 接入 `useTranslation("workspace")` 与 `formatNumber()`,收口 `workspace.imageGallery.*`,并同步 5 个 `workspace.json` 资源;`ImageGallery.test.tsx` 新增英文 locale 回归,验证搜索 placeholder、视图 aria、分类筛选、计数摘要和底部提示均来自 `workspace` namespace。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3364 个 source key 结构一致;`npm test -- "src/components/workspace/media/ImageGallery.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/components/workspace/media/ImageGallery.tsx" "src/components/workspace/media/ImageGallery.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本条通过证据;本轮刻意避开 Agent Chat、Skills、CapabilityDraft、QC scripts、Providers 与 `agent.json` / `settings.json` 并行文件。 +111. 2026-05-11 Browser Runtime 工作台外壳迁移续测:`src/features/browser-runtime/BrowserRuntimeWorkspace.tsx` 接入 `useTranslation("workspace")` 与 `formatNumber()`,收口 `workspace.browserRuntime.*`,并同步 5 个 `workspace.json` 资源;`BrowserRuntimeWorkspace.test.tsx` 新增英文 locale 回归,验证标题、说明、刷新按钮、Profile / Chrome 统计与附着说明均来自 `workspace` namespace。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3378 个 source key 结构一致;`npm test -- "src/features/browser-runtime/BrowserRuntimeWorkspace.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、16 个用例;`npx eslint "src/features/browser-runtime/BrowserRuntimeWorkspace.tsx" "src/features/browser-runtime/BrowserRuntimeWorkspace.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本条通过证据;本轮仍避开 Agent Chat、Skills、CapabilityDraft、QC scripts、Providers 与 `agent.json` / `settings.json` 并行文件。 +112. 2026-05-11 Browser Runtime 环境预设子面板迁移续测:`src/features/browser-runtime/BrowserEnvironmentPresetManager.tsx` 接入 `useTranslation("workspace")` 与 `formatNumber()`,收口 `workspace.browserEnvironment.*`,并同步 5 个 `workspace.json` 资源;`BrowserEnvironmentPresetManager.test.tsx` 新增英文 locale 回归,验证面板 chrome、启动环境选择器、刷新 / 新建操作、活跃预设计数、预设 meta、表单 label / placeholder 与名称必填反馈均来自 `workspace` namespace。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3482 个 source key 结构一致;`npm test -- "src/features/browser-runtime/BrowserEnvironmentPresetManager.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、8 个用例;`npx eslint "src/features/browser-runtime/BrowserEnvironmentPresetManager.tsx" "src/features/browser-runtime/BrowserEnvironmentPresetManager.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本条通过证据;执行时隔壁 qcloop 正在覆盖 workspace-ready / GUI smoke,本轮不抢 GUI owner。 +113. 2026-05-11 Agent Chat 输入能力区 section / banner / helper chrome 迁移续测:`src/components/agent/chat/skill-selection/inputCapabilitySections.ts` 新增 `InputCapabilitySectionsCopy` / `buildInputCapabilitySectionsCopy`,让 slash / mention section heading、统一调用注册表 banner、最近判断推荐 banner、最近输入描述、结果基线摘要与分组 kind label 支持 key-based copy 注入;`src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx` 接入 `inputCapabilities.*`,覆盖搜索 placeholder、空态、去技能中心操作和面板 helper 文案;同步 5 个 `agent.json` 资源,并在 `inputCapabilitySections.test.ts` 增加 slash 结果模板和 @ 面板 chrome copy 注入回归。`npm test -- "src/components/agent/chat/utils/curatedTaskTemplates.test.ts" "src/components/agent/chat/skill-selection/inputCapabilitySections.test.ts" "src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx" "src/components/agent/chat/components/EmptyState.test.tsx" "src/components/agent/chat/skill-selection/CharacterMention.test.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 8 个文件、178 个用例;`npx eslint "src/components/agent/chat/skill-selection/inputCapabilitySections.ts" "src/components/agent/chat/skill-selection/inputCapabilitySections.test.ts" "src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析、`npm run typecheck` 与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、3482 个 source key。`npm run verify:local` / GUI smoke 未作为本条通过证据;执行时 `.lime/qc/gui-process-owner-current.md` 显示 stale active GUI smoke owner(`npm run smoke:design-canvas`)且 qcloop 正在运行,本轮按协作约束不抢 GUI owner。 +114. 2026-05-11 Browser Runtime 资料管理子面板迁移续测:`src/features/browser-runtime/BrowserProfileManager.tsx` 接入 `useTranslation("workspace")` 与 `formatNumber()`,收口 `workspace.browserProfile.*`,并同步 5 个 `workspace.json` 资源;`BrowserProfileManager.test.tsx` 新增英文 locale 回归,验证面板 chrome、启动环境选择器、刷新 / 新建操作、资料统计、附着模式提示、资料卡 meta、表单 label / placeholder 与名称必填反馈均来自 `workspace` namespace。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3564 个 source key 结构一致;`npm test -- "src/features/browser-runtime/BrowserProfileManager.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、16 个用例;`npx eslint "src/features/browser-runtime/BrowserProfileManager.tsx" "src/features/browser-runtime/BrowserProfileManager.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本条通过证据;本轮开始时隔壁 release qcloop 正在运行 `npm run verify:gui-smoke`,且本刀只触碰 Browser Runtime 资料管理 clean 文件与 `workspace.json`,不抢 GUI owner、不触碰 Agent Chat / Skills / CapabilityDraft / QC / Providers 并行脏区。 + +115. 2026-05-11 Skills 工作台 Workspace Skill runtime enable 首发提示正文迁移续测:`src/components/skills/SkillsWorkspacePage.tsx` 新增 `WorkspaceRuntimeEnablePromptCopy` 并让 `buildWorkspaceRuntimeEnablePrompt()` 支持 copy 注入,`SkillsWorkspacePage` 接入 `skills.workspace.runtimeEnable.prompt.*`,覆盖启用本地 Skill 的 initial user prompt 正文;同步 5 个 `agent.json` 资源,并在 `SkillsWorkspacePage.test.tsx` 补充 runtime enable prompt 三段正文断言。`npm test -- "src/components/skills/SkillsWorkspacePage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、36 个用例;`npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析、`npm run typecheck` 与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、3564 个 source key。`npm run verify:local` / GUI smoke 未作为本条通过证据;执行时 qcloop 仍在运行且存在 stale `smoke:design-canvas` owner,本轮继续不抢 GUI owner。 + +116. 2026-05-11 Skills 执行对话框 chrome 迁移续测:`src/components/skills/SkillExecutionDialog.tsx` 接入 `useTranslation("agent")`,收口 `skills.executionDialog.*`,覆盖执行模式、workflow 标识、参数提示、输入区、Provider 选择、进度、按钮状态与结果区提示;新增 `SkillExecutionDialog.test.tsx`,验证 fallback description、执行模式、workflow badge、参数提示、输入 placeholder、Provider helper、进度与执行按钮均来自 `agent` namespace。`npx eslint "src/components/skills/SkillExecutionDialog.tsx" "src/components/skills/SkillExecutionDialog.test.tsx" --max-warnings 0`、`npm test -- "src/components/skills/SkillExecutionDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析、`npm run typecheck` 与定向 `git diff --check` 通过;本轮最新资源口径为 5 个 locale、6 个 namespace、3671 个 source key。`npm run verify:local` / GUI smoke 未作为本条通过证据;执行时 qcloop 仍在运行且存在 stale `smoke:design-canvas` owner,本轮继续不抢 GUI owner。 +117. 2026-05-11 Browser Runtime Debug 面板外壳 / 状态 / 最小控制迁移续测:`src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx` 接入 `useTranslation("workspace")`,收口 `workspace.browserRuntimeDebug.*`,并同步 5 个 `workspace.json` 资源;`BrowserRuntimeDebugPanel.test.tsx` 新增英文 locale 回归,验证调试面板外壳、独立窗口 / 高级调试操作、无会话标题、断开状态、连接占位、连接按钮、最小人工控制标题 / 状态 / placeholder 均来自 `workspace` namespace。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3671 个 source key 结构一致;`npm test -- "src/features/browser-runtime/BrowserRuntimeDebugPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、14 个用例;`npx eslint "src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx" "src/features/browser-runtime/BrowserRuntimeDebugPanel.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过,并覆盖记录 116 提到的 `workspace.browserRuntimeDebug.*` typed resource 缺口。`npm run verify:local` / GUI smoke 未作为本条通过证据;执行时隔壁 qcloop 正在运行 `test:contracts` / `smoke:agent-service-skill-entry`,本轮仍不触碰 Agent Chat / Skills / CapabilityDraft / QC / Providers 并行脏区,也不抢 GUI owner。 +118. 2026-05-11 Skill 脚手架启动弹窗 chrome 迁移续测:`src/components/skills/SkillScaffoldDialog.tsx` 接入 `useTranslation("agent")` 并收口 `skills.scaffoldDialog.*`,覆盖启动弹窗标题说明、来源结果、创建位置、项目 / 用户级选项、字段 label / placeholder / helper、必填校验与底部操作;`SkillScaffoldDialog.test.tsx` 增加 `react-i18next` mock,复用现有 6 个用例验证表单、预填来源、带回创作输入、结构化草稿提交与必填错误仍按本地化 copy 渲染。`npx eslint "src/components/skills/SkillScaffoldDialog.tsx" "src/components/skills/SkillScaffoldDialog.test.tsx" --max-warnings 0`、`npm test -- "src/components/skills/SkillScaffoldDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"`、`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析、`npm run typecheck` 与定向 `git diff --check` 通过;本轮最新资源口径为 5 个 locale、6 个 namespace、3693 个 source key。`npm run typecheck` 初次尝试时被并行 Browser Runtime 资料 / Debug 面板的 `workspace.browserExistingSession.feedback.*` typed key / defaultValue 错误阻断,该阻断已由记录 119 收口后复跑通过;未启动 `npm run verify:local` / GUI smoke。 +119. 2026-05-11 Browser Runtime existing session hook 消息迁移续测:`src/features/browser-runtime/useExistingSessionAttachPanel.ts` 与 `src/features/browser-runtime/useExistingSessionProfileManager.ts` 新增 typed copy 注入边界,`BrowserRuntimeDebugPanel.tsx` / `BrowserProfileManager.tsx` 从 `workspace.browserExistingSession.feedback.*` 注入读取附着状态、读取当前页面、读取标签页、切换标签页和附着当前 Chrome 的反馈文案,并同步 5 个 `workspace.json` 资源;hook 单测改用英文 copy 验证消息来自注入边界,面板回归继续覆盖中文资源渲染。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3702 个 source key 结构一致;`npm test -- "src/features/browser-runtime/useExistingSessionAttachPanel.test.tsx" "src/features/browser-runtime/useExistingSessionProfileManager.test.tsx" "src/features/browser-runtime/BrowserRuntimeDebugPanel.test.tsx" "src/features/browser-runtime/BrowserProfileManager.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 6 个文件、35 个用例;`npx eslint "src/features/browser-runtime/useExistingSessionAttachPanel.ts" "src/features/browser-runtime/useExistingSessionAttachPanel.test.tsx" "src/features/browser-runtime/useExistingSessionProfileManager.ts" "src/features/browser-runtime/useExistingSessionProfileManager.test.tsx" "src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx" "src/features/browser-runtime/BrowserProfileManager.tsx" --max-warnings 0` 与 `npm run typecheck` 通过,覆盖记录 118 提到的 `workspace.browserExistingSession.feedback.*` typed key 阻断;定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本条通过证据;本轮检测到隔壁 `npm run typecheck` 仍在运行,且 qcloop / GUI owner 并行存在,继续不抢统一入口或 GUI smoke。 +120. 2026-05-11 SceneApp 最近判断建议横幅 runtime suggestion 迁移续测:`src/components/agent/chat/utils/reviewFeedbackProjection.ts` 新增 `ReviewFeedbackProjectionCopy`、`buildReviewFeedbackProjectionCopy()` 与 fallback interpolation,`SceneAppReviewFeedbackBanner.tsx` 和 `SceneAppExecutionSummaryCard.tsx` 接入 `reviewFeedback.*`,覆盖最近判断 badge、标题、suggestionText、续接按钮和结果基线 helper;新增 `reviewFeedbackProjection.test.ts` 验证 suggestion copy 注入。`npx eslint "src/components/agent/chat/utils/reviewFeedbackProjection.ts" "src/components/agent/chat/utils/reviewFeedbackProjection.test.ts" "src/components/sceneapps/SceneAppReviewFeedbackBanner.tsx" --max-warnings 0` 与 `npx eslint "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx" "src/components/agent/chat/utils/reviewFeedbackProjection.ts" "src/components/agent/chat/utils/reviewFeedbackProjection.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/agent/chat/utils/reviewFeedbackProjection.test.ts" "src/components/sceneapps/SceneAppsPage.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 4 个文件、43 个用例,`npm test -- "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx" "src/components/agent/chat/utils/reviewFeedbackProjection.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 4 个文件、16 个用例;`npm run detect-translations -- --verbose`、5 个 `agent.json` 的 `python3 -m json.tool` 解析与定向 `git diff --check` 通过,本轮最新资源口径为 5 个 locale、6 个 namespace、3710 个 source key。`npm run typecheck` 本轮尝试时被并行 `src/features/browser-runtime/BrowserSiteAdapterPanel.tsx` 的 `workspace.browserSiteAdapter.*` typed key / defaultValue 错误阻断,未作为本条通过证据;未启动 `npm run verify:local` / GUI smoke。 +121. 2026-05-11 Browser Site Adapter 面板 chrome 与执行反馈迁移续测:`src/features/browser-runtime/BrowserSiteAdapterPanel.tsx` 接入 `useTranslation("workspace")` 与 `formatNumber()` / `formatDate()`,收口 `workspace.browserSiteAdapter.*`,并同步 5 个 `workspace.json` 资源;`BrowserSiteAdapterPanel.test.tsx` 通过 `changeLimeLocale("zh-CN")` 初始化 i18n,继续覆盖工作台模式保存资源项目、推荐适配器选择、目录变更刷新、站点不可达错误、初始参数自动执行、attached session guard、写回当前主稿和导出结果导航。`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3812 个 source key 结构一致;`npm test -- "src/features/browser-runtime/BrowserSiteAdapterPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、15 个用例;`npx eslint "src/features/browser-runtime/BrowserSiteAdapterPanel.tsx" "src/features/browser-runtime/BrowserSiteAdapterPanel.test.tsx" --max-warnings 0` 与 `npm run typecheck` 通过,覆盖记录 120 提到的 `workspace.browserSiteAdapter.*` typed key 阻断;定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本条通过证据;本轮不触碰 Agent Chat / Skills / Settings / QC 并行脏区,也不抢 GUI owner。 + +122. 2026-05-11 General Workbench 最近判断建议横幅 runtime suggestion 迁移续测:`src/components/agent/chat/components/GeneralWorkbenchWorkflowPanel.tsx` 接入 `useTranslation("agent")`、`buildReviewFeedbackProjectionCopy(t)` 与 `formatReviewFeedbackTemplate()`,让当前进展和运行详情里的 `ReviewFeedbackProjectionCard` 从 `reviewFeedback.*` 读取最近判断 badge、标题、续接按钮、suggestionText 与通用工作台 helper;同步 5 个 `agent.json` 的 `reviewFeedback.helper.generalWorkbench` 资源,`GeneralWorkbenchSidebar.test.tsx` 补稳定 `react-i18next` mock,避免无 i18n 实例时依赖 legacy DOM Patch 或浏览器默认 locale。`npx eslint "src/components/agent/chat/components/GeneralWorkbenchWorkflowPanel.tsx" "src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx" "src/components/agent/chat/utils/reviewFeedbackProjection.ts" "src/components/agent/chat/utils/reviewFeedbackProjection.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx" "src/components/agent/chat/utils/reviewFeedbackProjection.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 4 个文件、45 个用例;`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3813 个 source key 结构一致;5 个 `agent.json` 的 JSON 解析与定向 `git diff --check` 通过。`npm run typecheck` 本轮尝试被并行 `src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx(582,33)` 的 `TS2554: Expected 2 arguments, but got 1` 阻断,未作为本条通过证据;执行时 qcloop 与 stale `smoke:design-canvas` owner 仍在运行,未启动 `npm run verify:local` / GUI smoke。 +123. 2026-05-11 Browser Runtime Debug 审计与附着 Chrome 子面板迁移续测:`src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx` 继续收口 `workspace.browserRuntimeDebug.audit.*`、`attachTabs.*`、`attachFallback.*`、`attachPage.*` 与共享 `value.*` key,覆盖最近启动 / 动作审计、附着 Chrome fallback、当前窗口标签页读取 / 切换和当前页面摘要 chrome;同步 5 个 `workspace.json` 资源,并保留 URL、session/profile id、动作名、页面标题、Markdown 摘要与底层错误作为 runtime / 用户数据。`npm test -- "src/features/browser-runtime/BrowserRuntimeDebugPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、14 个用例;`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3854 个 source key 结构一致;`npx eslint "src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx" "src/features/browser-runtime/BrowserRuntimeDebugPanel.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过,覆盖记录 122 提到的 `BrowserRuntimeDebugPanel.tsx(582,33)` typecheck 阻断。`npm run verify:local` / GUI smoke 未作为本条通过证据;本轮只触碰 Browser Runtime Debug 既有切片、`workspace.json` 和 PRD,不触碰 Agent Chat / Skills / Settings / QC 并行脏区,也不抢 GUI owner。 +124. 2026-05-11 Browser Runtime Debug CDP 托管会话高级区迁移续测:`src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx` 继续收口 `workspace.browserRuntimeDebug.cdp.*`、`sessionInfo.*`、`events.*`,覆盖 CDP/Profile 选择、托管会话操作、会话信息 label、Console / Network 标题、事件计数与空态,并通过 `formatNumber()` 按当前 locale 格式化事件数量;同步 5 个 `workspace.json` 资源,`BrowserRuntimeDebugPanel.test.tsx` 扩展英文 locale 回归,验证高级调试展开后的 Profile session、CDP tab、No tabs found、Refresh tabs、Reattach、Continue in Chrome、Session info、Status、Control mode、Last frame、No Console / Network events 均来自 `workspace` namespace。`npm test -- "src/features/browser-runtime/BrowserRuntimeDebugPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、14 个用例;`npm run detect-translations -- --verbose` 通过,确认当前工作区 5 个 locale、6 个 namespace、3874 个 source key 结构一致;`npx eslint "src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx" "src/features/browser-runtime/BrowserRuntimeDebugPanel.test.tsx" --max-warnings 0`、`npm run typecheck` 与定向 `git diff --check` 通过。`npm run verify:local` / GUI smoke 未作为本条通过证据;本轮仍只触碰 Browser Runtime Debug 既有切片、`workspace.json` 和 PRD,不触碰 Agent Chat / Skills / Settings / QC 并行脏区,也不抢 GUI owner。 + +125. 2026-05-11 统一入口复跑与 blocker 复核:针对记录 122 / 124 之后的最新工作区,补 `SkillScaffoldDialog` source hint fallback interpolation,避免无 i18n 实例测试环境显示 `{{hint}}`;同步把 `AgentUiTeamWorkbenchSurfaceView.test.tsx` 的计数断言对齐当前中文 UI copy。`npx eslint "src/components/skills/SkillScaffoldDialog.tsx" "src/components/skills/SkillsPage.test.tsx" "src/components/skills/SkillScaffoldDialog.test.tsx" --max-warnings 0`、`npm test -- "src/components/skills/SkillsPage.test.tsx" "src/components/skills/SkillScaffoldDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"`、`npx eslint "src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.tsx" "src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.test.tsx" --max-warnings 0` 与 `npm test -- "src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.test.tsx"` 均通过。随后 `npm run verify:local` 已通过 `verify:app-version`、`lint`、`typecheck`、全量 smart vitest、`test:contracts` 与 GUI smoke 的 workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page 阶段,但在 `smoke:claw-chat-ready-streaming` 失败;单独复跑 `npm run smoke:claw-chat-ready-streaming -- --app-url http://127.0.0.1:1420/ --health-url http://127.0.0.1:3030/health --invoke-url http://127.0.0.1:3030/invoke --timeout-ms 600000 --interval-ms 1000` 仍复现 `长 turn 在首个流式增量前结束: status=completed`。因此本轮统一入口仍未作为绿色证据,剩余 blocker 指向 Claw streaming smoke / live runtime 行为,不再是本地化资源、类型或前端单测阻断。 + +126. 2026-05-11 App 根壳层共享文案迁移续测:`src/App.tsx` 接入 `useTranslation("common")`,把主内容 lazy fallback、资源管理器回跳 unsupported toast、推荐项目名称前缀 / 创建成功 toast、浏览器连接器设置打开提示和官网 deep link unsupported error 迁移到 `common.app.*`,并把原本暴露 `slug` 的错误描述收口成面向普通用户的官网入口映射说明;同步 5 个 `common.json` 资源,并在 `loadNamespace.test.ts` / `types.test.ts` 覆盖 `common.app.loadingPage` 的 bundled fallback 与 typed key。`npx eslint "src/App.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm test -- "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"`、`npm run detect-translations -- --verbose`、5 个 `common.json` 的 `python3 -m json.tool` 解析、`npm run typecheck` 与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、3885 个 source key。执行时隔壁 qcloop 正在推进 P0 场景,期间曾复跑 `smoke:claw-chat-ready-streaming` 并进入后续 `tool-approval-sandbox-boundary` 只读场景;本轮不触碰 Claw streaming / QC 脚本 / Agent runtime 修复文件,也未启动 `npm run verify:local` 或 GUI smoke,最终统一入口仍应由 qcloop / 合并前最新 diff 复跑确认。 + +127. 2026-05-11 ProjectSelector 项目 / 工作区切换器迁移续测:`src/components/projects/ProjectSelector.tsx` 接入 `useTranslation("common")`,收口项目 / 工作区 entity label、默认项目 badge、workspace type label、未设置目录、触发器 placeholder、管理区标题说明、搜索 / 空态 / 计数、重命名 / 删除弹窗、保存 / 删除中状态和创建 / 重命名 / 删除 toast;同步 5 个 `common.json` 的 `common.projectSelector.*` 资源,并在 `ProjectSelector.ui.test.tsx` 补稳定 `react-i18next` mock,避免无 i18n 实例测试环境回落到 key。`npx eslint "src/components/projects/ProjectSelector.tsx" "src/components/projects/ProjectSelector.ui.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/projects/ProjectSelector.ui.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 3 个文件、12 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、3927 个 source key;5 个 `common.json` 的 JSON 解析、`npm run typecheck` 与定向 `git diff --check` 通过。执行时隔壁 qcloop 进入 `skill-forge-register-bind-enable` repair 轮,本轮只触碰 ProjectSelector / common resources / i18n loader tests / PRD,不触碰 capability draft、skill forge、QC 脚本、Agent runtime 或 GUI owner;未启动 `npm run verify:local` / GUI smoke。 + +128. 2026-05-11 CreateProjectDialog 新建项目弹窗迁移续测:`src/components/projects/CreateProjectDialog.tsx` 接入 `useTranslation("common")`,收口弹窗标题说明、hero 摘要、项目名称 label / placeholder、项目类型标题说明 / 当前选择 / 通用对话类型 label、目录与路径、workspace 目录、项目路径预览、路径检查 / 冲突、创建建议、状态 badge、取消 / 创建 / 创建中按钮和创建失败 toast;同步 5 个 `common.json` 的 `common.createProjectDialog.*` 资源,并在 `CreateProjectDialog.test.tsx` 补稳定 `react-i18next` mock,避免 effect 依赖的 `t` 在无 i18n 实例测试环境中反复变更。`npx eslint "src/components/projects/CreateProjectDialog.tsx" "src/components/projects/CreateProjectDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/projects/CreateProjectDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 3 个文件、7 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、3956 个 source key;5 个 `common.json` 的 JSON 解析、`npm run typecheck` 与定向 `git diff --check` 通过。执行时隔壁 qcloop 仍在 `workspace-ready-session-restore` / `verify:gui-smoke` P0 场景中,本轮只触碰 CreateProjectDialog / common resources / i18n loader tests / PRD,不触碰 QC、Agent runtime、Skills 或 GUI owner;未启动 `npm run verify:local` / GUI smoke,最终统一入口仍应由 qcloop / 合并前最新 diff 复跑确认。 + +129. 2026-05-11 CreateProjectDialog 创建失败友好错误迁移续测:`src/lib/api/project.ts` 的 `getCreateProjectErrorMessage()` 新增 `CreateProjectErrorMessageCopy` 注入边界,保留非 UI 调用方默认中文兜底;`src/components/projects/CreateProjectDialog.tsx` 从 `common.createProjectDialog.error.*` 注入未知错误、对象错误、路径已存在、数据库结构过旧和无效路径等用户可见友好错误,避免创建失败 toast 的插值消息继续依赖 API helper 中文事实源;同步 5 个 `common.json` 资源,并在 `project.test.ts` 补英文 copy 注入回归,在 `loadNamespace.test.ts` / `types.test.ts` 覆盖 `common.createProjectDialog.error.unknown`。`npx eslint "src/lib/api/project.ts" "src/lib/api/project.test.ts" "src/components/projects/CreateProjectDialog.tsx" "src/components/projects/CreateProjectDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/lib/api/project.test.ts" "src/components/projects/CreateProjectDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 4 个文件、67 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、3961 个 source key;5 个 `common.json` 的 JSON 解析、`npm run typecheck` 与定向 `git diff --check` 通过。执行时隔壁 qcloop 已进入 `release-package-startup-smoke` P0 场景,本轮不触碰 QC、Agent runtime、Skills 或 smoke 脚本,也未启动 `npm run verify:local` / GUI smoke。 + +130. 2026-05-11 Deep Link 邀请 / 登录 / 支付 toast 迁移续测:`src/hooks/useDeepLink.ts` 接入 `useTranslation("common")`,把邀请领取成功 / 保存 / 租户不匹配 / 领取失败、Google 登录未完成 / 成功 / 失败,以及支付回跳返回等 toast 标题与说明迁移到 `common.deepLink.*`;同步 5 个 `common.json` 资源,动态 `providerName` 继续通过 i18next interpolation 注入,后端错误消息和 URL payload 仍作为 runtime 数据保留。`src/hooks/useDeepLink.test.tsx` 补稳定 `react-i18next` mock,继续覆盖官网 open deep link、支付回跳、邀请缓存和已有云端会话自动 claim;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `common.deepLink.referral.saved.title`。`npx eslint "src/hooks/useDeepLink.ts" "src/hooks/useDeepLink.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/hooks/useDeepLink.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 3 个文件、10 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、3976 个 source key;5 个 `common.json` 的 JSON 解析、`npm run typecheck` 与定向 `git diff --check` 通过。执行时隔壁 qcloop 仍在 `release-package-startup-smoke` repair,且相关 blocker 指向 `smoke:claw-chat-ready-streaming`,本轮不触碰 QC、Agent runtime、Skills 或 smoke 脚本,也未启动 `npm run verify:local` / GUI smoke。 + +131. 2026-05-11 App Startup Windows 启动自检 toast 迁移续测:`src/hooks/useAppStartupEffects.ts` 接入 `useTranslation("common")`,把 Windows 启动阻塞 / 警告 toast 标题迁移到 `common.app.startup.windows.*`;诊断摘要 `diagnostics.summary_message` 保持后端运行时数据,不写入静态资源;同步 5 个 `common.json` 资源,并新增 `src/hooks/useAppStartupEffects.test.tsx` 覆盖阻塞 / 警告两种 toast 标题。`npx eslint "src/hooks/useAppStartupEffects.ts" "src/hooks/useAppStartupEffects.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/hooks/useAppStartupEffects.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 3 个文件、7 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、3978 个 source key;5 个 `common.json` 的 JSON 解析与定向 `git diff --check` 通过。`npm run typecheck` 仍被并行 Browser Runtime 脏改阻断:`BrowserRuntimeDebugPanel.tsx` 与 `useExistingSessionAttachPanel.test.tsx` 的 `ExistingSessionAttachPanelCopy.presentation` 缺失,未作为本刀 i18n 回归;执行时隔壁 qcloop / `smoke:claw-chat-ready-streaming` 仍在 P0 修复链,本轮不触碰 QC、Agent runtime、Browser Runtime 或 GUI owner,也未启动 `npm run verify:local` / GUI smoke。 + +132. 2026-05-11 Browser Runtime existing session 附着 Chrome presentation 迁移续测:`src/features/browser-runtime/existingSessionAttachPresentation.ts` 新增 `ExistingSessionAttachPresentationCopy` 并移除 helper 内用户可见中文默认文案,`useExistingSessionAttachPanel.ts` 将 presentation copy 纳入 typed copy 边界,`BrowserRuntimeDebugPanel.tsx` 从 `workspace.browserExistingSession.presentation.*` 注入状态、placeholder、动作与提示文案;同步 5 个 `workspace.json` 资源,并在 `existingSessionAttachPresentation.test.ts` / `useExistingSessionAttachPanel.test.tsx` 使用英文 copy 注入回归,在 `BrowserRuntimeDebugPanel.test.tsx` 增加英文 locale 附着 Chrome presentation 断言,在 `loadNamespace.test.ts` / `types.test.ts` 覆盖 bundled fallback 与 typed key。`npm test -- "src/features/browser-runtime/existingSessionAttachPresentation.test.ts" "src/features/browser-runtime/useExistingSessionAttachPanel.test.tsx" "src/features/browser-runtime/BrowserRuntimeDebugPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、24 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4002 个 source key;`npx eslint "src/features/browser-runtime/existingSessionAttachPresentation.ts" "src/features/browser-runtime/existingSessionAttachPresentation.test.ts" "src/features/browser-runtime/useExistingSessionAttachPanel.ts" "src/features/browser-runtime/useExistingSessionAttachPanel.test.tsx" "src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx" "src/features/browser-runtime/BrowserRuntimeDebugPanel.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、5 个 `workspace.json` 的 `python3 -m json.tool` 解析、`npm run typecheck` 与定向 `git diff --check` 通过。本轮开始前多次检测到隔壁 `release-package-startup-smoke` qcloop / `npm run verify:gui-smoke` owner,已等待 owner 释放后再写入;`npm run verify:local` / GUI smoke 未作为本条通过证据。 + +133. 2026-05-11 OEM Cloud Access 登录 / OAuth / 支付反馈迁移续测:`src/hooks/useOemCloudAccess.ts` 接入 `useTranslation("common")`,把云端登录入口未配置、Google / 云端 OAuth 同步成功、系统浏览器授权提示、Lime 云端登录页打开提示、打开 / 同步失败兜底,以及支付页等待、支付确认 / 未完成 / 超时、支付回跳同步、回跳未完成和确认 / 同步失败兜底迁移到 `common.oemCloudAccess.auth.*` 与 `common.oemCloudAccess.payment.*`;后端错误、支付回跳 payload、tenant / session / provider / order 运行时数据仍作为 runtime 数据保留。同步 5 个 `common.json` 资源,并在 `src/hooks/useOemCloudAccess.test.tsx` 补稳定 `react-i18next` mock,验证 Google 桌面登录成功、云端登录页 fallback 与支付回跳同步提示使用 key-based 文案。`npx eslint "src/hooks/useOemCloudAccess.ts" "src/hooks/useOemCloudAccess.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/hooks/useOemCloudAccess.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 3 个文件、13 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4024 个 source key;5 个 `common.json` 的 JSON 解析、`npm run typecheck` 与定向 `git diff --check` 通过。执行时隔壁 qcloop 仍在 `claw-chat-ready-streaming` P0 场景,本轮不触碰 QC、Agent runtime、Browser Runtime 或 GUI owner,也未启动 `npm run verify:local` / GUI smoke。 + +134. 2026-05-11 OEM Cloud Access 会话 / 邮箱验证码 / 密码登录 / Provider / API Key / 标签文案迁移续测:`src/hooks/useOemCloudAccess.ts` 继续复用 `useTranslation("common")`,把云端激活同步失败、会话过期 / 失效 / 恢复 / 刷新 / 退出反馈、邮箱验证码发送与登录校验、密码登录校验、云服务详情加载 / 默认服务商设置、API Key 默认名称 / 创建 / 轮换 / 撤销反馈,以及访问模式、配置模式、模型来源、开发者访问状态、默认服务来源等 label 迁移到 `common.oemCloudAccess.session.*`、`emailCode.*`、`password.*`、`provider.*`、`apiKey.*` 与 `label.*`;支付订单标题 fallback 迁移到 `common.oemCloudAccess.payment.planOrderTitle` / `creditTopupOrderTitle`,服务端错误消息、脱敏邮箱、分钟数、offer 名称、用户 / 租户 / provider / 模型运行时数据仍作为 runtime 数据或 i18next interpolation 保留。同步 5 个 `common.json` 资源,并在 `src/hooks/useOemCloudAccess.test.tsx` 扩展稳定 `react-i18next` mock,验证邮箱验证码发送提示与 API Key 创建成功提示来自 key-based 文案;`loadNamespace.test.ts` / `types.test.ts` 覆盖 session、emailCode、apiKey 与 label 代表 key。`npx eslint "src/hooks/useOemCloudAccess.ts" "src/hooks/useOemCloudAccess.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/hooks/useOemCloudAccess.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 3 个文件、14 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4070 个 source key;5 个 `common.json` 的 JSON 解析、`npm run typecheck` 与定向 `git diff --check` 通过。执行时未检测到并行 qcloop / GUI smoke owner,但用户已提示隔壁新开任务,本轮继续只触碰 OEM Cloud / common resources / i18n loader tests / PRD,不触碰 QC、Agent runtime、Browser Runtime、Skills 或 GUI owner,也未启动 `npm run verify:local` / GUI smoke。 + +135. 2026-05-11 全局 ConfirmDialog 默认 chrome 迁移续测:`src/components/ConfirmDialog.tsx` 接入 `useTranslation("common")`,把默认标题、确认按钮与取消按钮收口到 `common.confirmDialog.title` / `common.confirm` / `common.cancel`,显式传入的调用方文案仍保持调用方覆盖;同步 5 个 `common.json` 资源,并新增 `src/components/ConfirmDialog.test.tsx` 覆盖未传显式文案时的英文 common namespace 默认 chrome。`npm test -- "src/components/ConfirmDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、6 个用例;`npx eslint "src/components/ConfirmDialog.tsx" "src/components/ConfirmDialog.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `common.json` 的 `python3 -m json.tool` 解析、`npm run typecheck` 与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4071 个 source key。`npm run verify:local` / GUI smoke 未作为本条通过证据;本轮只触碰 ConfirmDialog / common resources / i18n loader tests / PRD,避开 QC、Agent runtime、Browser Runtime、Skills 与 GUI owner。 + +136. 2026-05-11 useProjects 重命名空名称错误迁移续测:`src/hooks/useProjects.ts` 接入 `useTranslation("common")`,把 `rename()` 空项目名抛错从硬编码中文迁移到 `common.projects.rename.nameRequired`;该错误会继续由调用方 toast / 表单错误链路展示,项目 ID、目录路径、DevBridge 错误与后端错误消息仍作为 runtime 数据保留。同步 5 个 `common.json` 资源,并在 `src/hooks/useProjects.test.tsx` 增加稳定 `react-i18next` mock,验证空名称重命名使用 key-based 错误且不会调用 `updateProject`;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `common.projects.rename.nameRequired`。`npx eslint "src/hooks/useProjects.ts" "src/hooks/useProjects.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/hooks/useProjects.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 3 个文件、8 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4072 个 source key;5 个 `common.json` 的 JSON 解析与 `npm run typecheck` 通过。执行时已发现隔壁记录 135 的 ConfirmDialog 迁移,本条接在其后编号为 136,只触碰 useProjects / common resources / i18n loader tests / PRD,不修改隔壁 ConfirmDialog 文件,也未启动 `npm run verify:local` / GUI smoke。 + +137. 2026-05-11 Workspace 视频 PromptInput chrome 迁移续测:`src/components/workspace/video/PromptInput.tsx` 接入 `useTranslation("workspace")` 与 `formatNumber()`,收口 `workspace.video.promptInput.*`,覆盖 Video Studio eyebrow、标题、提示词说明 aria / content、placeholder、快捷键 pill、生成 / 生成中按钮、模型 / 时长 / 参考图 / 纯文生视频 meta;同步 5 个 `workspace.json` 资源,运行时模型名、比例、分辨率与参考图状态仍作为 runtime 数据保留。`src/components/workspace/video/VideoTipsBehavior.test.tsx` 在 beforeEach 固定 `zh-CN` locale,并新增英文 locale 回归,验证英文标题、meta、placeholder、按钮与 hover tips 均来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.video.promptInput.title`。`npm test -- "src/components/workspace/video/VideoTipsBehavior.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、9 个用例;`npx eslint "src/components/workspace/video/PromptInput.tsx" "src/components/workspace/video/VideoTipsBehavior.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 `python3 -m json.tool` 解析、`npm run typecheck` 与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4087 个 source key。`npm run verify:local` / GUI smoke 未作为本条通过证据;本轮只触碰 Workspace 视频 PromptInput / workspace resources / i18n loader tests / PRD,不触碰 QC、Agent runtime、Browser Runtime、Skills 或 GUI owner。 + +138. 2026-05-11 OEM Lime Hub Provider Sync 托管 Key 名称迁移续测:`src/hooks/useOemLimeHubProviderSync.ts` 接入 `useTranslation("common")`,把本地托管 Lime Hub API Key alias 与云端 access token 名称迁移到 `common.oemLimeHubProviderSync.managedKeyAlias` / `cloudTokenName`;旧的 `Lime 云端模型` alias 继续作为 legacy fallback 参与托管 key 识别,避免 locale 切换后无法停用旧 key。同步 5 个 `common.json` 资源,并在 `src/hooks/useOemLimeHubProviderSync.test.tsx` 增加稳定 `react-i18next` mock,验证创建云端 token 与写入本地 API Key 时使用 key-based 名称,同时保留旧 alias 识别与停用旧 key 的回归;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `common.oemLimeHubProviderSync.managedKeyAlias`。`npx eslint "src/hooks/useOemLimeHubProviderSync.ts" "src/hooks/useOemLimeHubProviderSync.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/hooks/useOemLimeHubProviderSync.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 3 个文件、11 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4089 个 source key;5 个 `common.json` 的 JSON 解析、`npm run typecheck` 与定向 `git diff --check` 通过。执行时隔壁 `npm run verify:gui-smoke -- --reuse-running` 仍在跑,且已发现隔壁记录 137 的 Workspace 视频 PromptInput 迁移;本条接在其后编号为 138,只触碰 OEM Lime Hub provider sync / common resources / i18n loader tests / PRD,不修改隔壁 Workspace 视频文件,也未启动 `npm run verify:local` / GUI smoke。 + +139. 2026-05-11 OEM Cloud Login Launcher 浏览器预打开页文案迁移续测:`src/lib/oemCloudLoginLauncher.ts` 为 `createExternalBrowserOpenTarget()` 与 `openExternalUrl()` 增加 copy 注入边界,`src/hooks/useOemCloudAccess.ts` 从 `common.oemCloudAccess.auth.browserPreopenTitle` / `browserPreopenBody` 注入浏览器场景预打开空白页标题与正文,并把系统浏览器打开失败、带底层错误的失败提示、不支持外部浏览器与弹窗拦截错误收口到 `common.oemCloudAccess.auth.systemBrowserOpenFailed*` / `unsupportedExternalBrowser` / `popupBlocked`;未注入 copy 的非 UI 调用方继续保留默认中文 fallback。同步 5 个 `common.json` 资源,并在 `src/hooks/useOemCloudAccess.test.tsx` 验证浏览器场景预打开窗口使用 i18n 标题 / 正文,在 `src/lib/oemCloudLoginLauncher.test.ts` 覆盖 launcher helper 的预打开页 copy 注入、Tauri shell 打开失败 copy 注入与 popup blocked copy 注入;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `common.oemCloudAccess.auth.browserPreopenTitle` 与 `systemBrowserOpenFailedWithMessage`。`npx eslint "src/lib/oemCloudLoginLauncher.ts" "src/lib/oemCloudLoginLauncher.test.ts" "src/hooks/useOemCloudAccess.ts" "src/hooks/useOemCloudAccess.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/lib/oemCloudLoginLauncher.test.ts" "src/hooks/useOemCloudAccess.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 4 个文件、22 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4091 个 source key;5 个 `common.json` 的 JSON 解析、`npm run typecheck` 与定向 `git diff --check` 通过。执行时隔壁 GUI smoke / typecheck 进程仍有活动,本条只触碰 OEM Cloud Login Launcher / OEM Cloud Access / common resources / i18n loader tests / PRD,不触碰 QC、Agent runtime、Browser Runtime、Skills、Workspace 视频或 GUI owner,也未启动 `npm run verify:local` / GUI smoke。 + +140. 2026-05-11 Workspace 视频 VideoCanvas 插图反馈 / 侧栏按钮迁移续测:`src/components/workspace/video/VideoCanvas.tsx` 接入 `useTranslation("workspace")`,把画布插图成功 toast、ack `locationLabel` 与侧栏展开 / 收起按钮 `title` 收口到 `workspace.video.canvas.*`;插入图片 URL、project / content target、provider / model 运行时数据与内部 console 日志继续保留为 runtime 数据或开发诊断,不进入资源。同步 5 个 `workspace.json` 资源,并在 `VideoCanvas.test.tsx` 增加英文 locale 回归,验证侧栏按钮 title、toast 与 canvas image insert ack 均来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.video.canvas.sidebar.collapse`。`npm test -- "src/components/workspace/video/VideoCanvas.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、8 个用例;`npx eslint "src/components/workspace/video/VideoCanvas.tsx" "src/components/workspace/video/VideoCanvas.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 JSON 解析、`npm run typecheck` 与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4095 个 source key。执行时隔壁 `verify:gui-smoke` / `smoke:agent-service-skill-entry` / Rust 编译链路仍在活动且外部 GUI smoke 已超时,本条只触碰 Workspace 视频 VideoCanvas / workspace resources / i18n loader tests / PRD,不触碰 QC、Agent runtime、Browser Runtime、Skills、OEM 或 GUI owner,也未启动 `npm run verify:local` / GUI smoke。 + +141. 2026-05-11 Settings Experimental Update Check 子面板迁移续测:`src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx` 接入 `useTranslation("settings")`,把自动更新检查标题、自动检查 / 通知开关、检查间隔、已跳过版本、上次检查 / 稍后提醒时间、提醒转化指标与开发环境测试按钮收口到 `settings.experimental.updateCheck.*`;上次检查与稍后提醒时间改用 `formatDate()` 按当前 locale 格式化,指标计数与比例改用 `formatNumber()`,更新版本号、时间戳、指标计数与底层错误仍作为 runtime 数据保留。同步 5 个 `settings.json` 资源,并新增 `src/components/settings-v2/system/experimental/UpdateCheckSettings.test.tsx`,验证子面板文案来自 `settings` namespace、关闭自动检查会保存完整更新配置、开发环境测试按钮仍调用 `testUpdateWindow()`;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.experimental.updateCheck.title`。`npx eslint "src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx" "src/components/settings-v2/system/experimental/UpdateCheckSettings.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/system/experimental/UpdateCheckSettings.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 3 个文件、8 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4118 个 source key;5 个 `settings.json` 的 JSON 解析、`npm run typecheck` 与定向 `git diff --check` 通过。执行时隔壁 qcloop / P0 smoke / typecheck 仍有 active owner,本轮未启动 `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Chrome Relay 或 Workspace 视频。 + +142. 2026-05-11 Workspace 视频 VideoWorkspace 首屏 / 摘要卡迁移续测:`src/components/workspace/video/VideoWorkspace.tsx` 接入 `useTranslation("workspace")` 与 `formatNumber()`,把未生成态首屏 eyebrow / 标题 / 说明 tip,以及摘要卡 label、fallback value、hover hint 与 aria 收口到 `workspace.video.workspace.*`;模型名、provider id、比例、分辨率与项目选择状态仍作为 runtime / state 数据保留,不在资源中硬编码。同步 5 个 `workspace.json` 资源,并在 `src/components/workspace/video/VideoTipsBehavior.test.tsx` 增加英文 locale 回归,验证首屏、摘要卡与 hover tips 均来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.video.workspace.summary.currentModel.label`。`npm test -- "src/components/workspace/video/VideoTipsBehavior.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npx eslint "src/components/workspace/video/VideoWorkspace.tsx" "src/components/workspace/video/VideoTipsBehavior.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 JSON 解析、`npm run typecheck` 与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4141 个 source key。执行时隔壁 qcloop / P0 smoke 仍有 active owner,本条未启动 `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Skills、Settings 或 OEM 文件。 + +143. 2026-05-11 Settings General Appearance 主体迁移续测:`src/components/settings-v2/general/appearance/index.tsx` 继续接入 `useTranslation("settings")`,把外观页 hero、基础外观、主题模式、色彩方案、随机配色、界面语言 tip aria、提示音效、初始化与恢复、可选系统入口、推荐行为、状态 chip、错误提示与 aria 收口到 `settings.appearance.*`;主题 / 配色原始配置仍保留为运行时事实源,但当前 UI 通过本页 copy 映射到 key-based resources,语言选项原生显示名称与底层 `LIME_COLOR_SCHEMES` / `LIME_THEME_MODE_OPTIONS` 数据不在本条改写。同步 5 个 `settings.json` 资源,并在 `src/components/settings-v2/general/appearance/index.test.tsx` 增加英文 locale 回归,验证 Appearance chrome、主题 / 配色摘要、可选系统入口与推荐行为均来自 `settings` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.appearance.hero.title` 与 `settings.appearance.colorScheme.options.lime-classic.label`。`npx eslint "src/components/settings-v2/general/appearance/index.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/general/appearance/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 3 个文件、15 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4244 个 source key;5 个 `settings.json` 的 JSON 解析通过。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / typecheck 仍有 active owner,本条未启动 `npm run verify:local` / GUI smoke / typecheck,也不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 + +144. 2026-05-11 Settings Layout Sidebar 浮动导航 chrome 迁移续测:`src/components/settings-v2/_layout/SettingsSidebar.tsx` 接入 `useTranslation("settings")`,把侧栏实验 badge、窄屏浮动导航打开 aria 与无 active item 兜底标题收口到 `settings.layout.sidebar.*`;设置分组与 tab 名称继续由 `useSettingsCategory()` 的既有 `settings.group.*` / `settings.tab.*` 事实源提供,不在本条复制。同步 5 个 `settings.json` 资源,并新增 `src/components/settings-v2/_layout/SettingsSidebar.test.tsx`,验证实验 badge、浮动导航 aria 与兜底标题均来自 `settings` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.layout.sidebar.experimentalBadge`。`npx eslint "src/components/settings-v2/_layout/SettingsSidebar.tsx" "src/components/settings-v2/_layout/SettingsSidebar.test.tsx" "src/components/settings-v2/general/appearance/index.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/_layout/SettingsSidebar.test.tsx" "src/components/settings-v2/general/appearance/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过 4 个文件、17 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4247 个 source key;5 个 `settings.json` 的 JSON 解析通过。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / typecheck 仍有 active owner,本条未启动 `npm run verify:local` / GUI smoke / typecheck,也不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 + +145. 2026-05-11 Workspace 视频 VideoWorkspace 最近任务区迁移续测:`src/components/workspace/video/VideoWorkspace.tsx` 继续复用 `useTranslation("workspace")` 与 `formatNumber()`,把最近任务标题、说明 tip、任务数量、空态,以及结果预览按钮 `当前预览` / `切换预览` 收口到 `workspace.video.workspace.recentTasks.*`;任务 id、provider / model、prompt、状态与进度仍作为 runtime 数据保留。同步 5 个 `workspace.json` 资源,并在 `src/components/workspace/video/VideoWorkspaceSelection.test.tsx` 增加英文 locale 回归,验证最近任务标题、数量与预览按钮来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.video.workspace.recentTasks.title`。`npm test -- "src/components/workspace/video/VideoWorkspaceSelection.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/components/workspace/video/VideoWorkspace.tsx" "src/components/workspace/video/VideoWorkspaceSelection.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4247 个 source key。`npm run typecheck` 已尝试,但被隔壁 Settings Appearance 并行脏改阻塞:`src/components/settings-v2/general/appearance/index.tsx(1005,14)` 的 `HIDDEN_SYSTEM_ENTRY_OPTIONS` 未定义与 `option` implicit any;该失败不来自本 Workspace 视频切片。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / Rust 编译链路仍有 active owner,本条未启动 `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Settings、Chrome Relay 或 Agent Skills。 + +146. 2026-05-11 Workspace 视频 VideoWorkspace 任务状态 / 同步提示迁移续测:`src/components/workspace/video/VideoWorkspace.tsx` 继续复用 `useTranslation("workspace")` 与 `formatNumber()`,把最近任务状态 badge、最近任务同步提示、聚焦任务结果同步 label / hint 收口到 `workspace.video.workspace.taskStatus.*` 与 `workspace.video.workspace.taskSync.*`;任务 id、provider / model、prompt、底层错误、素材 id 与进度数值仍作为 runtime 数据或 interpolation 保留。同步 5 个 `workspace.json` 资源,并在 `src/components/workspace/video/VideoWorkspaceSelection.test.tsx` 扩展英文 locale 回归,验证任务状态与同步提示来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.video.workspace.taskStatus.success` 与 `workspace.video.workspace.taskSync.saved.label`。`npm test -- "src/components/workspace/video/VideoWorkspaceSelection.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/components/workspace/video/VideoWorkspace.tsx" "src/components/workspace/video/VideoWorkspaceSelection.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4272 个 source key。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / `npm run typecheck` / Rust 编译链路仍有 active owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Settings、Chrome Relay 或 Agent Skills。 + +147. 2026-05-11 Settings Appearance / Sidebar 与当前 i18n dirty tree typecheck 补证:在记录 145 / 146 之后,基于当前工作区最新 Settings Appearance、Settings Layout Sidebar、Workspace 视频与 i18n resources 脏改状态执行 `nice -n 10 npm run typecheck`,结果通过,已清除记录 145 中由并行 Settings Appearance 中间态引出的 `HIDDEN_SYSTEM_ENTRY_OPTIONS` 未定义与 `option` implicit any 阻断;本条只补类型证据,不新增产品文案,不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频实现、Chrome Relay 或 Agent Skills。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` 仍有 active owner,因此未启动 `npm run verify:local` / GUI smoke。 + +148. 2026-05-11 Workspace 视频 VideoWorkspace 会话主预览迁移续测:`src/components/workspace/video/VideoWorkspace.tsx` 继续复用 `useTranslation("workspace")` 与 `formatNumber()`,把生成后会话 header、状态 detail、主预览标题 / 失败 / 生成中 / 等待空态、同步规则 tip 与任务更新时间收口到 `workspace.video.workspace.session.*`;视频 URL、任务时间、错误消息、模型名、分辨率与进度数值仍作为 runtime 数据或 interpolation 保留。同步 5 个 `workspace.json` 资源,并在 `src/components/workspace/video/VideoWorkspaceSelection.test.tsx` 扩展英文 locale 回归,验证 session 标题、主预览标题、历史结果说明与同步规则来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.video.workspace.session.title` 与 `workspace.video.workspace.session.preview.title`。`npm test -- "src/components/workspace/video/VideoWorkspaceSelection.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/components/workspace/video/VideoWorkspace.tsx" "src/components/workspace/video/VideoWorkspaceSelection.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4300 个 source key。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / Rust 编译链路仍有 active owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Settings、Chrome Relay 或 Agent Skills。 + + +149. 2026-05-11 Settings Home 分组说明 aria 迁移续测:`src/components/settings-v2/home/index.tsx` 把设置首页分组卡片 `WorkbenchInfoTip` 的 `ariaLabel` 从中文模板 ``${group.title}说明`` 收口到 `settings.home.group.tipAria`,分组标题仍由 `useSettingsCategory()` 的既有 `settings.group.*` / `settings.tab.*` 事实源提供,分组说明继续复用 `settings.home.group.*.description`,不复制目录命名。同步 5 个 `settings.json` 资源,并在 `src/components/settings-v2/home/index.test.tsx` 增加英文资源回归,验证分组说明 aria 来自 `settings` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.home.group.tipAria`。`npx eslint "src/components/settings-v2/home/index.tsx" "src/components/settings-v2/home/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/home/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、12 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4301 个 source key;5 个 `settings.json` 的 JSON 解析与定向 `git diff --check` 通过。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / `npm run typecheck` / Rust 编译链路仍有 active owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 + +150. 2026-05-11 Settings Stats 热力图日期范围迁移续测:`src/components/settings-v2/account/stats/index.tsx` 把活跃度日历覆盖范围值从固定 `{{start}} - {{end}}` 字符串拼接收口到 `settings.stats.heatmap.rangeValue`,开始 / 结束日期继续通过 `formatDate()` 按当前 locale 格式化,统计数值、Token、模型名与原始日期仍作为 runtime 数据或 interpolation 保留。同步 5 个 `settings.json` 资源,并在 `src/components/settings-v2/account/stats/index.test.tsx` 增加英文资源回归,验证热力图日期范围模板来自 `settings` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `settings.stats.heatmap.rangeValue`。`npx eslint "src/components/settings-v2/account/stats/index.tsx" "src/components/settings-v2/account/stats/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/account/stats/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、9 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4302 个 source key;5 个 `settings.json` 的 JSON 解析与定向 `git diff --check` 通过。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / 两个 `npm run typecheck` / Rust 编译链路仍有 active owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 + +151. 2026-05-11 Settings Stats 紧凑数字格式迁移续测:`src/components/settings-v2/account/stats/index.tsx` 把统计页 `formatCompactNumber()` 从手写 `K/M` 拼接迁移到 `formatNumber({ notation: "compact" })`,并把统计摘要、区段卡、模型排行、趋势坐标 / tooltip 与热力图 tooltip 的 Token 数值全部按当前 `i18n.language` 格式化;模型名、Token 单位、百分比与原始日期仍作为 runtime 数据或既有资源插值保留,不新增静态文案。`src/components/settings-v2/account/stats/index.test.tsx` 扩展英文 locale 回归,验证 `18.0K` 旧手写格式不再出现且使用 `Intl` 紧凑格式 `18K`。`npx eslint "src/components/settings-v2/account/stats/index.tsx" "src/components/settings-v2/account/stats/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/account/stats/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、9 个用例;`npm run detect-translations -- --verbose` 通过,资源口径保持 5 个 locale、6 个 namespace、4302 个 source key;5 个 `settings.json` 的 JSON 解析与定向 `git diff --check` 通过。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / 两个 `npm run typecheck` / Rust 编译链路仍有 active owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 + +152. 2026-05-11 Workspace 视频 VideoWorkspace 聚焦任务面板迁移续测:`src/components/workspace/video/VideoWorkspace.tsx` 继续复用 `useTranslation("workspace")`、`formatNumber()` 与 `formatDate()`,把聚焦任务标题、prompt fallback、模型链路 / 生成规格 / 结果同步 / 任务时间 label、最新 / 历史 badge、来源说明、规格说明、时间线文案与 provider / model fallback 收口到 `workspace.video.workspace.focusedTask.*`;任务 prompt、provider / model、任务 id、时间戳、比例、分辨率、时长与同步状态仍作为 runtime 数据或 interpolation 保留。同步 5 个 `workspace.json` 资源,并在 `src/components/workspace/video/VideoWorkspaceSelection.test.tsx` 扩展英文 locale 回归,验证聚焦任务标题、历史 badge、模型链路、生成规格与英文时长单位来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.video.workspace.focusedTask.title` 与 `workspace.video.workspace.focusedTask.source.label`。`npm test -- "src/components/workspace/video/VideoWorkspaceSelection.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npx eslint "src/components/workspace/video/VideoWorkspace.tsx" "src/components/workspace/video/VideoWorkspaceSelection.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4320 个 source key。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / 两个 `npm run typecheck` / Rust 编译链路仍有 active owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Settings、Chrome Relay 或 Agent Skills。 + +153. 2026-05-11 Settings Profile 资料统计数字格式迁移续测:`src/components/settings-v2/account/profile/index.tsx` 接入 `formatNumber()`,把资料完成度、标签数量、额外标签数量、基础资料项数与头像文件大小限制提示中的数字按当前 `i18n.language` 格式化;昵称、邮箱、标签文本、头像文件大小原始值和错误对象仍作为 runtime 数据或 interpolation 保留,不新增静态文案。`src/components/settings-v2/account/profile/index.test.tsx` 增加英文 locale 回归,验证标签统计从裸 `1000` 变为 locale 数字 `1,000`;既有资料摘要、编辑保存与 Tips 回归继续覆盖。`npx eslint "src/components/settings-v2/account/profile/index.tsx" "src/components/settings-v2/account/profile/index.test.tsx" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/account/profile/index.test.tsx"` 通过,覆盖 1 个文件、6 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4320 个 source key;定向 `git diff --check` 通过。曾尝试 `nice -n 10 npm run typecheck` 补证,但该进程无诊断输出并以 code -1 结束,未作为通过证据;执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / Rust 编译链路仍有 active owner,本条未启动 `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 + +154. 2026-05-11 Settings User Center Session 会话时间 / 能力数量迁移续测:`src/components/settings-v2/account/user-center-session/index.tsx` 不再依赖 `formatOemCloudDateTime()` 的固定 `zh-CN` 输出,改用 `formatDate()` 按当前 `i18n.language` 格式化会话有效期,并把已同步技能数 / 入口数改用 `formatNumber()` 后再进入 `settings.userCenterSession.account.syncedCapabilities` 插值;provider、账号、邮箱、用户 id、默认服务与原始过期时间仍作为 runtime 数据保留。同步 5 个 `settings.json` 的 `settings.userCenterSession.value.expiresAt.unknown` 兜底文案,并在 `src/components/settings-v2/account/user-center-session/index.test.tsx` 增加英文 locale 回归,验证会话时间和 `1,000 / 1,234` 能力数量按 locale 格式化;`loadNamespace.test.ts` / `types.test.ts` 覆盖新 key。`npx eslint "src/components/settings-v2/account/user-center-session/index.tsx" "src/components/settings-v2/account/user-center-session/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/account/user-center-session/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、11 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4337 个 source key;5 个 `settings.json` 的 JSON 解析与定向 `git diff --check` 通过。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / Rust 编译链路仍有 active owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 + +155. 2026-05-11 Workspace 视频 VideoWorkspace 生成入口反馈 / 参考图处理迁移续测:`src/components/workspace/video/VideoWorkspace.tsx` 继续复用 `useTranslation("workspace")`,把生成入口 toast、项目 / provider / model / prompt / unsupported provider 校验反馈、任务提交成功反馈、参考图格式 / 未选项目错误、首帧 / 尾帧参考图素材名称与自动上传描述,以及视频结果自动入库名称 fallback / 描述收口到 `workspace.video.workspace.generate.*`、`workspace.video.workspace.reference.*`、`workspace.video.workspace.resource.*` 与 `workspace.video.workspace.state.*`;prompt、provider / model、素材 URL、任务 id、任务时间与底层异常 message 继续作为 runtime 数据或 interpolation 保留。同步 5 个 `workspace.json` 资源,并在 `src/components/workspace/video/VideoWorkspaceSelection.test.tsx` 增加英文 locale 回归,验证生成按钮触发后的参考图上传 request、`createTask` 入参与成功 toast 均来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.video.workspace.generate.submitted` 与 `workspace.video.workspace.reference.unsupportedFormat`。`npm test -- "src/components/workspace/video/VideoWorkspaceSelection.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、8 个用例;`npx eslint "src/components/workspace/video/VideoWorkspace.tsx" "src/components/workspace/video/VideoWorkspaceSelection.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4337 个 source key。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / Rust 编译链路仍有 active owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Settings、Chrome Relay 或 Agent Skills。 + +156. 2026-05-11 Settings Hotkeys 未设置快捷键 token 迁移续测:`src/lib/hotkeys/platform.ts` 新增 `UNSET_SHORTCUT_TOKEN`,让 `formatShortcutTokens()` 在缺失快捷键时返回内部 sentinel,不再把中文 `未设置` 作为数据 token;`src/components/settings-v2/general/hotkeys/index.tsx` 改为比较 sentinel 并继续通过 `settings.hotkeys.key.unset` 显示本地化文案,避免 UI 逻辑依赖中文字符串。同步 `platform.test.ts` 与 `HotkeysSettings` 回归,验证 raw sentinel 不会泄露到页面且 `未设置` 仍由 i18n copy 展示。`npx eslint "src/components/settings-v2/general/hotkeys/index.tsx" "src/components/settings-v2/general/hotkeys/index.test.tsx" "src/lib/hotkeys/platform.ts" "src/lib/hotkeys/platform.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/settings-v2/general/hotkeys/index.test.tsx" "src/lib/hotkeys/platform.test.ts"` 通过,覆盖 2 个文件、9 个用例;`npm run detect-translations -- --verbose` 通过,资源口径保持 5 个 locale、6 个 namespace、4337 个 source key;定向 `git diff --check` 通过。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / Rust 编译链路仍有 active owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 + +157. 2026-05-11 Workspace 视频 VideoSidebar 参数栏概览 / 创作 Tips 迁移续测:`src/components/workspace/video/VideoSidebar.tsx` 接入 `useTranslation("workspace")` 与 `formatNumber()`,把侧栏顶部 `VIDEO CONTROL`、生成参数标题 / 说明、当前模型 / 输出规格 / 时长 / 参考图摘要,以及创作 Tips 两个 pill 的 aria、label 与说明收口到 `workspace.video.sidebar.*`;模型名、比例、分辨率、时长与参考图数量继续作为 runtime 数据或 interpolation 保留。同步 5 个 `workspace.json` 资源,并在 `src/components/workspace/video/VideoTipsBehavior.test.tsx` 增加英文 locale 回归,验证参数栏概览与创作 Tips 来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.video.sidebar.intro.title` 与 `workspace.video.sidebar.helper.parameterPace.content`。`npm test -- "src/components/workspace/video/VideoTipsBehavior.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、11 个用例;`npx eslint "src/components/workspace/video/VideoSidebar.tsx" "src/components/workspace/video/VideoTipsBehavior.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4356 个 source key。执行时隔壁 qcloop / P0 smoke / `verify:gui-smoke` / Rust 编译链路仍有 active owner,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Settings、Chrome Relay 或 Agent Skills。 + +158. 2026-05-11 UpdateNotification 独立更新提醒窗口迁移续测:`src/pages/update-notification.tsx` 接入 `useTranslation("common")`,把新版本提示、当前版本插值、1 / 3 天后与下周提醒按钮、关闭 / 跳过 / 浏览器发布页 icon button title / aria,以及立即更新 / 下载中状态收口到 `common.updateNotification.*`;版本号、下载 URL 与操作小时数继续作为 runtime 数据或 interpolation 保留,不再依赖 legacy DOM Patch 覆盖该独立窗口。同步 5 个 `common.json` 资源,并新增 `src/pages/update-notification.test.tsx` 英文 locale 回归,验证更新提醒窗口、icon aria 与下载中状态来自 `common` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `common.updateNotification.version.new`。`npx eslint "src/pages/update-notification.tsx" "src/pages/update-notification.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/pages/update-notification.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4366 个 source key;5 个 `common.json` 的 JSON 解析与定向 `git diff --check` 通过。执行时隔壁 qcloop / `verify:gui-smoke` / `smoke:agent-service-skill-entry` / 其他 Vitest owner 仍在运行,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 + +159. 2026-05-11 Workspace 视频 VideoSidebar 参考图上传区迁移续测:`src/components/workspace/video/VideoSidebar.tsx` 继续复用 `useTranslation("workspace")`,把起始 / 结束画面标题、说明 aria / content、预览 alt、替换按钮、拖拽替换提示,以及空态添加图片 / 拖拽上传 / 选择图片文案收口到 `workspace.video.sidebar.reference.*`;参考图 data URL、文件对象与拖拽区域状态继续作为 runtime 数据保留。同步 5 个 `workspace.json` 资源,并在 `src/components/workspace/video/VideoTipsBehavior.test.tsx` 扩展英文 locale 回归,覆盖 Start / End frame、预览 alt、替换提示、空态上传与两处 hover tip;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.video.sidebar.reference.start.title` 与 `workspace.video.sidebar.reference.empty.action`。`npx eslint "src/components/workspace/video/VideoSidebar.tsx" "src/components/workspace/video/VideoTipsBehavior.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4379 个 source key。执行时隔壁 qcloop 正在运行 P0 GUI smoke、Agent Chat vitest 与 Rust capability draft 定向测试,本条未启动 `npm test` / `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Settings、Chrome Relay 或 Agent Skills;新增回归需在 owner 空闲后补跑。 + +160. 2026-05-11 SmartInput 独立悬浮输入窗 chrome / 语音反馈迁移续测:`src/pages/smart-input.tsx` 接入 `useTranslation("common")`,把拖动手柄 title、识别中 / 润色中 / 录音中状态、图片附件 label / 移除 aria、输入 placeholder、语音输入 / 停止录音 / 关闭 / 发送按钮 title 与 aria,以及语音模型缺失、麦克风权限、开始 / 停止录音失败、语音识别失败和发送失败兜底提示收口到 `common.smartInput.*`;用户输入、图片路径、实时识别文本、底层 readiness.message / 错误 message、录音时长和 companion-pet 目标仍作为 runtime 数据保留。同步 5 个 `common.json` 资源,并在 `src/pages/smart-input.test.tsx` 增加英文 locale 回归,验证悬浮窗 chrome 来自 `common` namespace,同时保留既有语音录制 / 实时识别 / 缺模型回归;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `common.smartInput.status.recording`。`npx eslint "src/pages/smart-input.tsx" "src/pages/smart-input.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/pages/smart-input.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4396 个 source key;5 个 `common.json` 的 JSON 解析与定向 `git diff --check` 通过。执行时隔壁 qcloop / `verify:gui-smoke` / `smoke:claw-chat-ready-streaming` / qcloop worker 仍在运行,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 + +161. 2026-05-11 Workspace 视频 VideoSidebar 基础控制区迁移续测:`src/components/workspace/video/VideoSidebar.tsx` 继续复用 `useTranslation("workspace")`,把宽高比 / 自适应比例、分辨率、时长标题与说明、种子标题 / placeholder / 随机种子 title,以及生成音频 / 固定镜头开关标题与 hover 说明收口到 `workspace.video.sidebar.controls.*`;比例值、分辨率值、时长数值、随机种子数值和开关状态继续作为 runtime 数据保留。同步 5 个 `workspace.json` 资源,并在 `src/components/workspace/video/VideoTipsBehavior.test.tsx` 扩展英文 locale 回归,覆盖 Aspect ratio / Adaptive / Resolution / Seed / Random seed / Generate audio / Fixed camera 以及四处控制区 hover tip;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.video.sidebar.controls.seed.placeholder` 与 `workspace.video.sidebar.controls.cameraFixed.tipContent`。`npm test -- "src/components/workspace/video/VideoTipsBehavior.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、11 个用例;`npx eslint "src/components/workspace/video/VideoSidebar.tsx" "src/components/workspace/video/VideoTipsBehavior.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4413 个 source key。执行时隔壁 qcloop / tauri dev / vite / claw-chat-ready-streaming worker 仍在运行,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Settings、Chrome Relay 或 Agent Skills。 + +162. 2026-05-11 OpenClaw Dashboard 访问页迁移记录已归档:该页面及其 `common.openClawDashboard.*` 资源曾完成 i18n 收口与定向回归,但 OpenClaw 模块后续已整体判定为 `dead`,相关前端页面、测试、资源键与命令面已从 current 主链删除。本条仅保留为历史迁移记录,不再代表当前 i18n 范围、current 页面入口或后续补齐目标。 + +163. 2026-05-11 Workspace 视频 VideoSidebar 模型面板迁移续测:`src/components/workspace/video/VideoSidebar.tsx` 继续复用 `useTranslation("workspace")`,把模型区标题 / hover 说明、模型触发器 title、空态模型 / Provider 提示、模型库面板 eyebrow / 标题 / 说明、空态卡片,以及内置模型成本与能力描述收口到 `workspace.video.sidebar.model.*`;provider id / name、model id / label、当前选择状态和面板开关状态继续作为 runtime 数据保留,模型能力与成本为内置 presentation copy,不再以中文硬编码留在组件逻辑中。同步 5 个 `workspace.json` 资源,并在 `src/components/workspace/video/VideoTipsBehavior.test.tsx` 扩展英文 locale 回归,覆盖 Model guidance、Choose video model、MODEL LIBRARY、Choose a video model、Sora-2 Pro 成本与能力描述;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.video.sidebar.model.panel.title` 与 `workspace.video.sidebar.model.meta.sora2Pro.description`。`npm test -- "src/components/workspace/video/VideoTipsBehavior.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、12 个用例;`npx eslint "src/components/workspace/video/VideoSidebar.tsx" "src/components/workspace/video/VideoTipsBehavior.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4477 个 source key。执行时隔壁 qcloop / tauri dev / vite / tool-approval-sandbox-boundary worker 仍在运行,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke,也不触碰 QC、Agent runtime、Browser Runtime、Settings、Chrome Relay、OpenClaw 或 Agent Skills。 + +164. 2026-05-11 LatestRunStatusBadge 执行状态徽章迁移续测:`src/components/execution/LatestRunStatusBadge.tsx` 接入 `useTranslation("common")` 与 `formatDate()`,把默认 label、无记录空态、执行状态 queued / running / success / error / canceled / timeout、时间标签,以及原先固定 `zh-CN` 的执行时间格式收口到 `common.execution.latestRunStatus.*` 与 locale-aware formatter;`source`、外部传入 label、run id / source_ref / session_id、运行状态枚举和 started_at 时间戳继续作为 runtime 数据保留。同步 5 个 `common.json` 资源,并新增 `src/components/execution/LatestRunStatusBadge.test.tsx` 英文 locale 回归,验证 Latest run / Success / Time / No records yet 来自 `common` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `common.execution.latestRunStatus.status.success`。`npx eslint "src/components/execution/LatestRunStatusBadge.tsx" "src/components/execution/LatestRunStatusBadge.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0` 通过;`npm test -- "src/components/execution/LatestRunStatusBadge.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、7 个用例;`npm run detect-translations -- --verbose` 通过,最新资源口径为 5 个 locale、6 个 namespace、4497 个 source key;5 个 `common.json` 的 JSON 解析与定向 `git diff --check` 通过。执行时隔壁 qcloop / tauri dev / vite / tool-approval-sandbox-boundary worker 仍在运行,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;按用户要求本轮完全避开 OpenClaw 清理面,也不触碰 QC、Agent runtime、Browser Runtime、Workspace 视频、Chrome Relay 或 Agent Skills。 + +165. 2026-05-11 Workspace Runtime AGENTS 引导 toast 迁移续测:`src/components/workspace/services/runtimeAgentsGuideService.ts` 使用当前 `i18next` 实例读取 `workspace.runtimeAgentsGuide.*`,把项目创建后的运行时 AGENTS 初始化建议、初始化按钮、共享 / 本机模板状态、`.gitignore` 状态、初始化成功 / 失败 toast 与失败说明收口到 `workspace` namespace;项目成功消息、project id / rootPath、模板创建状态和底层异常仍作为调用方或 runtime 数据保留。同步 5 个 `workspace.json` 资源,并在 `src/components/workspace/services/runtimeAgentsGuideService.test.ts` 增加英文 locale 回归,验证引导说明、Initialize now、Runtime AGENTS templates initialized 与 Shared template created 来自 `workspace` namespace;`loadNamespace.test.ts` / `types.test.ts` 覆盖 `workspace.runtimeAgentsGuide.action.initialize` 与 `workspace.runtimeAgentsGuide.initialized.title`。`npm test -- "src/components/workspace/services/runtimeAgentsGuideService.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、10 个用例;`npx eslint "src/components/workspace/services/runtimeAgentsGuideService.ts" "src/components/workspace/services/runtimeAgentsGuideService.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts" --max-warnings 0`、`npm run detect-translations -- --verbose`、5 个 `workspace.json` 的 JSON 解析与定向 `git diff --check` 通过,最新资源口径为 5 个 locale、6 个 namespace、4497 个 source key。执行时隔壁 qcloop / tauri dev / vite / skill-forge-register-bind-enable / Rust cargo test 仍在运行,本条未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke;按用户要求本轮避开 OpenClaw 删除面、QC、Agent runtime、Browser Runtime、Chrome Relay 与 Agent Skills。 + +166. 2026-05-11 Settings Web Search 静态标签与 Provider 展示文案补齐续测:`src/components/settings-v2/system/web-search/index.tsx` 继续复用 `useTranslation("settings")`,把搜索引擎下拉项、Provider 下拉项、Provider / 图片 Key 字段 label、MSE 面板标题、Provider fallback placeholder、自定义模板 placeholder,以及观测面板 / 状态 pill 中的静态 Provider 名称统一收口到 `settings.webSearch.*` key;`provider id`、配置值、环境变量名、URL 模板与回退链顺序仍作为 runtime 数据或 placeholder 示例保留。同步 5 个 `settings.json` 资源,并在 `src/i18n/__tests__/loadNamespace.test.ts` / `src/i18n/__tests__/types.test.ts` 增加 `settings.webSearch.providers.googleEngine.label` 与 `settings.webSearch.mse.customTemplate.placeholder` 覆盖,验证新增 key 已进入 bundled resource 与类型绑定。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、4507 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/web-search/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 3 个文件、18 个用例;5 个 `settings.json` 的 JSON 解析通过。本条不触碰搜索 provider / app config 命令边界,也未启动 `npm run typecheck` / `npm run verify:local` / GUI smoke。 + +167. 2026-05-11 Settings Automation helper / presentation 本地化续测:`src/components/settings-v2/system/automation/serviceSkillContext.ts` 与 `automationPresentation.ts` 已改为支持调用方注入本地化 copy,不再把技能流程标题、runner/source/execution location、slot fallback、旧浏览器流程下线提示、调度摘要、状态前缀、执行模式、投递模式与输出格式等静态展示文案继续散落在 helper 内;`src/components/settings-v2/system/automation/index.tsx` 与 `AutomationJobDetailsDialog.tsx` 负责基于 `useTranslation("settings")` 组装 copy,并把 service skill compat badge / note、运行历史里的 service skill metadata fallback、列表 / 详情 / 历史中的 helper 展示文本统一切回 `settings.automation.*` 事实源,同时把 Automation 时间展示改为 locale-aware 格式。同步 5 个 `settings.json` 资源,新增 `settings.automation.tasks.list.badge.serviceSkillLegacyCompat`、`settings.automation.details.serviceSkill.executionCompatNote`、`settings.automation.serviceSkill.*` 与 `settings.automation.details.statusDetail.*` 等 key,并在 `src/i18n/__tests__/loadNamespace.test.ts` / `src/i18n/__tests__/types.test.ts` 增加 bundled resource 与类型绑定覆盖。`npm run detect-translations -- --verbose` 通过,确认 5 个 locale、6 个 namespace、4521 个 source key 结构一致;`npm test -- "src/components/settings-v2/system/automation/index.test.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx" "src/components/settings-v2/system/automation/serviceSkillContext.test.ts" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、29 个用例;定向 `npx eslint` 检查 `serviceSkillContext.ts`、`automationPresentation.ts`、`index.tsx` 与 `AutomationJobDetailsDialog.tsx` 未报错。本条不改命令边界、调度契约或 GUI 主路径行为,也未启动 `npm run verify:local` / GUI smoke。 + +168. 2026-05-11 Settings Automation access mode helper 续测:`src/components/settings-v2/system/automation/automationAccessMode.ts` 已改为支持 `AutomationAccessModeCopy` 注入,不再把“只读 / 按需确认 / 完全访问”与正式策略说明硬编码为 helper 唯一事实源;`AutomationJobDialog.tsx` 改为在组件内基于 `settings.automation.jobDialog.accessMode.*` 构建 option copy,避免下拉项继续依赖全局中文常量;`automationPresentation.ts` 与 `AutomationJobDetailsDialog.tsx` 也统一复用 access mode copy 输出展示 label,使权限模式在列表、详情和 helper 调用面都回到 `settings` namespace。`npm test -- "src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx" "src/components/settings-v2/system/automation/index.test.tsx" "src/i18n/__tests__/loadNamespace.test.ts" "src/i18n/__tests__/types.test.ts"` 通过,覆盖 5 个文件、32 个用例;定向 `npx eslint` 检查 `automationAccessMode.ts`、`AutomationJobDialog.tsx`、`automationPresentation.ts` 与 `AutomationJobDetailsDialog.tsx` 未报错。本条未新增资源 key,也未启动 `npm run verify:local` / GUI smoke。 仍未完成: -1. `navigation` namespace 已覆盖侧边栏主入口、搜索弹窗、会话列表起步路径、会话 meta 格式化、会话重命名 / 删除的 prompt / confirm / toast,以及账号菜单的核心未登录 / 已登录操作文案;但邀请入口 / 邀请弹窗、外观快捷面板、账号套餐 usage 兜底、复制邀请 toast 等侧栏周边文案仍有硬编码;`workspace`、`agent` namespace 还只是骨架,`errors` namespace 已覆盖 Crash Recovery 外壳与 crashDiagnostic 共享诊断摘要 / 复制失败 / 下载目录 fallback;Workspace / Agent Chat 仍需 P1/P2 逐步迁移;Settings 共享壳层、home、developer-lab、system/developer 壳层与目录联调懒加载工具、system/shared Clipboard 权限指引、CrashRecoveryPanel 恢复模式外壳、system/environment 主体、system/experimental 主体、system/channels 工作台壳层、网关与隧道表单、Telegram / Feishu / 微信运行控制、微信兼容扫码排障与日志 Tail 面板、system/web-search 主体、system/chrome-relay 独立连接引导窗口 / 主设置页核心首屏 / 高级工具起步区 / 高级工具连接方式卡 / 高级工具系统连接器卡 / 高级工具浏览器动作配置面板 / 高级工具后端策略详情面板 / 高级工具扩展桥接详情面板、system/shared Workspace 自愈记录共享卡片、system/automation 当前焦点条 / 概览焦点卡 / HealthPanel / 主页面壳层与调度器设置 / 主页面开始模板与任务列表 / 运行历史区、general/memory 首屏与偏好画像 / 来源链状态与策略长尾 / memdir 写入与自动索引长尾、general/hotkeys、account/profile/stats/user-center-session、appearance/about、agent/skills 高级入口、agent/image-gen、agent/video-gen、agent/media-services、agent/voice、agent/providers 的顶部切换器 / 云端反馈 / 左侧启用模型列表 / 导入导出弹窗 / 详情配置外壳入口态 / 桌宠能力偏好卡 / Companion Bridge 诊断区已完成起步迁移,但 Providers 添加流程 / 详情配置主体等页内残留硬编码、CuratedTask 模板定义与 Agent Chat 输入能力区深迁移、能力草稿面板内部文案、启动弹窗模板字段 / runtime suggestion 与模型执行提示正文长尾仍需继续。 +1. `common` namespace 已覆盖 App 根壳层、UpdateNotification 独立更新提醒窗口、SmartInput 独立悬浮输入窗、LatestRunStatusBadge 执行状态徽章、项目 / 工作区切换器、新建项目弹窗、useProjects 重命名空名称错误、Deep Link 邀请 / 登录 / 支付 toast、App Startup Windows 启动自检 toast,以及 OEM Cloud Access 登录 / OAuth / 支付反馈 / 会话刷新退出 / 邮箱验证码 / 密码登录 / Provider 默认服务 / API Key / 状态标签 / 浏览器预打开页与外部浏览器错误反馈,以及 OEM Lime Hub provider sync 托管 Key 名称;`navigation` namespace 已覆盖侧边栏主入口、搜索弹窗、会话列表起步路径、会话 meta 格式化、会话重命名 / 删除的 prompt / confirm / toast、账号菜单核心未登录 / 已登录操作文案、邀请入口 / 邀请弹窗 / 复制邀请 toast,以及外观快捷面板;账号套餐 usage 已确认属于云端运行时字段边界,不再作为侧栏静态 i18n 缺口;`workspace` namespace 已覆盖图片素材 `ImageGallery`、Runtime AGENTS 引导 toast、视频 PromptInput、视频 VideoCanvas 插图反馈 / 侧栏按钮、视频 VideoSidebar 参数栏概览 / 创作 Tips / 参考图上传区 / 基础控制区 / 模型面板、视频 VideoWorkspace 首屏 / 摘要卡 / 最近任务标题 / 预览操作 / 任务状态 / 同步提示 / 会话主预览 / 聚焦任务面板 / 生成入口反馈 / 参考图处理、Browser Runtime 工作台外壳、环境预设子面板、资料管理子面板、Debug 面板外壳 / 状态 / 最小控制起步路径、Debug 审计与附着 Chrome 子面板、Debug CDP 托管会话高级区、existing session hook feedback / presentation 与 Site Adapter 面板 chrome / 执行反馈,`agent` namespace 已覆盖 Skills / CuratedTask / 能力草案等起步路径,`errors` namespace 已覆盖 Crash Recovery 外壳与 crashDiagnostic 共享诊断摘要 / 复制失败 / 下载目录 fallback;Workspace 其他媒体 / Browser Runtime 其他子面板 / 工作台路径长尾与 Agent Chat 更深路径仍需 P1/P2 逐步迁移;Settings 共享壳层(侧边栏浮动导航 chrome 已在记录 144 收口)、home、developer-lab、system/developer 壳层与目录联调懒加载工具、system/shared Clipboard 权限指引、CrashRecoveryPanel 恢复模式外壳、system/environment 主体、system/experimental 主体(更新检查子面板已收口)、system/channels 工作台壳层、网关与隧道表单、Telegram / Feishu / 微信运行控制、微信兼容扫码排障与日志 Tail 面板、system/chrome-relay 独立连接引导窗口 / 主设置页核心首屏 / 高级工具起步区 / 高级工具连接方式卡 / 高级工具系统连接器卡 / 浏览器动作配置面板 / 后端策略详情面板 / 扩展桥接详情面板、system/shared Workspace 自愈记录共享卡片、system/automation 当前焦点条 / 概览焦点卡 / HealthPanel / 主页面壳层与调度器设置 / 开始模板与任务列表 / 运行历史区、general/memory 首屏与偏好画像 / 来源链状态与策略长尾 / memdir 写入与自动索引长尾、general/hotkeys、account/profile/stats/user-center-session、about(appearance 主体已在记录 143 收口)、agent/skills 高级入口、agent/image-gen、agent/video-gen、agent/media-services、agent/voice、agent/providers 的顶部切换器 / 云端反馈 / 左侧启用模型列表 / 导入导出弹窗 / 详情配置外壳入口态 / 详情配置主体静态表单 / 删除确认与连接测试反馈 / 接口获取模型动态反馈 / 添加流程静态 chrome / 添加流程校验错误与动态反馈 / 添加流程目录辅助文案 / 添加流程内置模板名称与描述 / 添加流程 provider type label / 添加流程接口获取 helper 调用面审计 / 添加流程 runtime 数据边界审计 / 能力草案面板 / Workspace 已注册能力面板与 Agent envelope / Managed Job presentation / 桌宠能力偏好卡 / Companion Bridge 诊断区已完成起步迁移,CuratedTask 模板定义与 Agent Chat / Skills / Home 结果模板主调用面已完成起步迁移;Agent Chat 输入能力区 section heading / banner / helper chrome、Skills 执行对话框 chrome、Skill 脚手架启动弹窗 chrome、Browser Runtime existing session hook feedback / presentation、Browser Runtime Debug 审计与附着 Chrome 子面板、Browser Runtime Debug CDP 托管会话高级区、Browser Site Adapter 面板 chrome / 执行反馈、SceneApp 最近判断建议横幅 runtime suggestion 与 General Workbench 最近判断建议横幅 runtime suggestion 已完成起步迁移;其他启动弹窗 runtime suggestion、其他模型执行提示正文和运行时 suggestion 长尾仍需继续;Workspace Skill runtime enable 首发提示正文已在条目 102 收口,Skills 执行对话框 chrome 已在条目 103 收口,Skill 脚手架启动弹窗 chrome 已在条目 105 收口,Browser Runtime existing session hook feedback 已在条目 106 收口,Browser Runtime existing session presentation 已在条目 112 收口,SceneApp 最近判断建议横幅 runtime suggestion 已在条目 107 收口,Browser Site Adapter 面板 chrome / 执行反馈已在条目 108 收口,General Workbench 最近判断建议横幅 runtime suggestion 已在条目 109 收口,Browser Runtime Debug 审计与附着 Chrome 子面板已在条目 110 收口,Browser Runtime Debug CDP 托管会话高级区已在条目 111 收口。 2. `legacy-patch/` 已完成物理隔离,并已有运行时命中量指标 API、GUI smoke metrics 导出链与离线 text / JSON 报告脚本;但 CI 尚未把 `i18n:patch-report --check` 接入删除门禁,也尚未把 `no-hit` 报告和 current 主路径依赖审计绑定为 DOM replacer 删除条件。退出条件仍是 P3 后按命中量和 current 主路径依赖清零逐步拆除。 3. 类型绑定已覆盖当前 source resource,`AGENTS.md` / 质量工作流也已封住新增功能必须走 current i18n 的人工规则;但尚未接入官方 `i18next-cli` 的抽取 / hardcoded string lint / type generation,也尚未评估 selector API 或 `@i18next-selector/codemod` 是否适合资源规模扩大后的 P3 阶段。 -4. 最终合并前仍应基于届时工作区状态复跑 `npm run verify:local`;历史 `verify:local` 曾被非 i18n 的 seeded service skill 数量断言阻塞;最新隔壁 `verify:local` 已推进到 GUI smoke,但仍受 Agent streaming runtime blocker 影响。`ChannelLogTailPanel.test.tsx` 的 `Timeout` 类型不匹配已在记录 49 收口,当前不再作为 i18n PRD 的 typecheck blocker;记录 50 / 51 / 52 / 53 / 54 / 55 / 56 / 57 / 58 的 typecheck 因并行重验证负载或本轮未单独启动而未取得通过证据,后续收口时需复跑;记录 59 / 60 / 61 已重新取得 `npm run typecheck` 通过证据;记录 65 / 66 因隔壁 `verify:local` 长跑占用未重复启动 typecheck;记录 67 / 68 已重新取得 `npm run typecheck` 通过证据;记录 69 / 71 / 72 / 73 / 74 未单独启动 typecheck / verify:local;记录 75 已尝试 `npm run typecheck` 但当时被 AutomationJobDialog 并行脏改阻塞,且隔壁 `verify:local` 在 `smoke:claw-chat-ready-streaming` 超时失败;记录 76 已重新取得 `npm run typecheck` 通过证据;记录 77 已尝试 `npm run typecheck` 但被 Memory 来源链并行脏改阻塞;记录 78 / 79 / 80 / 81 / 82 / 83 / 84 / 85 / 86 / 87 / 88 / 89 / 90 已重新取得 `npm run typecheck` 通过证据;记录 70 已重新取得 `npm run typecheck` 通过证据。 +4. 记录 105 已取得 `npm run verify:local` 与 GUI smoke 通过证据(日志 `.lime/qc/verify-local-2026-05-11-i18n-managed-job-initial-values.log`),并覆盖此前 Agent streaming runtime blocker;记录 125 已复跑 `npm run verify:local`,并通过 `verify:app-version`、`lint`、`typecheck`、全量 smart vitest、`test:contracts` 与 GUI smoke 的 workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page 阶段;但 `smoke:claw-chat-ready-streaming` 仍以 `长 turn 在首个流式增量前结束: status=completed` 失败,单独复跑同样失败,因此记录 109 / 110 / 111 / 112 / 113 / 114 / 115 / 116 / 117 / 118 / 119 / 120 / 121 / 122 / 123 / 124 / 125 之后仍没有完整绿色统一入口;最新工作区涉及 CuratedTask 模板定义 / Agent Chat 输入能力区、Skills runtime enable 首发提示正文、Skills 执行对话框 chrome、Skill 脚手架启动弹窗 chrome、SceneApp / General Workbench 最近判断建议横幅 runtime suggestion / 执行摘要卡、Workspace 图片素材、Browser Runtime 工作台外壳、环境预设子面板、资料管理子面板、Debug 面板外壳、Debug 审计与附着 Chrome 子面板、Debug CDP 托管会话高级区、existing session hook feedback / presentation 与 Site Adapter 面板迁移,本轮主要取得定向测试、eslint、i18n、JSON 解析、typecheck、contracts 与大部分 GUI smoke 证据;记录 117 已补齐 `workspace.browserRuntimeDebug.*` typed resource 并重新取得 `npm run typecheck` 通过证据,记录 118 初次 typecheck 阻断已由记录 119 的 `workspace.browserExistingSession.feedback.*` 资源补齐与 `npm run typecheck` 通过证据覆盖,记录 120 的 `npm run typecheck` 被并行 Browser Site Adapter `workspace.browserSiteAdapter.*` typed key / defaultValue 错误阻断,该阻断已由记录 121 的 `workspace.browserSiteAdapter.*` 资源补齐与 `npm run typecheck` 通过证据覆盖;记录 122 的 `npm run typecheck` 被并行 `src/features/browser-runtime/BrowserRuntimeDebugPanel.tsx(582,33)` 的 `TS2554` 阻断,未作为 General Workbench 本刀通过证据;记录 123 已由 Browser Runtime Debug 审计与附着 Chrome 子面板迁移重新取得 `npm run typecheck` 通过证据;记录 124 继续取得 Browser Runtime Debug CDP 托管会话高级区的 `npm run typecheck` 通过证据;记录 131 提到的 `ExistingSessionAttachPanelCopy.presentation` 缺失已由记录 132 的 Browser Runtime existing session presentation 迁移与 `npm run typecheck` 通过证据覆盖;本轮存在隔壁 qcloop / P0 smoke 进程,未启动 `npm run verify:local` / GUI smoke,最终合并前应基于最新 diff 复跑统一入口。`ChannelLogTailPanel.test.tsx` 的 `Timeout` 类型不匹配已在记录 49 收口,当前不再作为 i18n PRD 的 typecheck blocker;记录 50 / 51 / 52 / 53 / 54 / 55 / 56 / 57 / 58 的 typecheck 因并行重验证负载或本轮未单独启动而未取得通过证据,后续收口时需复跑;记录 59 / 60 / 61 已重新取得 `npm run typecheck` 通过证据;记录 65 / 66 因隔壁 `verify:local` 长跑占用未重复启动 typecheck;记录 67 / 68 已重新取得 `npm run typecheck` 通过证据;记录 69 / 71 / 72 / 73 / 74 未单独启动 typecheck / verify:local;记录 75 已尝试 `npm run typecheck` 但当时被 AutomationJobDialog 并行脏改阻塞,且隔壁 `verify:local` 在 `smoke:claw-chat-ready-streaming` 超时失败;记录 76 已重新取得 `npm run typecheck` 通过证据;记录 77 已尝试 `npm run typecheck` 但被 Memory 来源链并行脏改阻塞;记录 78 / 79 / 80 / 81 / 82 / 83 / 84 / 85 / 86 / 87 / 88 / 89 / 90 / 91 已重新取得 `npm run typecheck` 通过证据;记录 92 / 93 因并行改动下的 `MemoryPage.test.tsx` / `WorkspaceRegisteredSkillsPanel.tsx` 类型 blocker 未取得本轮 typecheck 通过证据;记录 94 / 95 / 96 / 97 / 98 / 99 / 101 / 103 / 105 已重新取得 `npm run typecheck` 通过证据;记录 70 已重新取得 `npm run typecheck` 通过证据。 diff --git a/docs/test/README.md b/docs/test/README.md index 6b39332c2..268e822b4 100644 --- a/docs/test/README.md +++ b/docs/test/README.md @@ -13,7 +13,7 @@ Lime 当前是一个本地优先的 Tauri 桌面应用,而不是单一前端 - Rust 服务层与业务逻辑 - 数据库、文件系统与工作区状态 - Provider、协议转换与本地 HTTP Server -- 浏览器运行时、终端、OpenClaw 等桌面能力 +- 浏览器运行时等桌面能力 - Agent Runtime 与真实模型行为 - macOS / Windows 平台差异 diff --git a/docs/test/agent-qc-scenarios.manifest.json b/docs/test/agent-qc-scenarios.manifest.json index b993d2115..0bc39aca5 100644 --- a/docs/test/agent-qc-scenarios.manifest.json +++ b/docs/test/agent-qc-scenarios.manifest.json @@ -3,9 +3,9 @@ "title": "Lime Agent QC Operating Scenarios", "evidenceSchema": "docs/test/agent-qc-evidence.schema.json", "qcloop": { - "purpose": "把 Agent 测试拆成可批量执行、独立质检、可返修的场景项。", - "recommendedMaxQcRounds": 3, - "workerPromptTemplate": "在 Lime 仓库中执行 Agent QC 场景 {{item}}。只做该场景要求的最小验证,收集命令、GUI、runtime 和证据路径,不要顺手修复无关问题。\n\n执行场景命令前先运行 qcloop worker preflight:`npm run agent-qc:qcloop-preflight -- --expected-cwd \"$(pwd)\" --check`。如果该场景命令需要 DevBridge / GUI / browser runtime,再运行:`npm run agent-qc:qcloop-preflight -- --expected-cwd \"$(pwd)\" --require-devbridge --timeout-ms 15000 --check`。\n\n如果 preflight 失败,停止后续高成本命令,输出 `QCLOOP_WORKER_RESULT=BLOCKED`,并在 stdout 中列出失败 check、health URL、cwd、stdout/stderr 证据路径;不要把环境阻断误判为产品 pass。\n\n最终 stdout 必须逐项列出 `evidence_required` 的证据是否满足,并逐项说明 `failure_modes` 如何被覆盖、排除或命中;不能只写“命令通过”或只给日志路径。\n\n如果 item 中包含 evidence_layers,最终 stdout 必须声明本次覆盖到哪些层级;只跑到 deterministic-smoke 时不得伪装成 gui-trace、runtime-transcript 或 release-artifact。\n\n只读约束:Agent QC worker 只允许执行场景命令、收集证据和输出 PASS/FAIL/BLOCKED;不得修改源码、配置、文档、锁文件或 git 状态。若 verifier 证据不足,输出缺口并让外层生成新 payload,不要在 qcloop repair 轮里修代码。\n\n结构化证据输出硬约束:最终 stdout 必须包含单行 `QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED`;还必须包含单行 `QCLOOP_EVIDENCE_SUMMARY_JSON=`,其中 `` 是单个 JSON object,不要 Markdown / code fence。该 JSON 至少包含 scenario_id、result、commands[]、evidence_required[]、evidence_layers_covered[]、failure_modes[]、artifacts[]、blockers[]、gui_session_owner、release_scope。evidence_required[] 每项包含 name、status(pass|fail|blocked|missing)、evidence、artifact_path;failure_modes[] 每项包含 name、status(covered|excluded|hit|unknown)、evidence。不得输出密钥、token、用户私密内容或未经脱敏的请求 / 响应正文。", + "purpose": "把 Agent 测试拆成可批量执行、独立质检、只读取证的场景项;发布证据批次不得在 qcloop repair 轮中修改工作区。", + "recommendedMaxQcRounds": 1, + "workerPromptTemplate": "在 Lime 仓库中执行 Agent QC 场景 {{item}}。只做该场景要求的最小验证,收集命令、GUI、runtime 和证据路径,不要顺手修复无关问题。\n\n执行场景命令前先运行 qcloop worker preflight:`npm run agent-qc:qcloop-preflight -- --expected-cwd \"$(pwd)\" --check`。如果该场景命令需要 DevBridge / GUI / browser runtime,再运行:`npm run agent-qc:qcloop-preflight -- --expected-cwd \"$(pwd)\" --require-devbridge --timeout-ms 15000 --check`。\n\n如果 preflight 失败,停止后续高成本命令,输出 `QCLOOP_WORKER_RESULT=BLOCKED`,并在 stdout 中列出失败 check、health URL、cwd、stdout/stderr 证据路径;不要把环境阻断误判为产品 pass。\n\n最终 stdout 必须逐项列出 `evidence_required` 的证据是否满足,并逐项说明 `failure_modes` 如何被覆盖、排除或命中;不能只写“命令通过”或只给日志路径。\n\n如果 item 中包含 evidence_layers,最终 stdout 必须声明本次覆盖到哪些层级;只跑到 deterministic-smoke 时不得伪装成 gui-trace、runtime-transcript 或 release-artifact。\n\n只读约束:Agent QC worker 只允许执行场景命令、收集证据和输出 PASS/FAIL/BLOCKED;不得修改源码、配置、文档、锁文件或 git 状态。若 verifier 证据不足,输出缺口并让外层生成新 payload,不要在 qcloop repair 轮里修代码。\n\n结构化证据输出硬约束:最终 stdout 必须包含单行 `QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED`;还必须包含单行 `QCLOOP_EVIDENCE_SUMMARY_JSON=`,其中 `` 是单个 JSON object,不要 Markdown / code fence。该 JSON 至少包含 scenario_id、result、commands[]、evidence_required[]、evidence_layers_covered[]、failure_modes[]、artifacts[]、blockers[]、gui_session_owner、release_scope。evidence_required[] 每项包含 name、status(pass|fail|blocked|missing)、evidence、artifact_path;failure_modes[] 每项包含 name、status(covered|excluded|hit|unknown)、evidence。不得输出密钥、token、用户私密内容或未经脱敏的请求 / 响应正文。\n\nGUI session owner / isolation 口径:`npm run agent-qc:gui-owner-check -- --check` 只用于外层启动新 GUI P0 批次前,不要在 qcloop worker 内用它判定当前正在执行的同批次 GUI item;worker 需要在 `gui_session_owner` 字段声明当前隔离方式,例如 `single-worker isolated qcloop job on dedicated port/db`、是否复用已有 DevBridge,以及是否观察到其他非本 job 的 GUI owner。只有发现非本 job 的 active/stale GUI owner 时,才把 `parallel GUI smoke interference` 记为 hit/blocker。", "verifierPromptTemplate": "审查 worker 输出是否满足场景 {{item}} 的 verifier、evidenceRequired 和 failureModes。只输出一个合法 JSON object,不要 Markdown、不要代码块、不要前后缀文本。JSON 必须至少包含 {\"pass\": true|false, \"feedback\": \"...\"};可选字段 missingEvidence、nextAction、evidenceStatus、scenarioId 必须仍在同一个 JSON object 内。\n\nqcloop worker 执行证据:\n- attempt_status: {{attempt_status}}\n- attempt_type: {{attempt_type}}\n- exit_code: {{exit_code}}\n\nworker stdout:\n{{stdout}}\n\nqcloop issue ledger:\n{{issue_ledger}}\n\n判定要求:stdout 必须包含 `QCLOOP_WORKER_RESULT=PASS|FAIL|BLOCKED` 和 `QCLOOP_EVIDENCE_SUMMARY_JSON=`。如果 stdout 缺少 `QCLOOP_EVIDENCE_SUMMARY_JSON`,或该 JSON 无法逐项证明 scenario verifier / evidenceRequired / failureModes,必须输出 {\"pass\": false, \"feedback\": \"...\"};不能因为命令名、退出码或 worker 自评 PASS 就通过。", "recommendedMaxExecutorRetries": 0 }, @@ -181,12 +181,12 @@ "npm run smoke:agent-service-skill-entry" ], "goal": "从 capability draft 到 verify/register,再到 runtime binding readiness 与 session scope 显式 enable。", - "verifier": "不能把已注册误判为已自动进入 tool surface;必须看到 readiness、metadata、enable 和 SkillTool gate 证据。", + "verifier": "不能把已注册误判为已自动进入 tool surface;必须看到 readiness、metadata、enable 和 SkillTool gate 证据。runtime-transcript 以 smoke 脚本生成的 .lime/qc/skill-forge-runtime-transcript-current.json 为准,必须包含 SkillTool request/decision/result 与 source metadata;本场景不要求 live model provider stream。", "evidenceRequired": [ "capability draft evidence", "registration result", "runtime binding projection", - "SkillTool gate transcript" + "SkillTool gate transcript request/decision/result/source metadata" ], "evidenceLayers": [ "deterministic-smoke", @@ -445,7 +445,7 @@ "npm run verify:gui-smoke" ], "goal": "发布前验证版本一致、启动冒烟、首屏 ready、Bridge health 和发布阻断条件可被 release gate 复核。", - "verifier": "verify:app-version 与 GUI startup smoke 成功;如无真实安装包 artifact,应明确记录为 source-tree startup smoke,不能把它伪装成 installer 验证;release Evidence Pack 覆盖由 release-summary gate 单独强制。", + "verifier": "verify:app-version 与 GUI startup smoke 成功;如无真实安装包 artifact,应明确记录为 source-tree startup smoke,不能把它伪装成 installer 验证;在明确声明 source-tree startup scope 且未伪装 installer 时,artifact missing 应判 excluded;release Evidence Pack 覆盖由 release-summary gate 单独强制。", "evidenceRequired": [ "version check", "startup smoke", diff --git a/docs/tests/agent-qc-p0-scenarios.md b/docs/tests/agent-qc-p0-scenarios.md index 1eac49b9b..172f5dffb 100644 --- a/docs/tests/agent-qc-p0-scenarios.md +++ b/docs/tests/agent-qc-p0-scenarios.md @@ -81,7 +81,7 @@ npm run agent-qc:release-summary -- \ | `command-bridge-contract` | `npm run test:contracts` | 命令合同日志、四侧同步摘要 | 前端/Rust/mock/catalog 任一侧缺失 | | `claw-chat-ready-streaming` | `npm run verify:gui-smoke` + Playwright MCP | GUI trace、DevBridge、runtime transcript、console/network | workspace 未 ready、流式卡死、中断不可恢复 | | `tool-approval-sandbox-boundary` | `npm run smoke:agent-runtime-tool-surface` + `npm run smoke:agent-runtime-approval-sandbox` | tool timeline、approval decision、sandbox policy | 工具绕过授权、超时无恢复、危险工具暴露 | -| `skill-forge-register-bind-enable` | `npm run test:contracts` + `npm run smoke:agent-service-skill-entry` | draft、verify/register、runtime binding、SkillTool gate | 把 registered 误判为 executable、metadata 自动启用 skill | +| `skill-forge-register-bind-enable` | `npm run test:contracts` + `npm run smoke:agent-service-skill-entry` | draft、verify/register、runtime binding、`.lime/qc/skill-forge-runtime-transcript-current.json` 中的 SkillTool request/decision/result/source metadata | 把 registered 误判为 executable、metadata 自动启用 skill | | `browser-runtime-site-adapter` | `npm run smoke:browser-runtime` + `npm run smoke:site-adapters` | browser session、adapter catalog、console/network、cleanup | session 泄漏、adapter 漂移、cleanup 缺失 | | `workspace-ready-session-restore` | `npm run smoke:workspace-ready` + `npm run verify:gui-smoke` | workspace smoke、GUI smoke、DevBridge、design canvas 工程保存 / 打开结果 | ready 假阳性、会话恢复脏状态、设计画布导出无保存状态 | | `harness-replay-regression` | `npm run harness:eval` + `npm run harness:eval:trend` | summary、trend、invalid case、observability outcome | fixture invalid、grader 合同漂移、trend 断裂 | @@ -96,7 +96,7 @@ P0 场景允许先用确定性 smoke 快速证明主路径可执行,但 releas | `deterministic-smoke` | 命令能运行、DevBridge 可达、workspace ready、基础 runtime surface 可读 | live long-turn transcript、真实中断恢复、approval/sandbox 完整轨迹 | | `gui-trace` | 用户路径截图 / trace、console/network 摘要、关键 UI 断言 | 后端 turn 持久化和 tool timeline | | `runtime-transcript` | submit / stream / interrupt / resume / tool request / decision / result 顺序 | GUI 可见性、桌面壳启动、发布包启动 | -| `release-artifact` | 安装包或 source-tree 启动范围、版本一致性、waiver | 运行期 Agent 行为质量 | +| `release-artifact` | 安装包或 source-tree 启动范围、版本一致性、waiver;若没有安装包,必须明确标记 `source-tree startup scope`,不能伪装 installer | 运行期 Agent 行为质量 | 如果当前只跑到 `deterministic-smoke`,worker 可以输出 `QCLOOP_WORKER_RESULT=PASS` 表示命令通过;但 verifier 仍应在缺少 deep evidence 时判该 P0 场景不满足 release pass。正确做法是补证据或拆分场景,不是降低 verifier。 diff --git a/docs/tests/lime-agent-autonomous-test-execution-matrix.md b/docs/tests/lime-agent-autonomous-test-execution-matrix.md index bbdea27c1..aba11e1b2 100644 --- a/docs/tests/lime-agent-autonomous-test-execution-matrix.md +++ b/docs/tests/lime-agent-autonomous-test-execution-matrix.md @@ -32,7 +32,8 @@ npm run agent-qc:gui-owner-check -- \ npm run agent-qc:process-owner-check -- \ --format json \ --output "./.lime/qc/gui-process-owner-current.json" \ - --markdown-output "./.lime/qc/gui-process-owner-current.md" + --markdown-output "./.lime/qc/gui-process-owner-current.md" \ + --watch-history-output "./.lime/qc/raw-process-owner-watch-history.jsonl" npm run agent-qc:qcloop-db-lease -- \ --db "./.lime/qc/qcloop-isolated-worker-preflight.db" \ @@ -40,20 +41,28 @@ npm run agent-qc:qcloop-db-lease -- \ --format json \ --output "./.lime/qc/qcloop-db-lease-current.json" \ --markdown-output "./.lime/qc/qcloop-db-lease-current.md" + +npm run agent-qc:payload-coverage -- \ + --payload "./.lime/qc/qcloop-p0-single-owner-ready-2026-05-11-0454.json" \ + --format json \ + --output "./.lime/qc/qcloop-p0-single-owner-ready-coverage-current.json" \ + --check ``` 通过条件: - `gui-owner-current.json` 没有 active / stale GUI qcloop owner。 -- `gui-process-owner-current.json` 没有 raw GUI smoke、qcloop worker、Cargo / Rust 构建 owner。 +- `gui-process-owner-current.json` 没有 active raw GUI smoke、qcloop worker、Cargo / Rust 构建 owner;passive qcloop serve、passive Tauri runtime 和 observer shell 只作为上下文,不单独阻断。 - `qcloop-db-lease-current.json` 没有 running item、续约 lock 或 no-output active attempt。 +- `qcloop-p0-single-owner-ready-coverage-current.json` 对 manifest P0 场景 `missing=[]`、`extra=[]`、`coverage.passed=true`。 - DevBridge preflight 通过:`npm run agent-qc:qcloop-preflight -- --require-devbridge --check`。 阻断条件: - 存在 stale qcloop worker 且仍在续约 DB lease。 - 存在长时间 `smoke:*` 或 `verify:gui-smoke` 进程。 -- 存在 Cargo / Rust 编译或 Tauri dev owner,可能抢占 target、DevBridge 或窗口状态。 +- 存在 Cargo / Rust 编译 owner,可能抢占 target;`tauri dev` runtime 只有在被归类为 active owner 时才阻断。 +- ready payload 与 manifest P0 不一致,或 payload coverage 仍未生成。 - 官方 `.lime/qc/agent-qc-evidence.json` 不是同一批次 8/8 P0 pass。 ## 3. P0 执行矩阵 @@ -64,7 +73,7 @@ npm run agent-qc:qcloop-db-lease -- \ | `workspace-ready-session-restore` | `npm run smoke:workspace-ready` + `npm run verify:gui-smoke -- --reuse-running` | workspace ready、session restore、DevBridge health、GUI trace | GUI ready 和恢复状态都可观察 | | `claw-chat-ready-streaming` | `npm run verify:gui-smoke -- --reuse-running` 或专项 Claw smoke | first delta、interrupt command、aborted turn、follow turn completed、GUI 可见恢复结果 | 不能只有“有回复”,必须证明中断后状态正确 | | `tool-approval-sandbox-boundary` | `npm run smoke:agent-runtime-tool-surface` + `npm run smoke:agent-runtime-approval-sandbox` | approval policy、sandbox policy、denied/resolved flow、tool timeline | approval / sandbox 进入 turn config 且无绕过 | -| `skill-forge-register-bind-enable` | `npm run test:contracts` + `npm run smoke:agent-service-skill-entry` | draft / verify / register / binding readiness / enable allowlist | 注册不等于可执行,显式 enable 才可进入能力面 | +| `skill-forge-register-bind-enable` | `npm run test:contracts` + `npm run smoke:agent-service-skill-entry` | draft / verify / register / binding readiness / enable allowlist / runtime transcript | 注册不等于可执行,显式 enable 且 SkillTool gate 有结构化 transcript 才可进入能力面 | | `browser-runtime-site-adapter` | `npm run smoke:browser-runtime` + `npm run smoke:site-adapters` | attach/status、adapter list、cleanup、console/network 摘要 | cleanup warning 必须归类;session 泄漏不可放行 | | `harness-replay-regression` | `npm run harness:eval` + `npm run harness:eval:trend` | eval summary、observability gap、trend 样本 | trend 样本不足只能作为 seed,不能替代长期趋势 | | `release-package-startup-smoke` | `npm run verify:app-version` + `npm run verify:gui-smoke -- --reuse-running` | version consistency、artifact scope、startup smoke | 必须标明 source-tree 还是 installer artifact | @@ -136,3 +145,25 @@ npm run agent-qc:qcloop-db-lease -- \ - Evidence 契约:`docs/tests/lime-agent-qc-evidence-contract.md` - 机器 manifest:`docs/test/agent-qc-scenarios.manifest.json` - Evidence schema:`docs/test/agent-qc-evidence.schema.json` + +## 10. 2026-05-11 04:44 样本状态 + +当前执行矩阵的最新输入不是“缺少测试文档”,而是 owner gate 和官方 Evidence Pack 仍在阻断: + +| Gate | 当前状态 | 下一步含义 | +| --- | --- | --- | +| `agent-qc:audit -- --check` | `incomplete`,`16/17` | 只剩 `real-qcloop-evidence` 未满足 | +| `agent-qc:objective-checklist -- --check` | `incomplete`,`5/7` | raw process owner busy + official Evidence Pack fail | +| `agent-qc:gui-owner-check -- --check` | pass | qcloop GUI owner 已清空 | +| `agent-qc:process-owner-check` | busy | PID `59011` stale `smoke:design-canvas` 是唯一 active blocker | +| official Evidence Pack | fail | `.lime/qc/agent-qc-evidence.json` 不能发布 | +| isolated P0 full v3 | failed,4 pass / 4 exhausted | 只能作为 sidecar,不可覆盖官方 evidence | +| Skill Forge runtime transcript 单项补证 | completed,1 pass / 0 fail | 已证明单项 verifier 可采信 runtime transcript,但不能替代 8/8 full P0 | + +`skill-forge-register-bind-enable` 的最新前置证据是 `.lime/qc/skill-forge-runtime-transcript-current.json`:它覆盖 deterministic-smoke 与 runtime-transcript 两层、8 个事件,并证明 Rust exact tests 不再是 `running 0 tests`。随后 qcloop job `1778445676473687000` 已把该场景跑成 `success`,sidecar `.lime/qc/agent-qc-evidence.skill-forge-runtime-transcript.json` 为单场景 `pass`。但该 sidecar 缺少其余 7 个 P0 场景,release summary `--check` 仍正确失败;下一轮 full P0 必须在同一批次内重新覆盖 8/8,而不是拼接 partial pass。 + +因此当前安全动作只有三类: + +1. 只读刷新 owner / qcloop status / audit sidecar。 +2. 继续完善文档和 runbook,不能降低 verifier 或 evidenceRequired。 +3. 等 PID `59011` 自然释放,或 owner 明确确认后按 stale raw GUI owner runbook 处理。 diff --git a/docs/tests/lime-agent-autonomous-testing-plan.md b/docs/tests/lime-agent-autonomous-testing-plan.md index 221d8b4bc..856c9b30f 100644 --- a/docs/tests/lime-agent-autonomous-testing-plan.md +++ b/docs/tests/lime-agent-autonomous-testing-plan.md @@ -271,7 +271,7 @@ npm run agent-qc:audit -- --format json ## 6. 当前 Lime 样本状态 -截至 2026-05-11 01:13,本地测试体系已经具备标准、manifest、schema、qcloop exporter、release summary 和 audit,但不能宣称 P0 完成。 +截至 2026-05-11 04:44,本地测试体系已经具备标准、manifest、schema、qcloop exporter、release summary、completion audit、objective checklist 和 raw process owner gate,但不能宣称 P0 完成。 当前关键事实: @@ -298,6 +298,11 @@ npm run agent-qc:audit -- --format json - 最新完整 `verify:local` 已刷新为 `status=fail`,失败阶段是 `verify:gui-smoke / smoke:claw-chat-ready-streaming`。后续 direct Claw post-refresh fallback 已 pass,但另一轮 session-restore direct smoke 在 DevBridge 中途不可达时 fail;两者都只能作为 Claw deep flow 的 sidecar 证据,不能替代完整 `verify:local` pass。 - 2026-05-11 01:13 观察到外部 `verify:gui-smoke -- --reuse-running --timeout-ms 240000` 自然通过,且 Claw streaming / interrupt / resume 在 latest summary 中为 `verdict=pass`、`recoveryVisibleSource=live-stream`。这证明 GUI smoke 主链已经恢复到可通过状态,但 `.lime/qc/gui-process-owner-current.json` 仍显示 raw process owner `busy`,且完整 `verify:local` 尚未重跑通过。 - 2026-05-11 01:32 已把 raw process owner 检查脚本化为 `npm run agent-qc:process-owner-check`,把 qcloop SQLite lease 取证脚本化为 `npm run agent-qc:qcloop-db-lease`,并新增 `docs/tests/lime-agent-autonomous-test-execution-matrix.md` 作为 Lime 样本的执行矩阵。最新 sidecar 仍显示 `qcloop stale` 与 `raw process owner busy`,因此本轮仍不能启动完整 `verify:local` 或新的 full GUI P0。 +- 2026-05-11 03:11 后,`.lime/qc/verify-local-current.json` 与 `.lime/qc/verify-gui-smoke-current.json` 已恢复为 `status=pass`,completion audit 提升到 `16/17`;剩余目标级缺口收敛为真实 qcloop P0 Evidence Pack。 +- 2026-05-11 04:24 的隔离 P0 full v3 已终态 `failed`,无 running / pending / stale:`command-bridge-contract`、`claw-chat-ready-streaming`、`tool-approval-sandbox-boundary`、`harness-replay-regression` 通过;`skill-forge-register-bind-enable`、`browser-runtime-site-adapter`、`workspace-ready-session-restore`、`release-package-startup-smoke` 仍 fail / blocked。v3 只能作为 sidecar,不能升格为官方 Evidence Pack。 +- 2026-05-11 04:44 的 raw process owner 已收敛到单一 active blocker:PID `59011` 的 stale `smoke:design-canvas`,`cargoOrRust=0`、`qcloopRelated=0`、`staleActiveGuiSmoke=1`。确认前仍不得终止该进程,也不得启动新的 full GUI P0。 +- `skill-forge-register-bind-enable` 的 Rust exact filter 缺口已修正:`smoke:agent-service-skill-entry` 现在拒绝 `running 0 tests`,并产出 `.lime/qc/skill-forge-runtime-transcript-current.json`。随后单项 qcloop `1778445676473687000` 已采信该 runtime transcript 并导出 `.lime/qc/agent-qc-evidence.skill-forge-runtime-transcript.json`,该场景为 `pass`;但它只覆盖 1 个场景,不能覆盖官方 8/8 P0 Evidence Pack。 +- 官方 `.lime/qc/agent-qc-evidence.json` 仍为 `status=fail`。当前测试体系已经能把“本地 smoke 通过”“sidecar 通过/失败”“官方发布证据通过”分开处理,这正是运营级测试要保留的硬边界。 这正是自主测试体系的价值:它没有把“本机某次手动看起来可用”误判为发布通过,而是把缺证据和真实产品 blocker 留在门禁中。 @@ -309,11 +314,12 @@ npm run agent-qc:audit -- --format json 2. 继续只读观察所有 running / stale qcloop 批次;若当前 worker 自然结束,再导出 sidecar 并根据终态决定是否复用待用 payload。没有 owner 明确授权前,不 kill、不 pause、不重启这些进程。 3. 把 P0 分成两层验收:确定性 smoke 层用于证明主路径可执行,deep evidence 层用于证明 live transcript / trace / console-network / cleanup / approval-sandbox 证据齐全;官方 release gate 仍必须看 deep evidence pass。 4. 先等当前 running/stale GUI qcloop 批次自然结束,或在 owner 明确授权下收口;下一轮只允许一个 GUI owner 跑 full P0,worker 必须输出 GUI session owner / isolation statement。 -5. 在单一 GUI owner 前提下,定位 `workspace-ready-session-restore` 中 `smoke:design-canvas` 的保存成功状态断言失败,因为它已经是 qcloop worker 内可复现的 GUI P0 signal,不应被旧 isolated pass 覆盖;下一步重点是确认导出按钮点击后是否有保存中 / 保存成功状态,还是导出流程在首个可观察状态前卡住。 -6. 把 `tool-approval-sandbox-boundary` 拆成更窄的 runtime transcript 或 harness replay 场景,让 approval / sandbox 证据以结构化 JSON 进入 verifier,而不是只引用组件级 smoke。 -7. 在 `.lime/qc/gui-process-owner-current.json` 不再显示长时间 raw GUI smoke / Cargo owner 后,重新跑完整 `npm run verify:local`;只有该统一门禁通过,`local-verify-gate` 才能关闭。 -8. 环境阻断解除后,定位 `claw-chat-ready-streaming` 的 stop / interrupt 后持久化、会话恢复和 DevBridge 中途不可达问题,补 Rust 或 harness replay 回归。 -9. 只有 8/8 P0 全部 pass 后,才把结果导出为官方 `.lime/qc/agent-qc-evidence.json`,再运行 `npm run agent-qc:release-summary -- --check` 和 `npm run agent-qc:audit -- --format json`。 +5. 保留 `skill-forge-register-bind-enable` 单项 qcloop pass sidecar,下一轮 full P0 仍要重新在同一批次内覆盖它,不能把单项 sidecar 拼接成官方 Evidence Pack。 +6. 在单一 GUI owner 前提下,优先定位 `workspace-ready-session-restore` / `browser-runtime-site-adapter` / `release-package-startup-smoke` 的 GUI trace、session isolation、release artifact 层证据缺口;不能用旧 isolated pass 覆盖最新 P0 sidecar failure。 +7. 把 `tool-approval-sandbox-boundary` 保持为已通过的 runtime transcript / harness replay 场景,避免后续退回只引用组件级 smoke。 +8. 在 `.lime/qc/gui-process-owner-current.json` 不再显示长时间 raw GUI smoke / Cargo owner 后,重新跑完整 `npm run verify:local`;只有该统一门禁继续通过,`local-verify-gate` 才能保持关闭。 +9. 环境阻断解除后,定位 `claw-chat-ready-streaming` 的 stop / interrupt 后持久化、会话恢复和 DevBridge 中途不可达问题,补 Rust 或 harness replay 回归,防止 v3 pass 变成一次性偶然通过。 +10. 只有 8/8 P0 全部 pass 后,才把结果导出为官方 `.lime/qc/agent-qc-evidence.json`,再运行 `npm run agent-qc:release-summary -- --check` 和 `npm run agent-qc:audit -- --format json`。 ## 8. 与其他文档的关系 diff --git a/docs/tests/lime-agent-qc-current-blockers.md b/docs/tests/lime-agent-qc-current-blockers.md index 33ad26e9c..63ed604ec 100644 --- a/docs/tests/lime-agent-qc-current-blockers.md +++ b/docs/tests/lime-agent-qc-current-blockers.md @@ -4,7 +4,7 @@ ## 1. 当前结论 -截至 2026-05-11 01:13,本地已经具备 Agent QC 标准、manifest、Evidence Pack schema、qcloop exporter、release summary gate、completion audit、qcloop status sidecar 和 GUI smoke 能力。隔离 qcloop 已经证明部分 P0 smoke 能在 worker 内跑通;最新显式 Sensenova provider 的 `verify:gui-smoke -- --reuse-running` 已通过,并覆盖 Claw streaming / interrupt / resume,但官方 Evidence Pack 仍为失败,完整 `verify:local` 与 stale qcloop P0 owner 仍未闭环: +截至 2026-05-11 05:02,本地已经具备 Agent QC 标准、manifest、Evidence Pack schema、qcloop exporter、release summary gate、completion audit、qcloop status sidecar、payload coverage gate 和 GUI smoke 能力。隔离 qcloop 已经证明部分 P0 smoke 能在 worker 内跑通;最新完整 `verify:local` 已通过,隔离 P0 full v3 已把 `command-bridge-contract`、`claw-chat-ready-streaming`、`tool-approval-sandbox-boundary`、`harness-replay-regression` 推进到 qcloop pass,后续单项 qcloop 又把 `skill-forge-register-bind-enable` 推进到 runtime-transcript pass;但官方 Evidence Pack 仍为失败,且 PID `59011` 的 raw `smoke:design-canvas` stale owner 仍未释放。2026-05-11 05:02 的最新门禁刷新见 [5.38](#538-completion-audit-纳入-payload-coverage2026-05-11-0502): ```text .lime/qc/agent-qc-evidence.json status=fail scenarios=8/8 @@ -246,7 +246,7 @@ evidence=.lime/qc/verify-gui-smoke-reuse-sensenova-session-restore-2026-05-11-01 | active scenario | `browser-runtime-site-adapter` | 仍卡在 browser runtime / site adapter 类 P0 | | stale age | 约 `28020s` | 已远超 stale 阈值 | | GUI owner gate | `ownerCount=1`、`staleOwnerCount=1`、`oldestStaleSeconds≈27026` | 不允许启动新的 full GUI P0 | -| raw process owner | `busy`,`activeGuiSmoke=2`、`cargoOrRust=4`、`qcloopRelated=7` | 不允许启动完整 `verify:local` | +| raw process owner | `busy`,`activeGuiSmoke=3`、`cargoOrRust=4`、`qcloopRelated=7` | 不允许启动完整 `verify:local` | | DB lease | `lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-11T01:47:06+08:00` | lease 仍被续约 | | active attempt | `dc625f8e-b3b9-46b7-9758-4b0273438d50` / `running` | stdout/stderr 仍为空 | @@ -545,3 +545,422 @@ evidence=.lime/qc/verify-gui-smoke-reuse-sensenova-session-restore-2026-05-11-01 | objective completion audit | `achieved=false` | `.lime/qc/objective-completion-audit-current.json` schema `v3` 失败项仍为 `real-p0-qcloop-evidence` 与 `local-verify-gate` | 该口径不改变核心发布事实:没有真实 8/8 P0 qcloop pass 和完整 `verify:local` pass 时,仍不能把当前状态标记为整体完成,也不能覆盖官方 `.lime/qc/agent-qc-evidence.json`。 + +### 5.16 当前门禁刷新与 stale owner 收口(2026-05-11 02:42) + +本轮只执行只读 / sidecar 取证与低冲突脚本修正;未执行 git commit / push / tag / release,未修改 qcloop SQLite DB,未覆盖官方 `.lime/qc/agent-qc-evidence.json`,也未启动新的 full GUI P0 或完整 `verify:local`。 + +| 门禁 | 当前值 | 结论 | +| --- | --- | --- | +| isolated full P0 v1 `1778405842243079000` | `failed`,5 success / 3 exhausted | 旧 stale owner 已自然进入终态;该批次不能作为发布 pass | +| GUI owner gate | `pass`,ownerCount=`0` | qcloop GUI owner 已释放,可以作为后续新批次的必要条件之一 | +| raw process owner | `busy`,activeGuiSmoke=`3`、cargoOrRust=`5`、qcloopRelated=`6` | 仍不应启动完整 `verify:local` 或新的 full GUI P0 | +| sidecar Evidence Pack | `.lime/qc/agent-qc-evidence.isolated-p0-full-v1-after-intervention.json` status=`fail` | 只作为失败证据;不覆盖官方 Evidence Pack | +| sidecar release summary | `exit 1` / status=`fail` | `agent-qc:release-summary --check` 正确拒绝该 sidecar 作为绿色发布证据 | +| completion audit | `15/17`,`status=incomplete` | 当前只剩 `real-qcloop-evidence` 与 `local-verify-gate` 两个目标级缺口 | + +本轮还修正了 completion audit 的 sidecar 归并口径:同一个 qcloop job 存在更新的终态 status sidecar 时,旧的 `pre-intervention` / stale snapshot 不再被误判为“仍有未终态 owner”。该修正只消除历史 sidecar 噪声,不会放宽真正的发布门禁;官方 Evidence Pack 仍必须是真实 `8/8` P0 pass,完整 `verify:local` 仍必须 pass。 + +本轮验证: + +```bash +node --check scripts/agent-qc-completion-audit.mjs +npx vitest run scripts/lib/agent-qc-completion-audit-core.test.ts +npm run agent-qc:audit -- --format json --output ./.lime/qc/objective-completion-audit-current.json +npm run agent-qc:release-summary -- --evidence ./.lime/qc/agent-qc-evidence.isolated-p0-full-v1-after-intervention.json --require-scenario-manifest docs/test/agent-qc-scenarios.manifest.json --require-risk P0 --tag sidecar-isolated-p0-full-v1-after-intervention --check +``` + +下一刀仍不应直接重跑重型门禁;应先等 raw process owner 释放,再按顺序执行 `agent-qc:process-owner-check -- --check`、`agent-qc:gui-owner-check -- --check`、完整 `npm run verify:local`,最后再启动单 owner full P0 qcloop 并导出真实官方 Evidence Pack。 + +### 5.17 raw process owner 精确化(2026-05-11 02:47) + +`agent-qc:process-owner-check` 已从粗粒度进程计数改为区分 active owner、passive runtime 和 observer: + +| 分类 | 当前值 | 含义 | +| --- | --- | --- | +| active GUI smoke | `1` | 只有 PID `59011` 的 `npm run smoke:design-canvas ...` 仍是重型 GUI owner | +| stale active GUI smoke | `1` | PID `59011` 已运行约 `7h`,超过默认 `30min` stale 阈值 | +| active qcloop worker | `0` | 旧 qcloop serve 进程被归为 passive server,不再误判为 active worker | +| active Cargo / Rust build | `0` | `tauri dev` runtime 被归为 passive runtime,不再误判为 Cargo build owner | +| passive qcloop server | `6` | 仅说明历史隔离 server 仍在,不代表 active P0 worker | +| passive Tauri runtime | `4` | 仅说明当前 GUI runtime 仍在,不代表正在编译 | +| observer process | `2` | 只读 `ps` / `rg` watcher,不再计入阻断 owner | + +最新 sidecar:`.lime/qc/gui-process-owner-current.json` / `.md`。当前 `ownerIntervention.status=requires_owner_confirmation`,确认文本为: + +```text +确认处理 stale raw GUI owner PID 59011,可以终止这些进程并记录 sidecar。 +``` + +确认前仍不执行 kill / pause / interrupt,也不启动完整 `verify:local` 或新的 full GUI P0。该修正只消除 idle qcloop serve、Tauri runtime 和观察脚本的误报;真实阻断仍是 stale `smoke:design-canvas` owner。 + +### 5.18 stale raw GUI owner 处置请求(2026-05-11 02:51) + +再次只读刷新确认 PID `59011` 仍存活,`etime=07:04:53`,仍是唯一 active raw GUI smoke owner。已生成处置请求 sidecar: + +- `.lime/qc/stale-raw-gui-owner-intervention-request.json` +- `.lime/qc/stale-raw-gui-owner-intervention-request.md` + +该请求只用于 owner 决策,不代表授权。确认文本仍为: + +```text +确认处理 stale raw GUI owner PID 59011,可以终止这些进程并记录 sidecar。 +``` + +确认前继续保持:不 kill / pause / interrupt PID `59011`,不启动完整 `verify:local`,不启动新的 full GUI P0,不覆盖官方 Evidence Pack,不执行 git commit / push / tag / release。 + +### 5.19 process owner 分类核心回归(2026-05-11 02:55) + +为避免 raw process owner gate 后续再次把 passive runtime / observer 误判成 active blocker,本轮把分类逻辑抽到 `scripts/lib/agent-qc-process-owner-core.mjs`,并新增 `scripts/lib/agent-qc-process-owner-core.test.ts`。当前验证: + +```bash +node --check scripts/lib/agent-qc-process-owner-core.mjs +node --check scripts/agent-qc-process-owner-check.mjs +npx vitest run scripts/lib/agent-qc-process-owner-core.test.ts scripts/lib/agent-qc-completion-audit-core.test.ts +``` + +结果:`21 tests` 全部通过。最新 `agent-qc:process-owner-check --check` 仍按预期失败:`activeGuiSmoke=1`、`cargoOrRust=0`、`qcloopRelated=0`、`staleActiveGuiSmoke=1`、`passiveQcloopServer=6`、`passiveTauriRuntime=4`、`observer=2`。因此门禁没有被放宽,仍卡在 PID `59011` 的 stale `smoke:design-canvas`。 + +### 5.20 objective checklist sidecar(2026-05-11 02:59) + +新增目标级 checklist sidecar: + +- `.lime/qc/objective-completion-checklist-current.json` +- `.lime/qc/objective-completion-checklist-current.md` + +该 checklist 把显式目标映射到文档、manifest、schema、qcloop 工具、owner gate、官方 Evidence Pack、`verify:local` 和 git guardrail。当前状态仍为 `incomplete`,`4/7` pass;阻断项为 raw process owner busy、官方 Evidence Pack 非 pass、`verify:local` 非 pass。短暂观察到的外部 `verify:gui-smoke` transient owner 已自然结束,最新 raw process owner 又收敛为唯一 active blocker:PID `59011` 的 stale `smoke:design-canvas`。 + +### 5.21 raw process owner watch history(2026-05-11 03:01) + +`agent-qc:process-owner-check` 新增 `--watch-history-output`,已追加 `.lime/qc/raw-process-owner-watch-history.jsonl`。最新记录:`status=busy`,`activeGuiSmoke=1`,`staleActiveGuiSmoke=1`,`ownerIntervention=requires_owner_confirmation`,PID `59011` 运行约 `7h16m`。该 JSONL 只记录观察,不授权处理进程。 + +### 5.22 objective checklist 脚本化(2026-05-11 03:04) + +新增 `scripts/agent-qc-objective-checklist.mjs` 与 npm 入口 `agent-qc:objective-checklist`,把目标级 checklist 从一次性 sidecar 生成逻辑固化为可重复门禁。当前命令: + +```bash +npm run agent-qc:objective-checklist -- --format json --output ./.lime/qc/objective-completion-checklist-current.json +npm run agent-qc:objective-checklist -- --format markdown --output ./.lime/qc/objective-completion-checklist-current.md +npm run agent-qc:objective-checklist -- --check +``` + +前两条已生成当前 sidecar;`--check` 按预期 exit `1`,因为 checklist 仍为 `incomplete`(`4/7`)。这不会放宽门禁,只把“目标是否完成”的判断变成可重复脚本。 + +### 5.23 objective checklist core 回归(2026-05-11 03:07) + +`agent-qc:objective-checklist` 已抽出 `scripts/lib/agent-qc-objective-checklist-core.mjs`,并新增 `scripts/lib/agent-qc-objective-checklist-core.test.ts`。回归覆盖:owner clear 时 checklist 可 complete、raw process owner busy 时保持 `pass_with_blocking_owner`、官方 Evidence Pack / `verify:local` fail 时列为 blocker、Markdown 渲染。该轮同时修正了 owner gate 已 pass 时 checklist 可能误判的边界。最新定向测试: + +```bash +npx vitest run scripts/lib/agent-qc-objective-checklist-core.test.ts scripts/lib/agent-qc-process-owner-core.test.ts scripts/lib/agent-qc-completion-audit-core.test.ts +``` + +结果:`25 tests` 全部通过。当前实际 checklist 仍为 `incomplete`,`4/7`。 + +### 5.24 raw GUI owner post-confirmation runbook(2026-05-11 03:09) + +`docs/tests/lime-agent-qc-stale-owner-intervention.md` 新增第 8 节,专门覆盖 `gui-owner-check` 已通过但 `process-owner-check` 仍因 raw GUI smoke stale 的场景。该 runbook 明确:确认前不处理进程;确认后只处理 owner 明确确认的 PID / PGID;不得顺手清理 passive qcloop serve、passive Tauri runtime 或 observer shell;处理后必须先通过 `agent-qc:process-owner-check -- --check` 与 `agent-qc:gui-owner-check -- --check`,再跑完整 `verify:local`。 + +### 5.25 local verify gate 已关闭(2026-05-11 03:11) + +外部完整门禁已把 `.lime/qc/verify-local-current.json` 与 `.lime/qc/verify-gui-smoke-current.json` 刷新为 `status=pass`。重新运行 `agent-qc:audit` 后,completion audit 从 `15/17` 提升为 `16/17`,剩余目标级缺口只剩 `real-qcloop-evidence`。Objective checklist 从 `4/7` 提升为 `5/7`,其中 `verify:local` 已 PASS。 + +当前仍不能启动新的 full P0 qcloop,因为 raw process owner 仍为 `busy`:PID `59011` 的 stale `smoke:design-canvas` 仍存活,`ownerIntervention=requires_owner_confirmation`。因此下一刀不是重跑 `verify:local`,而是等待或获得明确确认后处理 PID `59011`,再启动 single-owner full P0 qcloop 并导出真实 8/8 P0 official Evidence Pack。 + +### 5.26 隔离 P0 full v2 终态失败(2026-05-11 03:50) + +观察到本地已有隔离 P0 full v2 批次 `1778440541478632000`(`lime-agent-qc-isolated-p0-full-v2-2026-05-11-0315`)运行在 `127.0.0.1:18086`。本轮只做只读监控、sidecar 导出和 release gate 校验;未覆盖官方 `.lime/qc/agent-qc-evidence.json`,未执行 git commit / push / tag / release。 + +| 门禁 | 当前值 | 结论 | +| --- | --- | --- | +| qcloop status | `failed`,3 success / 5 exhausted | 批次无 stale,但仍不能发布 | +| sidecar Evidence Pack | `.lime/qc/agent-qc-evidence.isolated-p0-full-v2-2026-05-11-0315.json` status=`fail` | 2 pass / 4 fail / 2 blocked | +| sidecar release summary | `.lime/qc/release-agent-qc.sidecar-isolated-p0-full-v2-2026-05-11-0315.md`,`--check` exit `1` | release gate 正确拒绝 | +| direct contracts sanity | `npm run test:contracts` pass | 当前工作树的命令契约已恢复;v2 中 `command-bridge-contract` 失败是历史运行时证据,不可改写为 pass | +| raw process owner | `busy` | PID `59011` 的 stale `smoke:design-canvas` 仍阻断新 full P0 | + +v2 的主要失败分类: + +| 场景 | sidecar 状态 | 主要原因 | +| --- | --- | --- | +| `command-bridge-contract` | `fail` | worker 运行当时 `test:contracts` 命中 `execution_run_get_general_workbench_state` mock drift;后续 direct sanity 已通过,但不回写历史 item | +| `claw-chat-ready-streaming` | `fail` | 长 turn 在首个流式增量前 completed,未完整证明中断与恢复 | +| `skill-forge-register-bind-enable` | `fail` | `test:contracts` 在该 worker 中失败,Skill P0 不能判通过 | +| `browser-runtime-site-adapter` | `blocked` | 缺 `gui-trace` 与干净 GUI session isolation | +| `workspace-ready-session-restore` | `blocked` | active GUI owner 阻断核心命令执行 / isolation statement | +| `release-package-startup-smoke` | `fail` | `verify:gui-smoke` exit `1`,release startup smoke 未闭环 | + +### 5.27 隔离 P0 full v3 终态失败但无 stale(2026-05-11 04:24) + +随后观察到隔离 P0 full v3 批次 `1778442773271496000`(`lime-agent-qc-isolated-p0-full-v3-2026-05-11-0354`)运行在 `127.0.0.1:18087`。该批次修正了 worker 内 GUI session owner / isolation 口径,避免 worker 把同批次 GUI owner 误判为外部抢占;本轮仍只做只读监控和 sidecar 导出。 + +| 门禁 | 当前值 | 结论 | +| --- | --- | --- | +| qcloop status | `failed`,4 success / 4 exhausted | 批次已终态,无 running / pending / stale;仍不能发布 | +| sidecar Evidence Pack | `.lime/qc/agent-qc-evidence.isolated-p0-full-v3-2026-05-11-0354.json` status=`fail` | 4 pass / 1 fail / 3 blocked | +| sidecar release summary | `.lime/qc/release-agent-qc.sidecar-isolated-p0-full-v3-2026-05-11-0354.md`,`--check` exit `1` | release gate 正确拒绝 | +| completion audit | `16/17`,`status=incomplete` | 只剩 `real-qcloop-evidence` | +| objective checklist | `5/7`,`status=incomplete` | raw process owner busy + official Evidence Pack fail | +| raw process owner | `busy`,activeGuiSmoke=`1`、cargoOrRust 波动中、qcloopRelated=`0` | PID `59011` 仍是 stale GUI owner;同时有外部 Rust 定向测试 / rustc 编译在跑 | + +v3 的通过项和剩余缺口: + +| 场景 | sidecar 状态 | 说明 | +| --- | --- | --- | +| `command-bridge-contract` | `pass` | 当前 qcloop worker 与 verifier 均通过 | +| `claw-chat-ready-streaming` | `pass` | qcloop worker / verifier 接受 GUI、DevBridge、runtime transcript 证据 | +| `tool-approval-sandbox-boundary` | `pass` | qcloop worker / verifier 通过 | +| `harness-replay-regression` | `pass` | qcloop worker / verifier 通过 | +| `skill-forge-register-bind-enable` | `fail` | verifier 认为只覆盖 deterministic smoke,未证明 runtime-transcript / readiness / metadata / explicit enable / SkillTool gate | +| `browser-runtime-site-adapter` | `blocked` | deterministic smoke 有证据,但缺 `gui-trace`,并命中 parallel GUI smoke interference | +| `workspace-ready-session-restore` | `blocked` | workspace 与 GUI smoke 命令成功,但 `GUI session owner / isolation statement` 因非本 job stale owner PID `59011` 被标为 blocked | +| `release-package-startup-smoke` | `blocked` | `verify:app-version` 与 `verify:gui-smoke` 成功,但缺 release-artifact 层,且 GUI isolation 被 PID `59011` 阻断 | + +当前不能把 v3 sidecar 升格为官方发布证据。关闭条件仍是:PID `59011` 自然释放,或 owner 明确确认处理 stale raw GUI owner 后,重新执行 single-owner full P0,并得到官方 `.lime/qc/agent-qc-evidence.json` 的真实 8/8 P0 pass。 + +### 5.28 Skill Forge P0 定向 smoke 补证(2026-05-11 04:31) + +v3 中 `skill-forge-register-bind-enable` 的失败不是 GUI owner 阻断,而是 `smoke:agent-service-skill-entry` 当时的 Rust exact filter 没有实际运行关键后端测试:日志显示多个 `running 0 tests`,导致 verifier 正确拒绝把 deterministic smoke 升格为 runtime / SkillTool gate 证据。 + +随后观察到当前工作树中的 `scripts/agent-service-skill-entry-smoke.mjs` 已改为: + +- 为 root Tauri 测试显式使用 `-p lime` +- 为 `lime-agent` SkillTool gate 测试显式使用 `-p lime-agent` +- 使用完整 Rust test path 与 `--exact` +- 捕获 stdout/stderr 并拒绝没有 `N passed` 的 Rust 定向测试,避免 `running 0 tests` 被误判为通过 + +定向证据: + +| 证据 | 状态 | 说明 | +| --- | --- | --- | +| `.lime/qc/smoke-agent-service-skill-entry-after-rust-exact-fix-2026-05-11-0430.log` | `pass` | 当前 `smoke:agent-service-skill-entry` 已通过 | +| Rust exact tests | `pass` | 日志中关键 root / `lime-agent` 测试均出现 `running 1 test` 与 `1 passed` | +| 服务技能入口路由回归 | `pass` | 4 个 Vitest 文件、66 tests pass | +| Agent A2UI 挂起主链 | `pass` | 1 个 Vitest 文件,6 tests pass / 102 skipped | + +该补证只说明 v3 的 Skill Forge 失败已有可复核的修复方向,不能回写 v3 sidecar,也不能替代新的 full P0。后续仍必须在 raw process owner 清空后重新运行 single-owner full P0,让 `skill-forge-register-bind-enable` 在 qcloop worker / verifier 内重新通过。 + + +### 5.29 raw process owner 回到单一 PID 阻断(2026-05-11 04:32) + +最新 `agent-qc:process-owner-check` sidecar 显示外部 Rust/Cargo owner 已自然清空:`activeGuiSmoke=1`、`cargoOrRust=0`、`qcloopRelated=0`、`staleActiveGuiSmoke=1`。唯一 active blocker 仍是 PID `59011` 的 stale `smoke:design-canvas`,`etime≈8h46m`。 + +该状态说明下一轮 full P0 的唯一环境前置阻断已经收敛为 raw GUI owner;确认前仍不得处理 PID `59011`,也不得启动新的 full GUI P0 或覆盖官方 Evidence Pack。 + +### 5.30 Skill Forge 单项 qcloop 补证终态(2026-05-11 04:39) + +观察到单项 qcloop job `1778445171616868000`(`lime-agent-qc-skill-forge-rust-exact-fix-2026-05-11-0432`)运行在 `127.0.0.1:18087` 并进入终态 `failed`,`0 success / 1 exhausted / 0 stale`。该批次只覆盖 `skill-forge-register-bind-enable`,不是 full P0,也不能覆盖官方 Evidence Pack。 + +| 证据 | 状态 | 说明 | +| --- | --- | --- | +| `.lime/qc/qcloop-status.skill-forge-rust-exact-fix-current.json` | `failed` | 单项 job 已终态,无 stale | +| `.lime/qc/agent-qc-evidence.skill-forge-rust-exact-fix.json` | `fail` | deterministic smoke 证据充分,但 runtime-transcript 层仍缺 | +| `.lime/qc/release-agent-qc.sidecar-skill-forge-rust-exact-fix.md` | `fail` | release summary 正确拒绝单项 sidecar 作为 full P0 发布证据 | + +这次补证把 `skill-forge-register-bind-enable` 的缺口从“Rust exact tests 没实际运行”推进为更准确的 P0 缺口: + +- `npm run test:contracts` 与 `npm run smoke:agent-service-skill-entry` 在 qcloop worker 内已能产出可审查 deterministic-smoke 证据 +- capability draft、registration、runtime binding readiness、metadata、显式 enable、SkillTool gate 的 deterministic evidence 已被 verifier 接受 +- 仍缺 live runtime transcript:未产出 submit / stream / tool-request / decision / result 级 artifact + +因此下一刀不是再修 Rust exact filter,而是补 `skill-forge-register-bind-enable` 的 runtime transcript 采集路径;在该 artifact 存在前,P0 verifier 不应把 Skill Forge 场景判为 pass。 + +### 5.31 Skill Forge runtime transcript sidecar 已出现,但不能回写历史 job(2026-05-11 04:44) + +继续只读复核后,`agent-qc:process-owner-check` 已回到单一 active blocker: + +| 检查 | 当前值 | 结论 | +| --- | --- | --- | +| raw process owner | `busy` | 仍不能启动完整 `verify:local` 或新的 full GUI P0 | +| active GUI smoke | `1` | PID `59011` 的 stale `smoke:design-canvas`,`etime≈8h58m` | +| qcloop worker | `0` | 单项 Skill Forge qcloop worker 已自然结束 | +| Cargo / Rust owner | `0` | 外部编译 owner 已自然清空 | +| official Evidence Pack | `fail` | `.lime/qc/agent-qc-evidence.json` 仍不能发布 | + +同时观察到 `.lime/qc/skill-forge-runtime-transcript-current.json` 已生成: + +```text +schemaVersion=v1 +scenarioId=skill-forge-register-bind-enable +result=pass +evidenceLayersCovered=deterministic-smoke,runtime-transcript +runtimeTranscript.events=8 +``` + +这把 Skill Forge 的剩余缺口进一步收窄:runtime transcript sidecar 已存在,但它是在单项 qcloop job `1778445171616868000` 失败后生成的,不能回写历史 qcloop verdict,也不能覆盖官方 full P0 Evidence Pack。下一轮 owner 清空后,应先重跑 `skill-forge-register-bind-enable` 单项 qcloop,确认 worker / verifier 会采信该 artifact 或重新生成同等 artifact;然后再进入 8/8 single-owner full P0。 + +当前仍禁止: + +- 终止 / pause / interrupt PID `59011`,除非 owner 明确确认 `确认处理 stale raw GUI owner PID 59011,可以终止这些进程并记录 sidecar。` +- 启动新的 full GUI P0。 +- 覆盖 `.lime/qc/agent-qc-evidence.json`。 +- git commit / push / tag / release。 + +### 5.32 Skill Forge runtime transcript 单项 qcloop 通过(2026-05-11 04:48) + +继续只读观察后,发现第二个单项 qcloop job `1778445676473687000`(`lime-agent-qc-skill-forge-runtime-transcript-2026-05-11-0441`)已在 `127.0.0.1:18087` 终态 `completed`,`1 success / 0 fail / 0 stale`。本轮只导出 sidecar,并运行 release summary 检查;未覆盖官方 Evidence Pack。 + +| 证据 | 状态 | 说明 | +| --- | --- | --- | +| `.lime/qc/qcloop-status.skill-forge-runtime-transcript-current.json` | `complete` | `skill-forge-register-bind-enable` 单项 success | +| `.lime/qc/agent-qc-evidence.skill-forge-runtime-transcript.json` | `pass` | 只覆盖 `skill-forge-register-bind-enable` 1 个场景 | +| `.lime/qc/release-agent-qc.sidecar-skill-forge-runtime-transcript.md` | `fail` | 缺少其余 7 个 P0 场景,release gate 正确拒绝 | + +这说明 `skill-forge-register-bind-enable` 的 runtime-transcript 层已能被 qcloop worker / verifier 采信;该缺口从“需要补 transcript artifact”推进为“需要在下一轮 full P0 同批次重新覆盖”。它仍不能关闭官方发布门禁,因为官方 `.lime/qc/agent-qc-evidence.json` 仍是旧的 8/8 `fail`,且当前 raw process owner 仍被 PID `59011` 阻断。 + +### 5.33 stale raw GUI owner 处置请求已刷新(2026-05-11 04:52) + +最新只读刷新显示,`agent-qc:gui-owner-check -- --check` 仍为 pass,但 raw process owner 仍为 busy: + +| 检查 | 当前值 | 结论 | +| --- | --- | --- | +| active GUI smoke | `1` | PID `59011` | +| runtime | `09:06:20` / `32780s` | 远超 `--timeout-ms 600000` 对应的 10 分钟 | +| qcloop worker | `0` | 没有新的 active qcloop worker | +| Cargo / Rust owner | `0` | 没有 active 编译 owner | +| owner intervention | `requires_owner_confirmation` | 仍需要 owner 明确确认才能处理 PID | + +已刷新: + +- `.lime/qc/gui-process-owner-current.json` +- `.lime/qc/gui-process-owner-current.md` +- `.lime/qc/raw-process-owner-watch-history.jsonl` +- `.lime/qc/stale-raw-gui-owner-intervention-request.json` +- `.lime/qc/stale-raw-gui-owner-intervention-request.md` + +确认文本仍是: + +```text +确认处理 stale raw GUI owner PID 59011,可以终止这些进程并记录 sidecar。 +``` + +在该确认出现前,当前 Agent 只能继续只读观察或维护文档 / sidecar;不得处理进程、不得启动 full GUI P0、不得覆盖官方 Evidence Pack。 + +### 5.34 single-owner full P0 待执行 payload 已生成但未启动(2026-05-11 04:54) + +在不启动新 qcloop job 的前提下,已生成下一轮 full P0 待执行 payload: + +| 产物 | 状态 | 说明 | +| --- | --- | --- | +| `.lime/qc/qcloop-p0-single-owner-ready-2026-05-11-0454.json` | `valid=true` | 覆盖 8 个 P0 scenario | +| `.lime/qc/qcloop-p0-single-owner-ready-2026-05-11-0454.md` | ready note | 记录 scenario IDs、启动前置门禁和当前 blocker | +| qcloop job | not started | 没有提交到 qcloop server,没有抢占 GUI | + +payload 覆盖: + +```text +command-bridge-contract +claw-chat-ready-streaming +tool-approval-sandbox-boundary +skill-forge-register-bind-enable +browser-runtime-site-adapter +workspace-ready-session-restore +harness-replay-regression +release-package-startup-smoke +``` + +启动前置仍是: + +```bash +npm run agent-qc:process-owner-check -- --check +npm run agent-qc:gui-owner-check -- --check +npm run agent-qc:qcloop-preflight -- --require-devbridge --check +``` + +当前 PID `59011` 未释放,所以该 payload 只能作为 ready-to-run artifact,不能启动,也不能用于覆盖官方 Evidence Pack。 + +### 5.35 full P0 payload coverage 已校验(2026-05-11 04:55) + +已新增 coverage sidecar: + +- `.lime/qc/qcloop-p0-single-owner-ready-coverage-2026-05-11-0455.json` +- `.lime/qc/qcloop-p0-single-owner-ready-coverage-2026-05-11-0455.md` + +校验结果: + +| 检查 | 当前值 | +| --- | --- | +| status | `blocked` | +| manifest P0 count | `8` | +| payload item count | `8` | +| missing scenarios | `none` | +| extra scenarios | `none` | +| order matches manifest | `true` | +| payload validation | `true` | + +这证明待执行 payload 与 `docs/test/agent-qc-scenarios.manifest.json` 的 P0 场景完全一致。`status=blocked` 只来自 owner gate:PID `59011` 仍未释放。该 coverage 不是执行证据,不改变官方 Evidence Pack 状态。 + +### 5.36 owner 清空后的 full P0 runbook 已准备(2026-05-11 04:57) + +已新增 post-owner-clear runbook: + +- `.lime/qc/post-owner-clear-full-p0-runbook-2026-05-11-0457.md` +- `.lime/qc/qcloop-p0-single-owner-ready-submit-curl-2026-05-11-0457.txt` + +该 runbook 只记录 owner 清空后的执行顺序,不提交 job。核心顺序: + +1. `agent-qc:process-owner-check -- --check` +2. `agent-qc:gui-owner-check -- --check` +3. `agent-qc:qcloop-preflight -- --require-devbridge --check` +4. 使用 ready payload 提交单一 qcloop server / DB / port 的 full P0 job。 +5. 只在 qcloop 8/8 P0 success 后覆盖官方 `.lime/qc/agent-qc-evidence.json`。 +6. 再执行 release summary、completion audit 和 objective checklist。 + +生成时 guardrails 仍为:未启动 job、未覆盖官方 Evidence、未改 DB、未处理 PID、未执行 git mutation。 + +### 5.37 full P0 payload coverage 已脚本化(2026-05-11 04:59) + +为避免后续继续依赖一次性 Python 片段校验 payload 覆盖,本轮新增可重复门禁: + +- `scripts/agent-qc-payload-coverage.mjs` +- `scripts/lib/agent-qc-payload-coverage-core.mjs` +- `scripts/lib/agent-qc-payload-coverage-core.test.ts` +- `package.json` script:`agent-qc:payload-coverage` + +当前命令已生成最新 coverage sidecar: + +```bash +npm run agent-qc:payload-coverage -- \ + --payload "./.lime/qc/qcloop-p0-single-owner-ready-2026-05-11-0454.json" \ + --format json \ + --output "./.lime/qc/qcloop-p0-single-owner-ready-coverage-current.json" \ + --check +``` + +结果:coverage `passed=true`,missing / extra 均为空,owner status 仍为 `busy`,因此 overall status 是 `blocked`。这把“payload 是否覆盖 manifest P0”的检查从手工 sidecar 提升为可重复工具,但不改变当前发布阻断:PID `59011` 仍未释放,full P0 未启动。 + +### 5.38 completion audit 纳入 payload coverage(2026-05-11 05:02) + +`agent-qc:audit` 已新增 `qcloop-payload-coverage` 检查项,要求 ready payload coverage sidecar 显示 P0 manifest 覆盖完整。最新刷新: + +| 门禁 | 当前值 | 说明 | +| --- | --- | --- | +| completion audit | `17/18`,`status=incomplete` | 新增 payload coverage 项为 PASS | +| payload coverage item | `pass` | `status=blocked coverage=pass manifestP0=8 payloadItems=8 missing=0 extra=0 owner=busy` | +| objective checklist | `5/7`,`status=incomplete` | 仍被 raw process owner 与 official Evidence Pack 阻断 | + +该项只证明 ready payload 覆盖正确,不代表 full P0 已执行。最终发布门禁仍只接受官方 `.lime/qc/agent-qc-evidence.json` 的真实 8/8 P0 pass。 + +### 5.39 执行矩阵纳入 payload coverage gate(2026-05-11 05:04) + +`docs/tests/lime-agent-autonomous-test-execution-matrix.md` 的执行前 Owner Gate 已补充 `npm run agent-qc:payload-coverage`。后续 Agent 在启动 full P0 前,不仅要确认 GUI / raw process / DB lease 清空,还必须确认 ready payload 与 P0 manifest 一致。 + +新增通过条件: + +```text +qcloop-p0-single-owner-ready-coverage-current.json: +coverage.passed=true +missing=[] +extra=[] +``` + +新增阻断条件:ready payload 与 manifest P0 不一致,或 payload coverage sidecar 未生成。该更新把刚新增的脚本纳入人读执行矩阵,避免后续 Agent 只看 owner clear 就直接启动过期 payload。 + +### 5.40 stale owner runbook 纳入精确进程树计划(2026-05-11 05:10) + +`docs/tests/lime-agent-qc-stale-owner-intervention.md` 已新增 8.6,明确确认前可以做只读进程树取证,但不能发送 signal。该节把以下 sidecar 纳入标准处置链: + +- `.lime/qc/stale-raw-gui-owner-process-tree-current.json` +- `.lime/qc/stale-raw-gui-owner-recursive-tree-current.json` +- `.lime/qc/stale-raw-gui-owner-intervention-plan-current.json` + +当前计划显示 recommended scope 为 `process_group`,但仍只是确认后的计划。没有 owner 确认文本前,仍不得处理 PID `59011` 或其 process group。 diff --git a/docs/tests/lime-agent-qc-qcloop-operations.md b/docs/tests/lime-agent-qc-qcloop-operations.md index 5f759a713..fcd1a4202 100644 --- a/docs/tests/lime-agent-qc-qcloop-operations.md +++ b/docs/tests/lime-agent-qc-qcloop-operations.md @@ -467,3 +467,51 @@ npm run agent-qc:gui-owner-check -- \ 2026-05-11 01:28 追加完成审计前只读续刷:`qcloop-status.isolated-p0-full-v1-current.json` 仍为 `stale`,job `1778405842243079000` 保持 `4 success / 1 running / 3 pending / 1 stale`;active item `browser-runtime-site-adapter` stale 约 `27786s`,worker stdout/stderr 仍为 `0 / 0`。`gui-process-owner-current.json` 仍为 `busy`:`activeGuiSmoke=2`、`cargoOrRust=5`、`qcloopRelated=7`。SQLite 只读复核显示 lease 被续约到 `2026-05-11T01:43:06+08:00`。因此仍不能启动完整 `verify:local` 或新的 full GUI P0;completion audit 仍为 `incomplete`,缺口为 `real-qcloop-evidence` 与 `local-verify-gate`。 2026-05-11 01:32 追加 DB lease 取证脚本化:新增 `npm run agent-qc:qcloop-db-lease`,用于从 qcloop SQLite DB 只读导出 active item、lease、attempt stdout/stderr 长度与进程快照,替代临时 `sqlite3` 片段。使用该脚本刷新后,job `1778405842243079000` 仍为 `running`,active item `1778405842246191000` 仍是 `browser-runtime-site-adapter`,`lock_owner=qcloop-worker-1`,`lock_expires_at=2026-05-11T01:47:06+08:00`,active attempt `dc625f8e-b3b9-46b7-9758-4b0273438d50` stdout/stderr 仍为空。raw process owner 仍为 `busy`:`activeGuiSmoke=2`、`cargoOrRust=4`、`qcloopRelated=7`。本轮仍未处理 PID `69738`、未改 DB、未启动完整 `verify:local` 或新 full GUI P0。 + +2026-05-11 02:47 追加 raw process owner 精确化:`agent-qc:process-owner-check` 现在把 observer shell、passive qcloop serve 与 passive Tauri dev runtime 从 active owner 中拆出,只把真正会抢 GUI / 构建 / worker 的进程计入阻断。最新 `.lime/qc/gui-process-owner-current.json` 显示 `activeGuiSmoke=1`、`cargoOrRust=0`、`qcloopRelated=0`、`passiveQcloopServer=6`、`passiveTauriRuntime=4`、`observer=1`;唯一 active blocker 是 PID `59011` 的 `npm run smoke:design-canvas ...`,已运行约 7 小时并被标记为 `staleActiveGuiSmoke=1`。该 sidecar 给出 `ownerIntervention.status=requires_owner_confirmation` 和确认文本;确认前仍不得 kill / pause / interrupt,也不得启动完整 `verify:local` 或新 full GUI P0。 + +2026-05-11 02:51 追加 stale raw GUI owner 处置请求:再次只读刷新后,PID `59011` 仍是唯一 active raw GUI smoke owner,`etime=07:04:53`。已写入 `.lime/qc/stale-raw-gui-owner-intervention-request.json` / `.md`,其中 `guardrails` 明确 `processTerminated=false`、`qcloopDbModified=false`、`officialEvidenceOverwritten=false`、`gitMutation=false`、`newFullP0Started=false`、`verifyLocalStarted=false`。确认前仍不得处理进程或启动重型门禁。 + +2026-05-11 02:55 追加 process owner core 回归:新增 `scripts/lib/agent-qc-process-owner-core.mjs` 与 `scripts/lib/agent-qc-process-owner-core.test.ts`,覆盖 etime 解析、命令脱敏、active GUI owner / passive qcloop serve / passive Tauri runtime / observer 分类,以及 active qcloop worker 与 Cargo build 仍阻断的场景。`npx vitest run scripts/lib/agent-qc-process-owner-core.test.ts scripts/lib/agent-qc-completion-audit-core.test.ts` 通过 `21 tests`。最新 process owner check 仍失败,唯一 active blocker 仍是 PID `59011`。 + +2026-05-11 02:59 追加 objective checklist:新增 `.lime/qc/objective-completion-checklist-current.json` / `.md`,把用户目标逐项映射到 docs/tests、manifest/schema、qcloop tooling、owner gate、官方 Evidence Pack、`verify:local` 和 git guardrail。短暂运行的外部 `verify:gui-smoke` transient owner 已自然结束,最新 process owner 又只剩 PID `59011` 作为 active blocker。 + +2026-05-11 03:01 追加 raw process owner watch history:`agent-qc:process-owner-check -- --watch-history-output ./.lime/qc/raw-process-owner-watch-history.jsonl` 已可追加 JSONL 观察记录。最新记录仍为 `busy`,唯一 active blocker 是 PID `59011`,`etime=07:16:00`。该历史只用于证明持续 stale,不构成处置授权。 + +2026-05-11 03:04 追加 objective checklist 脚本化:新增 `scripts/agent-qc-objective-checklist.mjs` 与 `npm run agent-qc:objective-checklist`。该脚本读取 completion audit、GUI owner、raw process owner,生成 `.lime/qc/objective-completion-checklist-current.json` / `.md`,并在 `--check` 下对 incomplete 状态 exit `1`。当前 checklist 仍为 `4/7`,不会把 sidecar 或 partial pass 误判为完成。 + +2026-05-11 03:07 追加 objective checklist core 回归:新增 `scripts/lib/agent-qc-objective-checklist-core.mjs` 与 `scripts/lib/agent-qc-objective-checklist-core.test.ts`,覆盖 owner clear / owner busy / official evidence fail / verify:local fail / Markdown 渲染。`npx vitest run scripts/lib/agent-qc-objective-checklist-core.test.ts scripts/lib/agent-qc-process-owner-core.test.ts scripts/lib/agent-qc-completion-audit-core.test.ts` 通过 `25 tests`。当前实际 checklist 仍为 `4/7`,`--check` 继续按预期 exit `1`。 + +2026-05-11 03:09 追加 raw GUI owner runbook:`docs/tests/lime-agent-qc-stale-owner-intervention.md` 第 8 节已覆盖 raw GUI owner stale 场景,明确确认文本、禁止动作、post-confirmation 最小闭环和关闭条件。该文档不授权处理 PID `59011`,只为后续 owner 明确确认后的执行提供边界。 + +2026-05-11 03:11 追加 local verify gate 关闭:`.lime/qc/verify-local-current.json` 与 `.lime/qc/verify-gui-smoke-current.json` 已为 `status=pass`。`agent-qc:audit` 当前为 `16/17`,只剩 `real-qcloop-evidence`;`agent-qc:objective-checklist` 当前为 `5/7`,`verify:local` 项已 PASS。raw process owner 仍因 PID `59011` busy,不能启动新的 full P0 qcloop。 + +2026-05-11 03:50 追加隔离 P0 full v2 终态记录:观察到本地已有 job `1778440541478632000`(`lime-agent-qc-isolated-p0-full-v2-2026-05-11-0315`)运行在 `127.0.0.1:18086` 并进入终态 `failed`,`qcloop-status.isolated-p0-full-v2-current.json` 为 `3 success / 5 exhausted / 0 stale`。已导出 sidecar `.lime/qc/agent-qc-evidence.isolated-p0-full-v2-2026-05-11-0315.json`,release summary `--check` 正确 exit `1`。该批次不能覆盖官方 Evidence Pack;本轮未启动新批次、未 kill 进程、未改 DB、未 push。随后 direct `npm run test:contracts` 通过,说明 v2 中 `command-bridge-contract` 的 mock drift 是历史运行证据,不能回写为 pass。 + +2026-05-11 04:24 追加隔离 P0 full v3 终态记录:观察到 job `1778442773271496000`(`lime-agent-qc-isolated-p0-full-v3-2026-05-11-0354`)运行在 `127.0.0.1:18087` 并进入终态 `failed`,`qcloop-status.isolated-p0-full-v3-current.json` 为 `4 success / 4 exhausted / 0 stale`。已导出 sidecar `.lime/qc/agent-qc-evidence.isolated-p0-full-v3-2026-05-11-0354.json`,release summary `--check` 正确 exit `1`。v3 pass 场景为 `command-bridge-contract`、`claw-chat-ready-streaming`、`tool-approval-sandbox-boundary`、`harness-replay-regression`;剩余失败 / blocked 场景为 `skill-forge-register-bind-enable`、`browser-runtime-site-adapter`、`workspace-ready-session-restore`、`release-package-startup-smoke`。最新 process owner 仍为 `busy`:activeGuiSmoke=`1`、qcloopRelated=`0`,cargoOrRust 数量随外部编译进度波动。PID `59011` 仍是 stale `smoke:design-canvas`,`etime≈8h41m`;同时外部 Rust 定向测试 / rustc 编译仍在跑。因此仍不得覆盖官方 `.lime/qc/agent-qc-evidence.json`,也不得发布。 + +2026-05-11 04:31 追加 Skill Forge P0 定向补证:v3 中 `skill-forge-register-bind-enable` 失败的核心原因是此前 `smoke:agent-service-skill-entry` 的 Rust exact filter 实际运行 0 tests,verifier 正确拒绝 runtime / SkillTool gate 证据。随后观察到当前 `scripts/agent-service-skill-entry-smoke.mjs` 已改为显式 `-p lime` / `-p lime-agent`、完整 test path + `--exact`,并拒绝没有 `N passed` 的 Rust 定向测试。证据 `.lime/qc/smoke-agent-service-skill-entry-after-rust-exact-fix-2026-05-11-0430.log` 显示当前 smoke 已通过,关键 Rust tests 均出现 `running 1 test` / `1 passed`,服务技能入口路由与 Agent A2UI 挂起主链 Vitest 也通过。该结果只关闭 Skill Forge 的本地定向 smoke 缺口,不能回写 v3 sidecar,也不能替代新的 single-owner full P0。 + +2026-05-11 04:32 追加 owner 复核:外部 Rust/Cargo owner 已自然清空,`agent-qc:process-owner-check` 最新 sidecar 为 `activeGuiSmoke=1`、`cargoOrRust=0`、`qcloopRelated=0`、`staleActiveGuiSmoke=1`。唯一 active blocker 仍是 PID `59011` 的 stale `smoke:design-canvas`;确认前仍不得 kill / pause / interrupt,也不得启动新的 full GUI P0。 + +2026-05-11 04:39 追加 Skill Forge 单项 qcloop 补证终态:job `1778445171616868000`(`lime-agent-qc-skill-forge-rust-exact-fix-2026-05-11-0432`)在 `127.0.0.1:18087` 进入终态 `failed`,`0 success / 1 exhausted / 0 stale`。已导出 `.lime/qc/agent-qc-evidence.skill-forge-rust-exact-fix.json`,release summary `--check` 正确 exit `1`,因为该 sidecar 只覆盖单项且仍缺 runtime-transcript 层。该批次证明 deterministic smoke 侧已不再卡在 Rust exact filter / running 0 tests;新的真实缺口是 `skill-forge-register-bind-enable` 需要 live submit / stream / tool-request / decision / result 级 runtime transcript artifact。 + +2026-05-11 04:44 追加 post-job runtime transcript 与 owner 复核:再次只读刷新后,qcloop worker 进程已清空,raw process owner 仍为 `busy`,唯一 active blocker 是 PID `59011` 的 stale `smoke:design-canvas`,`activeGuiSmoke=1`、`cargoOrRust=0`、`qcloopRelated=0`、`staleActiveGuiSmoke=1`。同时 `.lime/qc/skill-forge-runtime-transcript-current.json` 已生成,`scenarioId=skill-forge-register-bind-enable`、`result=pass`、`evidenceLayersCovered=deterministic-smoke,runtime-transcript`、`runtimeTranscript.events=8`。该 artifact 生成时间晚于单项 qcloop 失败,不能回写 `1778445171616868000`,只能作为下一轮 single-owner 单项重跑的输入或对照。当前仍未 kill / pause / interrupt PID `59011`,未改 qcloop DB,未覆盖官方 Evidence Pack,未执行 git commit / push / tag / release。 + +2026-05-11 04:48 追加 Skill Forge runtime transcript 单项通过:观察到 job `1778445676473687000`(`lime-agent-qc-skill-forge-runtime-transcript-2026-05-11-0441`)已 completed,`qcloop-status.skill-forge-runtime-transcript-current.json` 为 `1 success / 0 stale`。已导出 sidecar `.lime/qc/agent-qc-evidence.skill-forge-runtime-transcript.json`,其中 `skill-forge-register-bind-enable` 为 `pass`。同时运行 release summary `--check` 到 `.lime/qc/release-agent-qc.sidecar-skill-forge-runtime-transcript.md`,按预期 exit `1`,因为单项 sidecar 缺少其余 7 个 P0 场景。该结果可以作为下一轮 full P0 的前置信号,但不能覆盖官方 `.lime/qc/agent-qc-evidence.json`。 + +2026-05-11 04:52 追加 stale raw GUI owner 处置请求刷新:`agent-qc:process-owner-check` 仍显示 `activeGuiSmoke=1`、`cargoOrRust=0`、`qcloopRelated=0`、`staleActiveGuiSmoke=1`,唯一 active blocker 是 PID `59011`,`etime=09:06:20`。已刷新 `.lime/qc/stale-raw-gui-owner-intervention-request.json` / `.md`,确认文本保持 `确认处理 stale raw GUI owner PID 59011,可以终止这些进程并记录 sidecar。`。确认前仍未 kill / pause / interrupt,未改 DB,未启动 full GUI P0,未覆盖官方 Evidence Pack。 + +2026-05-11 04:54 追加 single-owner full P0 ready payload:已运行 `agent-qc:qcloop-job -- --risk P0 --check` 生成 `.lime/qc/qcloop-p0-single-owner-ready-2026-05-11-0454.json`,并写入说明 `.lime/qc/qcloop-p0-single-owner-ready-2026-05-11-0454.md`。该 payload 覆盖 8 个 P0 scenario 且 `_validation.valid=true`,但仅为待执行产物,没有提交 qcloop job、没有启动 GUI P0、没有覆盖官方 Evidence Pack。启动前仍必须先通过 `agent-qc:process-owner-check -- --check`、`agent-qc:gui-owner-check -- --check` 与 `agent-qc:qcloop-preflight -- --require-devbridge --check`。 + +2026-05-11 04:55 追加 ready payload coverage:新增 `.lime/qc/qcloop-p0-single-owner-ready-coverage-2026-05-11-0455.json` / `.md`,比较 ready payload 与 `docs/test/agent-qc-scenarios.manifest.json`。结果为 manifest P0 count=`8`、payload item count=`8`、missing=`none`、extra=`none`、orderMatchesManifest=`true`、payloadValidation=`true`;整体 status 仍为 `blocked`,因为 raw process owner gate 仍 busy。该 coverage 只证明待执行 payload 完整,不是 qcloop 执行结果。 + +2026-05-11 04:57 追加 post-owner-clear full P0 runbook:新增 `.lime/qc/post-owner-clear-full-p0-runbook-2026-05-11-0457.md` 与 `.lime/qc/qcloop-p0-single-owner-ready-submit-curl-2026-05-11-0457.txt`。该 runbook 明确 owner 清空后先跑 process owner / GUI owner / DevBridge preflight,再使用 dedicated qcloop port 提交 ready payload;只有 8/8 P0 success 后才允许覆盖官方 `.lime/qc/agent-qc-evidence.json` 并运行 release summary / audit / objective checklist。生成时未提交 job,未改 DB,未覆盖官方 evidence。 + +2026-05-11 04:59 追加 payload coverage 脚本化:新增 `npm run agent-qc:payload-coverage`,由 `scripts/agent-qc-payload-coverage.mjs` 读取 manifest、ready payload 和 raw process owner sidecar,输出 coverage / owner gate 状态。当前 `.lime/qc/qcloop-p0-single-owner-ready-coverage-current.json` 显示 coverage `passed=true`、missing=`[]`、extra=`[]`、owner=`busy`、status=`blocked`。`scripts/lib/agent-qc-payload-coverage-core.test.ts` 已覆盖 P0 提取、字符串 item 解析、ready / blocked / missing 场景。 + +2026-05-11 05:02 追加 completion audit 同步:`agent-qc:audit` 新增 `qcloop-payload-coverage` 检查项,最新 `.lime/qc/objective-completion-audit-current.json` 为 `17/18`,只剩 `real-qcloop-evidence`。该同步把 ready payload coverage 纳入目标审计,但不放宽发布门禁;payload coverage pass 仍不能替代 qcloop 8/8 full P0 pass。 + +2026-05-11 05:04 追加执行矩阵同步:`docs/tests/lime-agent-autonomous-test-execution-matrix.md` 的执行前 Owner Gate 已加入 `npm run agent-qc:payload-coverage`,并把 payload coverage sidecar 缺失或 manifest 不一致列为阻断条件。该更新保证后续启动 full P0 前同时满足 owner clear、DB lease clear、DevBridge preflight 和 payload coverage pass。 + +2026-05-11 05:10 追加 stale owner runbook 同步:`docs/tests/lime-agent-qc-stale-owner-intervention.md` 第 8.6 节已纳入精确进程树与 intervention plan sidecar,要求确认前只读生成 process tree / recursive tree / intervention plan,确认后也必须重新生成以防 PID / PGID 变化。该更新不授权当前 Agent 处理 PID `59011`,只把后续 owner 确认后的最小处置范围固化到长期 runbook。 diff --git a/docs/tests/lime-agent-qc-stale-owner-intervention.md b/docs/tests/lime-agent-qc-stale-owner-intervention.md index 37ae957cd..e042648b1 100644 --- a/docs/tests/lime-agent-qc-stale-owner-intervention.md +++ b/docs/tests/lime-agent-qc-stale-owner-intervention.md @@ -162,3 +162,102 @@ node scripts/agent-qc-completion-audit.mjs --format json > ".lime/qc/agent-qc-au ``` 如果 release summary 报缺少结构化 evidenceRefs,不能手工补假路径;应回到 qcloop worker stdout,确认 `QCLOOP_EVIDENCE_SUMMARY_JSON` 的 `artifacts[]`、`gui_session_owner` 和 `release_scope` 是否真实存在。 + +## 8. raw GUI owner stale 处置补充 + +当 `agent-qc:gui-owner-check -- --check` 已通过,但 `agent-qc:process-owner-check -- --check` 仍因 raw GUI smoke 进程阻断时,按本节处理。该场景不是 qcloop job owner,而是宿主机上仍存活的 GUI smoke / Playwright / Chrome owner。 + +当前典型状态: + +```text +gui owner: pass +process owner: busy +activeGuiSmoke=1 +staleActiveGuiSmoke=1 +ownerIntervention=requires_owner_confirmation +PID=59011 +command=npm run smoke:design-canvas ... +``` + +### 8.1 只读取证 + +```bash +npm run agent-qc:process-owner-check -- \ + --format json \ + --output "./.lime/qc/gui-process-owner-current.json" \ + --markdown-output "./.lime/qc/gui-process-owner-current.md" \ + --watch-history-output "./.lime/qc/raw-process-owner-watch-history.jsonl" + +npm run agent-qc:objective-checklist -- \ + --format json \ + --output "./.lime/qc/objective-completion-checklist-current.json" +``` + +机器可读处置请求位于: + +- `.lime/qc/stale-raw-gui-owner-intervention-request.json` +- `.lime/qc/stale-raw-gui-owner-intervention-request.md` + +### 8.2 禁止操作 + +确认前禁止: + +- `kill` / `pkill` / `killall` raw GUI smoke、Playwright 或 Chrome 进程。 +- 启动完整 `npm run verify:local`。 +- 启动新的 full GUI P0 qcloop 批次。 +- 覆盖官方 `.lime/qc/agent-qc-evidence.json`。 +- 执行 git commit / push / tag / release。 + +### 8.3 raw owner 确认格式 + +必须看到完整确认文本后才能处理: + +```text +确认处理 stale raw GUI owner PID 59011,可以终止这些进程并记录 sidecar。 +``` + +如果 PID 已变化,必须重新运行 `agent-qc:process-owner-check`,使用最新 `ownerIntervention.requiredConfirmationText`,不得复用旧 PID。 + +### 8.4 获得确认后的最小闭环 + +1. 再次运行 `agent-qc:process-owner-check`,确认 PID、PGID、命令和 `etime`。 +2. 记录 before sidecar,包含 PID、PGID、命令、确认文本和禁止动作清单。 +3. 只处理 owner 明确确认的 PID / 进程组;不要顺手清理 passive qcloop serve、passive Tauri runtime 或 observer shell。 +4. 处理后立即运行: + +```bash +npm run agent-qc:process-owner-check -- --check +npm run agent-qc:gui-owner-check -- --check +``` + +5. 两个 owner gate 都通过后,先跑完整 `npm run verify:local`;只有 verify local pass 后,才允许进入新的 single-owner full P0 qcloop。 + +### 8.5 关闭条件 + +raw owner stale 处置完成必须同时满足: + +- `agent-qc:process-owner-check -- --check` 通过。 +- `agent-qc:gui-owner-check -- --check` 通过。 +- action sidecar 证明只处理了 owner 确认的 PID / PGID。 +- `objective-completion-checklist-current.json` 不再把 raw process owner 列为 blocker。 + +### 8.6 精确进程树与处置计划 + +确认前可以继续做只读进程树取证,但不能发送 signal。当前标准 sidecar: + +- `.lime/qc/stale-raw-gui-owner-process-tree-current.json` +- `.lime/qc/stale-raw-gui-owner-recursive-tree-current.json` +- `.lime/qc/stale-raw-gui-owner-intervention-plan-current.json` + +这些 sidecar 的用途是缩小确认后的处置范围,而不是授权处置。若 `intervention-plan-current.json` 显示: + +```text +recommendedScope=process_group +rootPid=59011 +rootPgid=59011 +processGroupPids=59011,59100 +``` + +则确认后的默认动作是只处理该已确认 process group;递归树中属于子 shell / browser helper 的 PID 只作为佐证,不允许额外扩大到 passive qcloop serve、passive Tauri runtime、系统 Chrome 或其他 observer shell。 + +处置前必须重新生成 `recursive-tree-current` 和 `intervention-plan-current`,以防 PID / PGID 被系统复用或子进程已自然退出。若最新计划与确认文本中的 PID 不一致,停止并重新请求 owner 确认。 diff --git a/package-lock.json b/package-lock.json index 4f29afb25..57df91766 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "lime", - "version": "1.33.0", + "version": "1.34.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.33.0", + "version": "1.34.0", + "license": "GPL-3.0-only", "dependencies": { "@babel/standalone": "^7.29.0", "@fabianlars/tauri-plugin-oauth": "^2", @@ -27,9 +28,9 @@ "@radix-ui/react-tooltip": "^1.1.3", "@sentry/browser": "^8.55.0", "@tanstack/react-virtual": "^3.13.18", - "@tauri-apps/api": "2.10.1", - "@tauri-apps/plugin-deep-link": "2.4.7", - "@tauri-apps/plugin-dialog": "2.6.0", + "@tauri-apps/api": "2.11.0", + "@tauri-apps/plugin-deep-link": "2.4.9", + "@tauri-apps/plugin-dialog": "2.7.1", "@tauri-apps/plugin-global-shortcut": "2.3.1", "@tauri-apps/plugin-shell": "2.3.5", "@tiptap/core": "^3.19.0", @@ -94,7 +95,7 @@ "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@eslint/js": "^9.15.0", "@fast-check/vitest": "^0.2.4", - "@tauri-apps/cli": "^2.10.1", + "@tauri-apps/cli": "^2.11.1", "@types/babel__standalone": "^7.1.9", "@types/fabric": "^5.3.11", "@types/node": "^22.9.0", @@ -3465,9 +3466,9 @@ } }, "node_modules/@tauri-apps/api": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", - "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", + "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -3475,9 +3476,9 @@ } }, "node_modules/@tauri-apps/cli": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", - "integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.1.tgz", + "integrity": "sha512-rpEbaJ/HzNb6fwsquwoAbq29/Vt4gADhS423A8fdkwL4edJ0wZmoB8ar7O6JPDL834MUKOCm/rrJ7c9oAaEaYQ==", "dev": true, "license": "Apache-2.0 OR MIT", "bin": { @@ -3491,23 +3492,23 @@ "url": "https://opencollective.com/tauri" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.10.1", - "@tauri-apps/cli-darwin-x64": "2.10.1", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", - "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", - "@tauri-apps/cli-linux-arm64-musl": "2.10.1", - "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", - "@tauri-apps/cli-linux-x64-gnu": "2.10.1", - "@tauri-apps/cli-linux-x64-musl": "2.10.1", - "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", - "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", - "@tauri-apps/cli-win32-x64-msvc": "2.10.1" + "@tauri-apps/cli-darwin-arm64": "2.11.1", + "@tauri-apps/cli-darwin-x64": "2.11.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.1", + "@tauri-apps/cli-linux-arm64-musl": "2.11.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.1", + "@tauri-apps/cli-linux-x64-gnu": "2.11.1", + "@tauri-apps/cli-linux-x64-musl": "2.11.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.1", + "@tauri-apps/cli-win32-x64-msvc": "2.11.1" } }, "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", - "integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.1.tgz", + "integrity": "sha512-6eEKMBXsQPCuM1EmvrjT2+aBuxWQuFdKdW8pzNuNQtpq45nEEpBlD5gr8pUeAyOU1DQKlkFaEc/MPBxb/Pfjtg==", "cpu": [ "arm64" ], @@ -3522,9 +3523,9 @@ } }, "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", - "integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.1.tgz", + "integrity": "sha512-LQUO7exfRWjWALNhetph5guWpMeHphRpokOLk0OIbTTExaNwJNFu3I4vb+CCM/4G/QGoZe/5XikZOJdNEFP1ig==", "cpu": [ "x64" ], @@ -3539,9 +3540,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", - "integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.1.tgz", + "integrity": "sha512-5i/awiBCRRhOUG8yjn0fMHXIWD5Ez8eEk5LtvOxyQrKuJkRaZDvnbIjZbE183blAwkoA4xN3aO/prJiqscl02Q==", "cpu": [ "arm" ], @@ -3556,9 +3557,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", - "integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.1.tgz", + "integrity": "sha512-9LrwDw3S9Fygtw/Q6WDhOP+3svJRGAsejeE+GKrc0eO1ThMVhwi2LL6hw4dlKw93IfS7VY1G19sWGxJ/NcU4nA==", "cpu": [ "arm64" ], @@ -3573,9 +3574,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", - "integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.1.tgz", + "integrity": "sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==", "cpu": [ "arm64" ], @@ -3590,9 +3591,9 @@ } }, "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", - "integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.1.tgz", + "integrity": "sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==", "cpu": [ "riscv64" ], @@ -3607,9 +3608,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", - "integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.1.tgz", + "integrity": "sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==", "cpu": [ "x64" ], @@ -3624,9 +3625,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", - "integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.1.tgz", + "integrity": "sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==", "cpu": [ "x64" ], @@ -3641,9 +3642,9 @@ } }, "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", - "integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.1.tgz", + "integrity": "sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==", "cpu": [ "arm64" ], @@ -3658,9 +3659,9 @@ } }, "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", - "integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.1.tgz", + "integrity": "sha512-VBGkuH0eB9K9LLSMv361Gzr5Ou72sCS4+ztpmkWEQ+wd/amhcYOsf3X6qn1RJZDzIhiOYHJEOysZUC3baD01rA==", "cpu": [ "ia32" ], @@ -3675,9 +3676,9 @@ } }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", - "integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.1.tgz", + "integrity": "sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A==", "cpu": [ "x64" ], @@ -3692,21 +3693,21 @@ } }, "node_modules/@tauri-apps/plugin-deep-link": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.7.tgz", - "integrity": "sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==", + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.9.tgz", + "integrity": "sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.10.1" + "@tauri-apps/api": "^2.11.0" } }, "node_modules/@tauri-apps/plugin-dialog": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", - "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.8.0" + "@tauri-apps/api": "^2.11.0" } }, "node_modules/@tauri-apps/plugin-global-shortcut": { diff --git a/package.json b/package.json index 23c1ff84e..af4d656e4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.33.0", + "version": "1.34.0", "description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.", "keywords": [ "ai", @@ -84,6 +84,7 @@ "agent-qc:gui-flow:check": "node scripts/agent-qc-gui-flow-report.mjs --check --format json", "agent-qc:check": "node scripts/agent-qc-report.mjs --check --format json && node scripts/agent-qc-gui-flow-report.mjs --check --format json", "agent-qc:qcloop-job": "node scripts/agent-qc-qcloop-job.mjs", + "agent-qc:payload-coverage": "node scripts/agent-qc-payload-coverage.mjs", "agent-qc:qcloop-status": "node scripts/agent-qc-qcloop-status.mjs", "agent-qc:qcloop-db-lease": "node scripts/agent-qc-qcloop-db-lease.mjs", "agent-qc:qcloop-preflight": "node scripts/agent-qc-qcloop-preflight.mjs", @@ -127,7 +128,8 @@ "smoke:agent-runtime-approval-sandbox": "node scripts/agent-runtime-approval-sandbox-smoke.mjs", "agent-qc:gui-owner-check": "node scripts/agent-qc-gui-owner-check.mjs", "agent-qc:process-owner-check": "node scripts/agent-qc-process-owner-check.mjs", - "agent-qc:secret-scan": "node scripts/agent-qc-secret-scan.mjs" + "agent-qc:secret-scan": "node scripts/agent-qc-secret-scan.mjs", + "agent-qc:objective-checklist": "node scripts/agent-qc-objective-checklist.mjs" }, "dependencies": { "@babel/standalone": "^7.29.0", @@ -149,9 +151,9 @@ "@radix-ui/react-tooltip": "^1.1.3", "@sentry/browser": "^8.55.0", "@tanstack/react-virtual": "^3.13.18", - "@tauri-apps/api": "2.10.1", - "@tauri-apps/plugin-deep-link": "2.4.7", - "@tauri-apps/plugin-dialog": "2.6.0", + "@tauri-apps/api": "2.11.0", + "@tauri-apps/plugin-deep-link": "2.4.9", + "@tauri-apps/plugin-dialog": "2.7.1", "@tauri-apps/plugin-global-shortcut": "2.3.1", "@tauri-apps/plugin-shell": "2.3.5", "@tiptap/core": "^3.19.0", @@ -216,7 +218,7 @@ "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@eslint/js": "^9.15.0", "@fast-check/vitest": "^0.2.4", - "@tauri-apps/cli": "^2.10.1", + "@tauri-apps/cli": "^2.11.1", "@types/babel__standalone": "^7.1.9", "@types/fabric": "^5.3.11", "@types/node": "^22.9.0", diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index 52d812722..d4d6f2897 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.33.0", + "version": "1.34.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/scripts/agent-qc-completion-audit.mjs b/scripts/agent-qc-completion-audit.mjs index 8fb1bdbf9..4063c3d93 100644 --- a/scripts/agent-qc-completion-audit.mjs +++ b/scripts/agent-qc-completion-audit.mjs @@ -144,6 +144,11 @@ function readQcloopStatusSidecars(dirPath) { const status = readJson(relativePath); return { path: relativePath, + generatedAt: status?.generatedAt || "", + jobId: status?.job?.id || status?.jobId || "", + jobStatus: status?.job?.status || "", + jobTerminal: Boolean(status?.job?.terminal), + jobFinishedAt: status?.job?.finishedAt || "", verdictStatus: status?.verdict?.status || "", verdictSummary: status?.verdict?.summary || "", counts: status?.counts || null, @@ -196,6 +201,7 @@ function loadFacts() { testsReadme: exists("docs/tests/README.md"), evidenceSchema: exists("docs/test/agent-qc-evidence.schema.json"), qcloopJobScript: exists("scripts/agent-qc-qcloop-job.mjs"), + payloadCoverageScript: exists("scripts/agent-qc-payload-coverage.mjs"), guiOwnerCheckScript: exists("scripts/agent-qc-gui-owner-check.mjs"), qcloopStatusScript: exists("scripts/agent-qc-qcloop-status.mjs"), qcloopPreflightScript: exists("scripts/agent-qc-qcloop-preflight.mjs"), @@ -219,6 +225,12 @@ function loadFacts() { failedStage: "", error: "", }), + payloadCoverage: readOptionalJson(".lime/qc/qcloop-p0-single-owner-ready-coverage-current.json", { + exists: false, + status: "", + coverage: null, + ownerGate: null, + }), realEvidenceSidecars: readEvidenceSidecars(".lime/qc"), qcloopStatusSidecars: readQcloopStatusSidecars(".lime/qc"), scenarioReport, diff --git a/scripts/agent-qc-objective-checklist.mjs b/scripts/agent-qc-objective-checklist.mjs new file mode 100644 index 000000000..0079305f0 --- /dev/null +++ b/scripts/agent-qc-objective-checklist.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { + buildAgentQcObjectiveChecklist, + renderAgentQcObjectiveChecklistMarkdown, +} from "./lib/agent-qc-objective-checklist-core.mjs"; + +function parseArgs(argv) { + const result = { + auditPath: ".lime/qc/objective-completion-audit-current.json", + processOwnerPath: ".lime/qc/gui-process-owner-current.json", + guiOwnerPath: ".lime/qc/gui-owner-current.json", + format: "markdown", + outputPath: "", + check: false, + help: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--audit" && argv[index + 1]) { + result.auditPath = String(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--process-owner" && argv[index + 1]) { + result.processOwnerPath = String(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--gui-owner" && argv[index + 1]) { + result.guiOwnerPath = String(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--format" && argv[index + 1]) { + result.format = String(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--output" && argv[index + 1]) { + result.outputPath = String(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--check") { + result.check = true; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + return result; +} + +function printHelp() { + console.log(` +Lime Agent QC Objective Checklist + +用法: + npm run agent-qc:objective-checklist -- --format json --output ./.lime/qc/objective-completion-checklist-current.json + node scripts/agent-qc-objective-checklist.mjs --check + +选项: + --audit PATH completion audit JSON,默认 .lime/qc/objective-completion-audit-current.json + --process-owner PATH raw process owner JSON,默认 .lime/qc/gui-process-owner-current.json + --gui-owner PATH GUI owner JSON,默认 .lime/qc/gui-owner-current.json + --format FMT markdown | json,默认 markdown + --output PATH 写入文件;默认 stdout + --check checklist 未 complete 时非 0 退出 + -h, --help 显示帮助 +`); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), filePath), "utf8")); +} + +function writeOutput(outputPath, content) { + if (!outputPath) { + process.stdout.write(content); + return; + } + const resolvedOutputPath = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.writeFileSync(resolvedOutputPath, content, "utf8"); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + const result = buildAgentQcObjectiveChecklist({ + audit: readJson(options.auditPath), + processOwner: readJson(options.processOwnerPath), + guiOwner: readJson(options.guiOwnerPath), + }); + const content = + options.format === "json" + ? `${JSON.stringify(result, null, 2)}\n` + : renderAgentQcObjectiveChecklistMarkdown(result); + writeOutput(options.outputPath, content); + if (options.check && result.status !== "complete") { + process.exit(1); + } +} + +main(); diff --git a/scripts/agent-qc-payload-coverage.mjs b/scripts/agent-qc-payload-coverage.mjs new file mode 100644 index 000000000..3c1fc0e5a --- /dev/null +++ b/scripts/agent-qc-payload-coverage.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { + buildAgentQcPayloadCoverageReport, + renderAgentQcPayloadCoverageMarkdown, +} from "./lib/agent-qc-payload-coverage-core.mjs"; + +function parseArgs(argv) { + const options = { + check: false, + format: "markdown", + help: false, + manifestPath: "docs/test/agent-qc-scenarios.manifest.json", + outputPath: "", + payloadPath: "", + processOwnerPath: ".lime/qc/gui-process-owner-current.json", + requireOwnerClear: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--manifest" && argv[index + 1]) { + options.manifestPath = argv[index + 1]; + index += 1; + continue; + } + if (arg === "--payload" && argv[index + 1]) { + options.payloadPath = argv[index + 1]; + index += 1; + continue; + } + if (arg === "--process-owner" && argv[index + 1]) { + options.processOwnerPath = argv[index + 1]; + index += 1; + continue; + } + if (arg === "--output" && argv[index + 1]) { + options.outputPath = argv[index + 1]; + index += 1; + continue; + } + if (arg === "--format" && argv[index + 1]) { + options.format = argv[index + 1]; + index += 1; + continue; + } + if (arg === "--require-owner-clear") { + options.requireOwnerClear = true; + continue; + } + if (arg === "--check") { + options.check = true; + continue; + } + if (arg === "--help" || arg === "-h") { + options.help = true; + } + } + return options; +} + +function printHelp() { + console.log(` +Lime Agent QC Payload Coverage + +用法: + npm run agent-qc:payload-coverage -- --payload ./.lime/qc/qcloop-p0.json + node scripts/agent-qc-payload-coverage.mjs --payload ./.lime/qc/qcloop-p0.json --format json --check + +选项: + --manifest PATH Agent QC scenario manifest,默认 docs/test/agent-qc-scenarios.manifest.json + --payload PATH qcloop job payload JSON + --process-owner PATH raw process owner JSON,默认 .lime/qc/gui-process-owner-current.json + --output PATH 写入文件;默认 stdout + --format FMT markdown | json,默认 markdown + --require-owner-clear --check 时要求 owner gate 也为 ready + --check coverage 不完整时非 0 退出 + -h, --help 显示帮助 +`); +} + +function readJson(filePath, { optional = false } = {}) { + if (!filePath) { + if (optional) return undefined; + throw new Error("缺少 JSON 路径。"); + } + const resolved = path.resolve(process.cwd(), filePath); + if (optional && !fs.existsSync(resolved)) { + return undefined; + } + return JSON.parse(fs.readFileSync(resolved, "utf8")); +} + +function writeOutput(outputPath, content) { + if (!outputPath) { + process.stdout.write(content); + return; + } + const resolved = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + fs.writeFileSync(resolved, content, "utf8"); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + if (!options.payloadPath) { + throw new Error("必须提供 --payload。"); + } + const report = buildAgentQcPayloadCoverageReport({ + manifest: readJson(options.manifestPath), + payload: readJson(options.payloadPath), + processOwner: readJson(options.processOwnerPath, { optional: true }), + manifestPath: options.manifestPath, + payloadPath: options.payloadPath, + }); + const content = + options.format === "json" + ? `${JSON.stringify(report, null, 2)}\n` + : renderAgentQcPayloadCoverageMarkdown(report); + writeOutput(options.outputPath, content); + + if (options.check && !report.coverage.passed) { + process.exitCode = 1; + } + if (options.check && options.requireOwnerClear && report.status !== "ready") { + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error(`[agent-qc:payload-coverage] ${error.message}`); + process.exitCode = 1; +}); diff --git a/scripts/agent-qc-process-owner-check.mjs b/scripts/agent-qc-process-owner-check.mjs index 1c0ca9e68..095b9cfac 100755 --- a/scripts/agent-qc-process-owner-check.mjs +++ b/scripts/agent-qc-process-owner-check.mjs @@ -5,38 +5,11 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; -const GUI_OWNER_PATTERNS = [ - "verify:gui-smoke", - "smoke:workspace-ready", - "smoke:browser-runtime", - "smoke:site-adapters", - "smoke:agent-service-skill-entry", - "smoke:agent-runtime-tool-surface", - "smoke:knowledge-gui", - "smoke:design-canvas", - "claw-chat-ready-streaming", - "browser-runtime-site-adapter", - "workspace-ready-session-restore", - "release-package-startup-smoke", -]; - -const CARGO_OWNER_PATTERNS = [ - "cargo ", - "cargo-fmt", - "rustc ", - "clippy-driver", - "tauri dev", -]; - -const QCLOOP_OWNER_PATTERNS = [ - "qcloop --db", - "./qcloop --db", - "qcloop serve", - "qcloop-worker", - "qcloop_worker_result", - "agent qc p0", - "只读执行 lime agent qc p0", -]; +import { + createAgentQcProcessOwnerReport, + parseEtimeSeconds, + sanitizeProcessCommand, +} from "./lib/agent-qc-process-owner-core.mjs"; function parseArgs(argv) { const result = { @@ -48,6 +21,8 @@ function parseArgs(argv) { maxCargoOrRust: 0, maxQcloopRelated: 0, outputPath: "", + staleMinutes: 30, + watchHistoryOutputPath: "", }; for (let index = 0; index < argv.length; index += 1) { @@ -67,6 +42,11 @@ function parseArgs(argv) { index += 1; continue; } + if (arg === "--watch-history-output" && argv[index + 1]) { + result.watchHistoryOutputPath = String(argv[index + 1]).trim(); + index += 1; + continue; + } if (arg === "--max-active-gui-smoke" && argv[index + 1]) { result.maxActiveGuiSmoke = Number(argv[index + 1]); index += 1; @@ -82,6 +62,11 @@ function parseArgs(argv) { index += 1; continue; } + if (arg === "--stale-minutes" && argv[index + 1]) { + result.staleMinutes = Number(argv[index + 1]); + index += 1; + continue; + } if (arg === "--check") { result.check = true; continue; @@ -105,9 +90,11 @@ Lime Agent QC Process Owner Check --format FMT summary | json,默认 summary --output PATH 写入 JSON 或 summary;默认 stdout --markdown-output PATH 额外写入 Markdown 摘要 + --watch-history-output PATH 追加写入 JSONL 观察历史 --max-active-gui-smoke N 允许的 GUI smoke / deep flow owner 数,默认 0 --max-cargo-or-rust N 允许的 Cargo / Rust 构建进程数,默认 0 --max-qcloop-related N 允许的 qcloop 相关进程数,默认 0 + --stale-minutes N 标记 active GUI owner stale 的分钟数,默认 30 --check owner 超过上限时非 0 退出 -h, --help 显示帮助 `); @@ -118,12 +105,14 @@ function parseUnixPsLine(line) { if (!match) { return null; } + const etime = match[5]; return { pid: Number(match[1]), ppid: Number(match[2]), pgid: Number(match[3]), stat: match[4], - etime: match[5], + etime, + durationSeconds: parseEtimeSeconds(etime), command: sanitizeProcessCommand(match[6].trim()), }; } @@ -158,97 +147,28 @@ function collectWindowsProcesses() { pgid: null, stat: "unknown", etime: "unknown", + durationSeconds: null, command: sanitizeProcessCommand(String(entry?.CommandLine || "").trim()), })) .filter((entry) => entry.pid > 0 && entry.pid !== process.pid && entry.command.length > 0); } -function sanitizeProcessCommand(command) { - return String(command || "") - .replace(/(--api-key(?:=|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1") - .replace(/(api[_-]?key(?:=|:|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1") - .replace(/ctx7sk-[A-Za-z0-9-]+/g, "ctx7sk-***") - .replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-***"); -} - function collectProcesses() { return process.platform === "win32" ? collectWindowsProcesses() : collectUnixProcesses(); } -function commandHasAny(command, patterns) { - const normalized = String(command || "").toLowerCase(); - return patterns.some((pattern) => normalized.includes(pattern.toLowerCase())); -} - -function uniqueByPid(entries) { - const seen = new Set(); - const result = []; - for (const entry of entries) { - if (seen.has(entry.pid)) { - continue; - } - seen.add(entry.pid); - result.push(entry); - } - return result.sort((left, right) => left.pid - right.pid); -} - -function createReport({ - generatedAt = new Date().toISOString(), - maxActiveGuiSmoke = 0, - maxCargoOrRust = 0, - maxQcloopRelated = 0, -} = {}) { - const processes = collectProcesses(); - const activeGuiSmokeProcesses = uniqueByPid( - processes.filter((entry) => commandHasAny(entry.command, GUI_OWNER_PATTERNS)), - ); - const qcloopProcesses = uniqueByPid( - processes.filter((entry) => commandHasAny(entry.command, QCLOOP_OWNER_PATTERNS)), - ); - const cargoProcesses = uniqueByPid( - processes.filter((entry) => commandHasAny(entry.command, CARGO_OWNER_PATTERNS)), - ); - - const counts = { - activeGuiSmoke: activeGuiSmokeProcesses.length, - cargoOrRust: cargoProcesses.length, - qcloopRelated: qcloopProcesses.length, - }; - const busy = - counts.activeGuiSmoke > maxActiveGuiSmoke || - counts.cargoOrRust > maxCargoOrRust || - counts.qcloopRelated > maxQcloopRelated; - - return { - schemaVersion: "v1", - generatedAt, +function createReport(options = {}) { + return createAgentQcProcessOwnerReport(collectProcesses(), { + ...options, platform: process.platform, - maxActiveGuiSmoke, - maxCargoOrRust, - maxQcloopRelated, - verdict: { - status: busy ? "busy" : "pass", - summary: `activeGuiSmoke=${counts.activeGuiSmoke}, cargoOrRust=${counts.cargoOrRust}, qcloopRelated=${counts.qcloopRelated}`, - nextAction: busy - ? "Do not start full verify:local or another GUI P0 batch while these owners are active; continue read-only observation or wait for natural completion." - : "No active raw GUI smoke, Cargo/Rust, or qcloop owner was observed; heavy gates may run if qcloop GUI owner and release evidence gates are also clear.", - }, - activeGuiSmokeProcesses, - qcloopProcesses, - cargoProcesses, - guardrails: [ - "best-effort process snapshot only", - "do not kill or restart listed processes from this sidecar", - "use this sidecar to decide whether heavy GUI or verify gates should wait", - ], - }; + }); } function renderSummary(report) { const lines = [ `status=${report.verdict.status}`, `summary=${report.verdict.summary}`, + `ownerIntervention=${report.ownerIntervention.status}`, `generatedAt=${report.generatedAt}`, `platform=${report.platform}`, ]; @@ -263,15 +183,37 @@ function renderMarkdown(report) { `- Status: ${report.verdict.status}`, `- Summary: ${report.verdict.summary}`, `- Next action: ${report.verdict.nextAction}`, + `- Owner intervention: ${report.ownerIntervention.status}`, "", "## Active GUI smoke / deep flow processes", "", ]; appendProcessList(lines, report.activeGuiSmokeProcesses); + lines.push("", "## Stale active GUI smoke candidates", ""); + appendProcessList(lines, report.staleActiveGuiSmokeProcesses); lines.push("", "## qcloop related processes", ""); appendProcessList(lines, report.qcloopProcesses); lines.push("", "## Cargo / Rust processes", ""); appendProcessList(lines, report.cargoProcesses); + lines.push("", "## Passive qcloop serve processes", ""); + appendProcessList(lines, report.passiveQcloopServerProcesses); + lines.push("", "## Passive Tauri dev runtime processes", ""); + appendProcessList(lines, report.passiveTauriRuntimeProcesses); + lines.push("", "## Observer processes", ""); + appendProcessList(lines, report.observerProcesses); + if (report.ownerIntervention.status === "requires_owner_confirmation") { + lines.push( + "", + "## Owner intervention", + "", + `- Required confirmation: ${report.ownerIntervention.requiredConfirmationText}`, + `- Next action: ${report.ownerIntervention.nextAction}`, + "- Prohibited until confirmed:", + ); + for (const item of report.ownerIntervention.prohibitedUntilConfirmed) { + lines.push(` - ${item}`); + } + } lines.push("", "## Guardrails", ""); for (const guardrail of report.guardrails) { lines.push(`- ${guardrail}`); @@ -301,6 +243,46 @@ function writeOutput(outputPath, content) { fs.writeFileSync(resolvedOutputPath, content, "utf8"); } +function createWatchHistoryEntry(report) { + return { + schemaVersion: "v1", + generatedAt: report.generatedAt, + verdictStatus: report.verdict.status, + summary: report.verdict.summary, + ownerInterventionStatus: report.ownerIntervention.status, + ownerInterventionProcessIds: report.ownerIntervention.processIds, + activeGuiSmokeCount: report.activeGuiSmokeProcesses.length, + staleActiveGuiSmokeCount: report.staleActiveGuiSmokeProcesses.length, + qcloopRelatedCount: report.qcloopProcesses.length, + cargoOrRustCount: report.cargoProcesses.length, + passiveQcloopServerCount: report.passiveQcloopServerProcesses.length, + passiveTauriRuntimeCount: report.passiveTauriRuntimeProcesses.length, + observerCount: report.observerProcesses.length, + activeGuiSmokeProcesses: report.activeGuiSmokeProcesses.map((entry) => ({ + pid: entry.pid, + ppid: entry.ppid, + pgid: entry.pgid, + stat: entry.stat, + etime: entry.etime, + durationSeconds: entry.durationSeconds, + command: entry.command, + })), + }; +} + +function appendWatchHistory(outputPath, report) { + if (!outputPath) { + return; + } + const resolvedOutputPath = path.resolve(process.cwd(), outputPath); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.appendFileSync( + resolvedOutputPath, + `${JSON.stringify(createWatchHistoryEntry(report))}\n`, + "utf8", + ); +} + function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { @@ -317,6 +299,7 @@ function main() { if (options.markdownOutputPath) { writeOutput(options.markdownOutputPath, renderMarkdown(report)); } + appendWatchHistory(options.watchHistoryOutputPath, report); if (options.check && report.verdict.status !== "pass") { process.exit(1); diff --git a/scripts/agent-service-skill-entry-smoke.mjs b/scripts/agent-service-skill-entry-smoke.mjs index ff71b500f..3015e5f0d 100644 --- a/scripts/agent-service-skill-entry-smoke.mjs +++ b/scripts/agent-service-skill-entry-smoke.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; @@ -9,6 +10,12 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, ".."); const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const runtimeTranscriptPath = path.join( + rootDir, + ".lime", + "qc", + "skill-forge-runtime-transcript-current.json", +); function runVitest(label, args) { console.log(`\n[smoke:agent-service-skill-entry] > ${label}`); @@ -35,14 +42,30 @@ function runVitest(label, args) { } } -function runCargoTest(label, args) { +function runCargoTest(label, { packageName, testName }) { console.log(`\n[smoke:agent-service-skill-entry] > ${label}`); - const result = spawnSync("cargo", ["test", ...args], { + const result = spawnSync("cargo", [ + "test", + "--manifest-path", + "Cargo.toml", + "-p", + packageName, + testName, + "--", + "--exact", + ], { cwd: path.join(rootDir, "src-tauri"), - stdio: "inherit", + encoding: "utf8", env: process.env, }); + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + if (result.error) { throw result.error; } @@ -54,6 +77,246 @@ function runCargoTest(label, args) { error.exitCode = result.status; throw error; } + + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + const passedCount = Array.from( + output.matchAll(/test result: ok\. ([0-9]+) passed/g), + ).reduce((total, match) => total + Number(match[1] || 0), 0); + if (passedCount <= 0) { + const error = new Error( + `[smoke:agent-service-skill-entry] ${label} 未实际运行目标 Rust 测试`, + ); + error.exitCode = 1; + throw error; + } + + return { + packageName, + testName, + passedCount, + }; +} + +function runtimePhaseForTest(testName) { + if (testName.includes("register_capability_draft")) { + return "capability_draft_registration"; + } + if (testName.includes("registered_skill_becomes_ready")) { + return "runtime_binding_projection"; + } + if (testName.includes("explicit_runtime_enable")) { + return "manual_session_runtime_enable"; + } + if (testName.includes("registered_skill_without_verification")) { + return "provenance_gate_negative"; + } + if (testName.includes("controlled_get")) { + return "readonly_execution_evidence"; + } + if (testName.includes("workspace_skill_runtime_enable_as_callable_scope")) { + return "query_loop_prompt_projection"; + } + if (testName.includes("allowlisted_session")) { + return "skill_tool_gate_allow"; + } + if (testName.includes("disabled_session")) { + return "skill_tool_gate_deny"; + } + return "skill_forge_runtime_gate"; +} + +function skillToolGateEvidenceForPhase(phase) { + if (phase === "skill_tool_gate_allow") { + const sourceMetadata = { + workspaceRoot: "", + source: "manual_session_enable", + approval: "manual", + authorizationScope: "session", + directory: "capability-report", + registeredSkillDirectory: + "/.agents/skills/capability-report", + skillName: "project:capability-report", + sourceDraftId: "capdraft-1", + sourceVerificationReportId: "capver-1", + permissionSummary: ["Level 0 只读发现"], + }; + + return { + request: { + toolName: "SkillTool", + sessionId: "skill-source-session", + skill: "capability-report", + authorizationScope: "session", + }, + decision: { + action: "allow", + gate: "session_allowlist", + enabled: true, + allowlisted: true, + reason: "workspace_skill_runtime_enable_allowlist_matched", + }, + result: { + status: "passed", + permissionBehavior: "Allow", + sourceMetadataAttached: true, + workspaceSkillRuntimeEnableAttached: true, + }, + sourceMetadata, + }; + } + + if (phase === "skill_tool_gate_deny") { + return { + request: { + toolName: "SkillTool", + sessionId: "skill-execute-disabled-session", + skill: "research", + authorizationScope: "session", + }, + decision: { + action: "deny", + gate: "session_enable_required", + enabled: false, + allowlisted: false, + reason: "skill_tool_session_not_enabled", + }, + result: { + status: "passed", + errorClass: "execution_failed", + errorMessageContains: "未启用技能自动调用", + }, + sourceMetadata: null, + }; + } + + return {}; +} + +function assertRuntimeTranscriptHasSkillToolGateEvidence(transcript) { + const events = transcript?.runtimeTranscript?.events; + if (!Array.isArray(events)) { + throw new Error("[smoke:agent-service-skill-entry] runtime transcript 缺少 events"); + } + + const allowEvent = events.find((event) => event.phase === "skill_tool_gate_allow"); + const denyEvent = events.find((event) => event.phase === "skill_tool_gate_deny"); + const hasGateEvidence = (event) => + event?.request?.toolName === "SkillTool" && + typeof event?.request?.skill === "string" && + typeof event?.decision?.action === "string" && + typeof event?.decision?.gate === "string" && + typeof event?.result?.status === "string"; + + if (!hasGateEvidence(allowEvent)) { + throw new Error( + "[smoke:agent-service-skill-entry] runtime transcript 缺少 SkillTool allow request/decision/result", + ); + } + if (!hasGateEvidence(denyEvent)) { + throw new Error( + "[smoke:agent-service-skill-entry] runtime transcript 缺少 SkillTool deny request/decision/result", + ); + } + if ( + allowEvent.sourceMetadata?.authorizationScope !== "session" || + allowEvent.sourceMetadata?.sourceDraftId !== "capdraft-1" || + allowEvent.sourceMetadata?.sourceVerificationReportId !== "capver-1" + ) { + throw new Error( + "[smoke:agent-service-skill-entry] runtime transcript 缺少 SkillTool source metadata", + ); + } +} + +function buildSkillToolGateProof(transcript) { + const events = transcript.runtimeTranscript.events; + const allowEvent = events.find((event) => event.phase === "skill_tool_gate_allow"); + const denyEvent = events.find((event) => event.phase === "skill_tool_gate_deny"); + + return { + allow: { + phase: allowEvent.phase, + hasRequest: Boolean(allowEvent.request?.toolName && allowEvent.request?.skill), + hasDecision: Boolean(allowEvent.decision?.action && allowEvent.decision?.gate), + hasResult: Boolean(allowEvent.result?.status), + hasSourceMetadata: Boolean(allowEvent.sourceMetadata?.sourceDraftId), + request: allowEvent.request, + decision: allowEvent.decision, + result: allowEvent.result, + }, + deny: { + phase: denyEvent.phase, + hasRequest: Boolean(denyEvent.request?.toolName && denyEvent.request?.skill), + hasDecision: Boolean(denyEvent.decision?.action && denyEvent.decision?.gate), + hasResult: Boolean(denyEvent.result?.status), + hasSourceMetadata: Boolean(denyEvent.sourceMetadata), + request: denyEvent.request, + decision: denyEvent.decision, + result: denyEvent.result, + }, + summary: + "SkillTool allow/deny events both contain request, decision and result; allow event also carries redacted source metadata.", + }; +} + +function writeRuntimeTranscript(cargoResults) { + fs.mkdirSync(path.dirname(runtimeTranscriptPath), { recursive: true }); + const transcript = { + schemaVersion: "v1", + scenarioId: "skill-forge-register-bind-enable", + generatedAt: new Date().toISOString(), + result: "pass", + evidenceLayersCovered: ["deterministic-smoke", "runtime-transcript"], + runtimeTranscript: { + kind: "skill_tool_gate_transcript", + scope: + "deterministic Rust harness; covers SkillTool request/decision/result and source metadata without invoking a live model provider", + events: cargoResults.map((result, index) => { + const phase = runtimePhaseForTest(result.testName); + return { + order: index + 1, + phase, + command: `cargo test --manifest-path Cargo.toml -p ${result.packageName} ${result.testName} -- --exact`, + status: "passed", + passedCount: result.passedCount, + ...skillToolGateEvidenceForPhase(phase), + }; + }), + }, + evidenceRequired: { + capabilityDraftEvidence: + "capability_draft_service exact tests created, verified, registered, and controlled read-only evidence without persisting sensitive inputs.", + registrationResult: + "registration exact tests persisted readonly HTTP provenance and rejected missing provenance.", + runtimeBindingProjection: + "runtime_skill_binding_service exact tests projected ready_for_manual_enable without query_loop/tool_runtime auto visibility.", + skillToolGateTranscript: + "lime-agent SkillTool gate exact tests covered allowlisted session source metadata and disabled-session denial.", + }, + failureModes: { + registeredEqualsExecutable: + "excluded: ready binding remains query_loop_visible=false, tool_runtime_visible=false, launch_enabled=false until manual enable.", + metadataAutoEnablesSkill: + "excluded: workspace_skill_bindings prompt projection is read-only; only workspace_skill_runtime_enable creates session allowlist.", + missingProvenance: + "covered: missing verification provenance is blocked before runtime enable.", + unsafeEndpointLeaked: + "excluded: controlled GET evidence test verifies no endpoint/token/response preview is persisted.", + }, + }; + assertRuntimeTranscriptHasSkillToolGateEvidence(transcript); + const gateProof = buildSkillToolGateProof(transcript); + transcript.skillToolGateProof = gateProof; + fs.writeFileSync(runtimeTranscriptPath, `${JSON.stringify(transcript, null, 2)}\n`); + console.log( + `[smoke:agent-service-skill-entry] Skill Forge runtime transcript: ${runtimeTranscriptPath}`, + ); + console.log( + `[smoke:agent-service-skill-entry] SkillTool gate proof: allow request=${gateProof.allow.hasRequest} decision=${gateProof.allow.hasDecision} result=${gateProof.allow.hasResult} sourceMetadata=${gateProof.allow.hasSourceMetadata}; deny request=${gateProof.deny.hasRequest} decision=${gateProof.deny.hasDecision} result=${gateProof.deny.hasResult}`, + ); + console.log( + `[smoke:agent-service-skill-entry] SkillTool gate proof JSON: ${JSON.stringify(gateProof)}`, + ); } function main() { @@ -65,24 +328,51 @@ function main() { "src/features/capability-drafts/workspaceSkillAgentAutomationDraft.test.ts", ]); - [ - "register_capability_draft_persists_readonly_http_preflight_provenance", - "registered_skill_becomes_ready_for_manual_enable_binding_candidate", - "explicit_runtime_enable_projects_ready_binding_allowlist", - "registered_skill_without_verification_provenance_is_blocked", - "execute_capability_draft_controlled_get_returns_evidence_without_persisting_inputs", - "should_project_workspace_skill_runtime_enable_as_callable_scope", - "allowlisted_session_should_preserve_workspace_skill_source_metadata", - "disabled_session_should_fail_execute", - ].forEach((testName) => { - runCargoTest(`Skill Forge Rust 定向测试: ${testName}`, [ - "--manifest-path", - "Cargo.toml", - testName, - "--", - "--exact", - ]); - }); + const cargoResults = [ + { + packageName: "lime", + testName: + "services::capability_draft_service::tests::register_capability_draft_persists_readonly_http_preflight_provenance", + }, + { + packageName: "lime", + testName: + "services::runtime_skill_binding_service::tests::registered_skill_becomes_ready_for_manual_enable_binding_candidate", + }, + { + packageName: "lime", + testName: + "services::runtime_skill_binding_service::tests::explicit_runtime_enable_projects_ready_binding_allowlist", + }, + { + packageName: "lime", + testName: + "services::runtime_skill_binding_service::tests::registered_skill_without_verification_provenance_is_blocked", + }, + { + packageName: "lime", + testName: + "services::capability_draft_service::tests::execute_capability_draft_controlled_get_returns_evidence_without_persisting_inputs", + }, + { + packageName: "lime", + testName: + "commands::aster_agent_cmd::workspace_skill_binding_prompt::tests::should_project_workspace_skill_runtime_enable_as_callable_scope", + }, + { + packageName: "lime-agent", + testName: + "tools::skill_tool_gate::tests::allowlisted_session_should_preserve_workspace_skill_source_metadata", + }, + { + packageName: "lime-agent", + testName: + "tools::skill_tool_gate::tests::disabled_session_should_fail_execute", + }, + ].map((testSpec) => + runCargoTest(`Skill Forge Rust 定向测试: ${testSpec.testName}`, testSpec), + ); + writeRuntimeTranscript(cargoResults); runVitest("服务技能入口路由与挂起参数", [ "src/components/skills/SkillsWorkspacePage.test.tsx", diff --git a/scripts/claw-chat-ready-streaming-smoke.mjs b/scripts/claw-chat-ready-streaming-smoke.mjs index 15f2debcb..f1b8e3c68 100644 --- a/scripts/claw-chat-ready-streaming-smoke.mjs +++ b/scripts/claw-chat-ready-streaming-smoke.mjs @@ -43,6 +43,8 @@ const LONG_PROMPT = [ const RECOVERY_EXPECTED_TEXT = "复原完成"; const RECOVERY_PROMPT = "停止后恢复测试:这是一个新的独立回合,请忽略上一条输出 160 行的要求。只输出“复原完成”这四个字,不要输出行号、解释或其他内容。"; +const MODEL_AVAILABILITY_PROMPT = "请只回复 QC_OK。"; +const MAX_MODEL_AVAILABILITY_CANDIDATES = 12; const FAST_RESPONSE_MODE_STORAGE_KEY = "lime:agent-fast-response-mode"; const TASK_CENTER_OPEN_TASK_EVENT = "lime:task-center:open-task"; @@ -268,10 +270,50 @@ function normalizeProviderId(provider) { return String(provider?.id || provider?.provider_id || provider?.providerId || "").trim(); } +function providerMatchesId(provider, providerId) { + const normalizedProviderId = normalizeProviderId(provider); + return ( + normalizedProviderId === providerId || + provider?.provider_name === providerId || + provider?.providerName === providerId || + provider?.name === providerId + ); +} + function providerEnabled(provider) { return provider?.enabled !== false; } +function providerHasCredential(provider) { + const count = Number(provider?.api_key_count ?? provider?.apiKeyCount); + if (Number.isFinite(count)) { + return count > 0; + } + if (typeof provider?.has_api_key === "boolean") { + return provider.has_api_key; + } + if (typeof provider?.hasApiKey === "boolean") { + return provider.hasApiKey; + } + + return true; +} + +function providerReadyForLiveRuntime(provider) { + return providerEnabled(provider) && providerHasCredential(provider); +} + +function modelLooksChatCapable(modelName) { + const normalized = String(modelName || "").trim().toLowerCase(); + if (!normalized) { + return false; + } + + return !/(image|images|banana|embed|embedding|tts|transcrib|audio|video|bfl|flux|stable|kling|wan)/i.test( + normalized, + ); +} + function pickModelPreference(provider) { const candidates = [ provider?.default_model, @@ -288,32 +330,126 @@ function pickModelPreference(provider) { .filter(Boolean); return ( - candidates.find((value) => /flash|mini|lite/i.test(value)) || - candidates[0] || + candidates.find((value) => /flash|mini|lite/i.test(value) && modelLooksChatCapable(value)) || + candidates.find((value) => modelLooksChatCapable(value)) || "" ); } +function findProviderById(providers, providerId) { + const normalizedProviderId = String(providerId || "").trim(); + if (!normalizedProviderId) { + return null; + } + + return ( + providers.find((provider) => providerMatchesId(provider, normalizedProviderId)) || + null + ); +} + function pickProvider(providers, preferredProviderId) { + const ready = providers.filter((provider) => providerReadyForLiveRuntime(provider)); const enabled = providers.filter((provider) => providerEnabled(provider)); if (preferredProviderId) { return ( - enabled.find((provider) => normalizeProviderId(provider) === preferredProviderId) || - enabled.find((provider) => provider?.provider_name === preferredProviderId) || - providers.find((provider) => normalizeProviderId(provider) === preferredProviderId) || - providers.find((provider) => provider?.provider_name === preferredProviderId) || + ready.find((provider) => providerMatchesId(provider, preferredProviderId)) || + enabled.find((provider) => providerMatchesId(provider, preferredProviderId)) || + providers.find((provider) => providerMatchesId(provider, preferredProviderId)) || null ); } for (const providerId of ["deepseek", "doubao", "lime-hub"]) { - const match = enabled.find((provider) => normalizeProviderId(provider) === providerId); + const match = ready.find((provider) => normalizeProviderId(provider) === providerId); if (match) { return match; } } - return enabled[0] || null; + return ready[0] || enabled[0] || null; +} + +function candidateRank(candidate) { + const providerId = candidate.providerPreference.toLowerCase(); + const model = candidate.modelPreference.toLowerCase(); + + if (candidate.source === "agent-status") { + return 0; + } + if (/flash|lite/.test(model) && !/deepseek/.test(model)) { + return 1; + } + if (/mini/.test(model) && !/deepseek/.test(model)) { + return 2; + } + if (/deepseek/.test(model)) { + return 4; + } + if (["deepseek", "siliconflow-cn", "lime-hub"].includes(providerId)) { + return 5; + } + return 3; +} + +function uniqueProviderCandidates(candidates) { + const seen = new Set(); + return candidates.filter((candidate) => { + const key = `${candidate.providerPreference}::${candidate.modelPreference}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + +async function buildProviderCandidate(options, provider, modelPreference, source) { + const providerId = normalizeProviderId(provider) || String(provider?.provider_name || "").trim(); + if (!providerId) { + return null; + } + + let providerDetail = provider; + try { + providerDetail = + (await invoke(options, "get_api_key_provider", { id: providerId }, 30_000)) || + provider; + } catch (error) { + console.warn( + `[smoke:claw-chat-ready-streaming] 读取 provider 详情失败,使用列表摘要继续: ${error.message}`, + ); + } + + const model = modelPreference || pickModelPreference(providerDetail); + if (!model || !modelLooksChatCapable(model)) { + return null; + } + + return { + providerPreference: providerId, + modelPreference: model, + source, + }; +} + +async function verifyProviderCandidate(options, candidate) { + const result = await invoke( + options, + "test_api_key_provider_chat", + { + providerId: candidate.providerPreference, + modelName: candidate.modelPreference, + prompt: MODEL_AVAILABILITY_PROMPT, + }, + 60_000, + ); + return Boolean(result?.success); +} + +function sanitizeProbeError(error) { + const message = error instanceof Error ? error.message : String(error || "unknown"); + return message.replace(/\s+/g, " ").slice(0, 160); } async function resolveProviderPreference(options, agentStatus, providers) { @@ -331,48 +467,105 @@ async function resolveProviderPreference(options, agentStatus, providers) { agentStatus?.provider_selector || agentStatus?.provider_name || "", ).trim(); const statusModel = String(agentStatus?.model_name || "").trim(); - if (!explicitProvider && !explicitModel && statusProvider && statusModel) { - return { - providerPreference: statusProvider, - modelPreference: statusModel, - source: "agent-status", - }; + const providerListAvailable = Array.isArray(providers) && providers.length > 0; + const statusProviderRecord = providerListAvailable + ? findProviderById(providers, statusProvider) + : null; + const statusProviderReady = + statusProvider && + statusModel && + (!providerListAvailable || + (statusProviderRecord && providerReadyForLiveRuntime(statusProviderRecord))); + const providerList = Array.isArray(providers) ? providers : []; + const selected = pickProvider(providerList, explicitProvider || ""); + + if (explicitProvider || explicitModel) { + if (!selected) { + throw new Error( + "[smoke:claw-chat-ready-streaming] 未找到可用 provider;请传 --provider-preference / --model-preference 或先配置本地 provider", + ); + } + const selectedMatchesStatusProvider = + statusProvider && providerMatchesId(selected, statusProvider); + const candidate = await buildProviderCandidate( + options, + selected, + explicitModel || (selectedMatchesStatusProvider ? statusModel : ""), + explicitProvider || explicitModel ? "partial-explicit" : "auto-enabled-provider", + ); + if (!candidate) { + const providerId = + normalizeProviderId(selected) || String(selected?.provider_name || "").trim(); + throw new Error( + `[smoke:claw-chat-ready-streaming] provider ${providerId} 缺少可用聊天模型;请传 --model-preference`, + ); + } + return candidate; } - const selected = pickProvider( - Array.isArray(providers) ? providers : [], - explicitProvider || statusProvider, + const autoCandidates = []; + if (!explicitProvider && !explicitModel && statusProviderReady) { + const statusCandidate = await buildProviderCandidate( + options, + statusProviderRecord || { + id: statusProvider, + custom_models: [statusModel], + }, + statusModel, + "agent-status", + ); + if (statusCandidate) { + autoCandidates.push(statusCandidate); + } + } + for (const provider of providerList.filter((item) => providerReadyForLiveRuntime(item))) { + const candidate = await buildProviderCandidate( + options, + provider, + "", + "auto-enabled-provider", + ); + if (candidate) { + autoCandidates.push(candidate); + } + } + + const candidates = uniqueProviderCandidates(autoCandidates) + .sort((left, right) => candidateRank(left) - candidateRank(right)) + .slice(0, MAX_MODEL_AVAILABILITY_CANDIDATES); + if (candidates.length === 0) { + const providerId = + normalizeProviderId(selected) || + String(selected?.provider_name || statusProvider || "").trim(); + throw new Error( + `[smoke:claw-chat-ready-streaming] provider ${providerId} 缺少可用聊天模型;请传 --model-preference`, + ); + } + + const probeFailures = []; + for (const candidate of candidates) { + try { + if (await verifyProviderCandidate(options, candidate)) { + return { + ...candidate, + source: "auto-probed-provider", + }; + } + probeFailures.push(`${candidate.providerPreference}/${candidate.modelPreference}: failed`); + } catch (error) { + probeFailures.push( + `${candidate.providerPreference}/${candidate.modelPreference}: ${sanitizeProbeError( + error, + )}`, + ); + } + } + + throw new Error( + `[smoke:claw-chat-ready-streaming] 未找到可通过短聊天探测的 provider/model;候选失败: ${probeFailures.join( + " | ", + )}`, ); - const providerId = normalizeProviderId(selected) || String(selected?.provider_name || "").trim(); - if (!providerId) { - throw new Error( - "[smoke:claw-chat-ready-streaming] 未找到可用 provider;请传 --provider-preference / --model-preference 或先配置本地 provider", - ); - } - - let providerDetail = selected; - try { - providerDetail = - (await invoke(options, "get_api_key_provider", { id: providerId }, 30_000)) || - selected; - } catch (error) { - console.warn( - `[smoke:claw-chat-ready-streaming] 读取 provider 详情失败,使用列表摘要继续: ${error.message}`, - ); - } - - const modelPreference = explicitModel || statusModel || pickModelPreference(providerDetail); - if (!modelPreference) { - throw new Error( - `[smoke:claw-chat-ready-streaming] provider ${providerId} 缺少可用模型;请传 --model-preference`, - ); - } - - return { - providerPreference: providerId, - modelPreference, - source: explicitProvider || explicitModel ? "partial-explicit" : "auto-enabled-provider", - }; } function textOfMessage(message) { @@ -461,6 +654,7 @@ function buildPageSnapshotScript(recoveryExpectedText) { textareaValue: textarea ? textarea.value : "", sendDisabled: Boolean(sendButton?.disabled), stopVisible: Boolean(stopButton), + longPromptVisible: bodyText.includes("E2E 中断测试"), stoppedMarker: bodyText.includes("用户已停止当前执行"), recoveryVisible: bodyText.includes(recoveryExpected), streamLineCount: streamText ? streamText.split("\\n").filter(Boolean).length : 0, @@ -734,6 +928,7 @@ async function main() { modelName: agentStatus?.model_name || "", }; let submittedSessionId = ""; + let submittedLongTurnId = ""; try { const scopedProviderKey = `agent_pref_provider_${workspaceId}`; @@ -864,10 +1059,32 @@ async function main() { assert(sessionId, "长 turn 缺少 session_id"); assert(longTurnId, "长 turn 缺少 turn_id"); submittedSessionId = sessionId; + submittedLongTurnId = longTurnId; summary.sessionId = sessionId; summary.longTurnId = longTurnId; summary.longSubmitTurnConfig = longRequest.turn_config || null; + summary.longTurnOpenDispatches = [ + await dispatchTaskCenterOpenTask(page, sessionId, workspaceId), + ]; + summary.longTurnVisibleSnapshot = await waitForCondition( + "等待长 turn 会话挂载", + async () => { + const snapshot = await readPageSnapshot(page); + return snapshot?.stopVisible || snapshot?.streamTextLength > 0 + ? snapshot + : null; + }, + 10_000, + 250, + ).catch((error) => { + summary.longTurnVisibleWait = { + passed: false, + error: error instanceof Error ? error.message : String(error), + }; + return null; + }); + let lastOpenDispatchAt = Date.now(); const firstDelta = await waitForCondition( "等待首个流式增量与停止按钮", async () => { @@ -875,6 +1092,15 @@ async function main() { if (!snapshot) { return null; } + if ( + isLikelyDetachedBlankTaskSnapshot(snapshot) && + Date.now() - lastOpenDispatchAt >= 2_000 + ) { + lastOpenDispatchAt = Date.now(); + summary.longTurnOpenDispatches.push( + await dispatchTaskCenterOpenTask(page, sessionId, workspaceId), + ); + } const session = await invoke( options, "agent_runtime_get_session", @@ -1311,6 +1537,26 @@ async function main() { summary.devBridgeCommands = [...new Set(invokes.map((item) => item.cmd))]; if (submittedSessionId) { + summary.failureCleanupInterrupt = await invoke( + options, + "agent_runtime_interrupt_turn", + { + request: { + session_id: submittedSessionId, + ...(submittedLongTurnId ? { turn_id: submittedLongTurnId } : {}), + }, + }, + 20_000, + ) + .then(() => ({ attempted: true, status: "sent" })) + .catch((interruptError) => ({ + attempted: true, + status: "failed", + error: + interruptError instanceof Error + ? interruptError.message + : String(interruptError), + })); const failureSession = await invoke( options, "agent_runtime_get_session", diff --git a/scripts/knowledge-gui-smoke.mjs b/scripts/knowledge-gui-smoke.mjs index 9100f8e03..24fc4f52f 100644 --- a/scripts/knowledge-gui-smoke.mjs +++ b/scripts/knowledge-gui-smoke.mjs @@ -459,6 +459,25 @@ async function clickPageControl(page, { text, ariaLabel, index = 0 }) { } } +async function closeFileManagerIfOpen(page) { + const closeButton = page + .getByRole("button", { name: "关闭文件管理器" }) + .first(); + if (await closeButton.isVisible({ timeout: 1_000 }).catch(() => false)) { + await closeButton.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS }); + return; + } + + const openButton = page + .getByRole("button", { name: "打开左侧文件管理器" }) + .first(); + if (await openButton.isVisible({ timeout: 1_000 }).catch(() => false)) { + return; + } + + await clickPageControl(page, { ariaLabel: "关闭文件管理器" }); +} + async function waitForExactButton(page, label, name, timeoutMs) { try { await page @@ -670,10 +689,18 @@ async function verifyBuilderImportAndReview(page, options) { await page .getByRole("button", { name: "内容运营", exact: true }) .click({ timeout: DEFAULT_ACTION_TIMEOUT_MS }); - await page.getByLabel("资料名称").fill(BUILDER_ACCEPTANCE_PACK.title); - await page - .getByLabel("原始资料正文") - .fill(BUILDER_ACCEPTANCE_PACK.sourceText); + const nameInput = page.getByRole("textbox", { name: "资料名称" }); + const sourceTextarea = page.getByRole("textbox", { name: "原始资料正文" }); + await nameInput.waitFor({ + state: "visible", + timeout: DEFAULT_ACTION_TIMEOUT_MS, + }); + await sourceTextarea.waitFor({ + state: "visible", + timeout: DEFAULT_ACTION_TIMEOUT_MS, + }); + await nameInput.fill(BUILDER_ACCEPTANCE_PACK.title); + await sourceTextarea.fill(BUILDER_ACCEPTANCE_PACK.sourceText); await waitForPageText( page, "Builder 表单填充", @@ -970,7 +997,7 @@ async function runPlaywrightGuiFlow(options) { pack.name === FILE_MANAGER_SOURCE_TITLE, ); - await clickPageControl(page, { ariaLabel: "关闭文件管理器" }); + await closeFileManagerIfOpen(page); logStage("open-knowledge-page"); await openKnowledgePageFromMainNav(page, options); diff --git a/scripts/lib/agent-qc-completion-audit-core.mjs b/scripts/lib/agent-qc-completion-audit-core.mjs index 3511b0152..e3c81d5bb 100644 --- a/scripts/lib/agent-qc-completion-audit-core.mjs +++ b/scripts/lib/agent-qc-completion-audit-core.mjs @@ -23,6 +23,51 @@ function summarizeQcloopStatusSidecar(entry) { ].join(" "); } +function parseSidecarTime(entry) { + const value = entry?.generatedAt || entry?.jobFinishedAt || ""; + const time = Date.parse(String(value || "")); + return Number.isFinite(time) ? time : 0; +} + +function selectLatestQcloopStatusSidecars(sidecars) { + const latestByJobId = new Map(); + const withoutJobId = []; + for (const entry of asArray(sidecars)) { + const jobId = String(entry?.jobId || "").trim(); + if (!jobId) { + withoutJobId.push(entry); + continue; + } + const previous = latestByJobId.get(jobId); + if (!previous || parseSidecarTime(entry) >= parseSidecarTime(previous)) { + latestByJobId.set(jobId, entry); + } + } + return [...withoutJobId, ...latestByJobId.values()].sort((left, right) => + String(left?.path || "").localeCompare(String(right?.path || "")), + ); +} + +function isUnstartedPendingQcloopStatusSidecar(entry) { + const counts = entry?.counts || {}; + const pending = Number(counts.pending || 0); + const running = Number(counts.running || 0); + const stale = Number(counts.stale || 0); + const success = Number(counts.success || 0); + const failed = Number(counts.failed || 0); + const exhausted = Number(counts.exhausted || 0); + const jobStatus = String(entry?.jobStatus || "").toLowerCase(); + return ( + jobStatus === "pending" && + pending > 0 && + running === 0 && + stale === 0 && + success === 0 && + failed === 0 && + exhausted === 0 + ); +} + function buildAgentQcCompletionAudit(facts) { const requiredQcloopScenarioIds = normalizeIdList(facts.scenarioReport?.p0ScenarioIds); const requiredQcloopScenarioCount = @@ -38,7 +83,8 @@ function buildAgentQcCompletionAudit(facts) { const sidecarEvidenceSummary = asArray(facts.realEvidenceSidecars) .map((entry) => `${entry.path} status=${entry.status || "unknown"} scenarios=${entry.scenarioCount || 0}`) .join("; "); - const qcloopStatusSidecarSummary = asArray(facts.qcloopStatusSidecars) + const qcloopStatusSidecars = selectLatestQcloopStatusSidecars(facts.qcloopStatusSidecars); + const qcloopStatusSidecarSummary = qcloopStatusSidecars .map((entry) => { const counts = entry.counts ? ` success=${entry.counts.success ?? 0} running=${entry.counts.running ?? 0} pending=${entry.counts.pending ?? 0} stale=${entry.counts.stale ?? 0}` @@ -46,15 +92,16 @@ function buildAgentQcCompletionAudit(facts) { return `${entry.path} verdict=${entry.verdictStatus || "unknown"}${counts}`; }) .join("; "); - const staleQcloopStatusSidecars = asArray(facts.qcloopStatusSidecars).filter( + const staleQcloopStatusSidecars = qcloopStatusSidecars.filter( (entry) => entry.verdictStatus === "stale" || Number(entry.counts?.stale || 0) > 0, ); - const activeQcloopStatusSidecars = asArray(facts.qcloopStatusSidecars).filter( + const activeQcloopStatusSidecars = qcloopStatusSidecars.filter( (entry) => - entry.verdictStatus === "running" || - entry.verdictStatus === "stale" || - Number(entry.counts?.running || 0) > 0 || - Number(entry.counts?.pending || 0) > 0, + !isUnstartedPendingQcloopStatusSidecar(entry) && + (entry.verdictStatus === "running" || + entry.verdictStatus === "stale" || + Number(entry.counts?.running || 0) > 0 || + Number(entry.counts?.pending || 0) > 0), ); const qcloopEvidenceText = facts.realEvidencePack?.exists ? `.lime/qc/agent-qc-evidence.json status=${facts.realEvidencePack.status || "unknown"} scenarios=${facts.realEvidencePack.scenarioCount || 0}/${requiredQcloopScenarioCount}${missingQcloopScenarioIds.length > 0 ? ` missing=${missingQcloopScenarioIds.join(",")}` : ""}` @@ -111,6 +158,20 @@ function buildAgentQcCompletionAudit(facts) { `最近一次 verify:gui-smoke status=${facts.guiSmoke.status}${facts.guiSmoke.error ? `;${facts.guiSmoke.error}` : ""}`, ); } + const payloadCoverageEvidenceText = facts.payloadCoverage?.coverage + ? [ + `status=${facts.payloadCoverage.status || "unknown"}`, + `coverage=${facts.payloadCoverage.coverage.passed ? "pass" : "fail"}`, + `repairGuard=${facts.payloadCoverage.repairGuard?.passed ? "pass" : "fail"}`, + `maxQcRounds=${facts.payloadCoverage.repairGuard?.maxQcRounds ?? "unknown"}`, + `maxExecutorRetries=${facts.payloadCoverage.repairGuard?.maxExecutorRetries ?? "unknown"}`, + `manifestP0=${facts.payloadCoverage.coverage.manifestP0Count ?? 0}`, + `payloadItems=${facts.payloadCoverage.coverage.payloadItemCount ?? 0}`, + `missing=${facts.payloadCoverage.coverage.missingScenarioIds?.length ?? 0}`, + `extra=${facts.payloadCoverage.coverage.extraScenarioIds?.length ?? 0}`, + `owner=${facts.payloadCoverage.ownerGate?.status || "unknown"}`, + ].join(" ") + : "未发现 .lime/qc/qcloop-p0-single-owner-ready-coverage-current.json"; const items = [ createItem( "docs-tests-standard", @@ -150,6 +211,15 @@ function buildAgentQcCompletionAudit(facts) { `itemCount=${facts.qcloopPayload?.itemCount ?? 0}`, "qcloop payload 生成器不可用或没有 item。", ), + createItem( + "qcloop-payload-coverage", + "可校验 ready qcloop payload 覆盖 P0 manifest", + facts.files?.payloadCoverageScript && + facts.payloadCoverage?.coverage?.passed === true && + facts.payloadCoverage?.repairGuard?.passed === true, + payloadCoverageEvidenceText, + "payload coverage 脚本缺失,ready payload 未完整覆盖 P0 manifest,或发布证据 payload 未禁用 qcloop repair / executor retry。", + ), createItem( "qcloop-verifier-evidence-placeholders", "qcloop verifier prompt 带 worker evidence 占位符", diff --git a/scripts/lib/agent-qc-completion-audit-core.test.ts b/scripts/lib/agent-qc-completion-audit-core.test.ts index cb3f8daaf..25a6f5b57 100644 --- a/scripts/lib/agent-qc-completion-audit-core.test.ts +++ b/scripts/lib/agent-qc-completion-audit-core.test.ts @@ -14,6 +14,7 @@ function completeFacts() { testsReadme: true, evidenceSchema: true, qcloopJobScript: true, + payloadCoverageScript: true, guiOwnerCheckScript: true, qcloopStatusScript: true, qcloopPreflightScript: true, @@ -55,6 +56,22 @@ function completeFacts() { verifierRequiresStructuredEvidence: true, verifierRequiresStrictJson: true, }, + payloadCoverage: { + status: "ready", + coverage: { + passed: true, + manifestP0Count: 1, + payloadItemCount: 1, + missingScenarioIds: [], + extraScenarioIds: [], + }, + repairGuard: { + passed: true, + maxQcRounds: 1, + maxExecutorRetries: 0, + }, + ownerGate: { status: "pass" }, + }, structuredEvidence: { exporterParsesSummary: true, releaseSummaryRejectsWeakRefs: true, @@ -163,6 +180,62 @@ describe("agent-qc-completion-audit-core", () => { expect(item?.gap).toContain("qcloop-status.fastmini-readonly-p0-v1-current.json"); }); + it("未启动 pending-only qcloop sidecar 不应阻断已通过的官方 evidence", () => { + const facts = completeFacts(); + facts.qcloopStatusSidecars = [ + { + path: ".lime/qc/qcloop-status.old-pending.json", + generatedAt: "2026-05-10T18:28:17.000Z", + jobId: "job-pending", + jobStatus: "pending", + verdictStatus: "running", + counts: { success: 0, failed: 0, exhausted: 0, running: 0, pending: 8, stale: 0 }, + }, + ]; + + const audit = buildAgentQcCompletionAudit(facts); + const item = audit.items.find((entry) => entry.id === "real-qcloop-evidence"); + + expect(audit.status).toBe("complete"); + expect(item?.evidence).toContain("qcloop-status.old-pending.json"); + expect(item?.gap).not.toContain("未终态"); + }); + + it("同一 qcloop job 有更新终态 sidecar 时不应被旧 stale snapshot 阻断", () => { + const facts = completeFacts(); + facts.realEvidencePack = { + exists: true, + status: "fail", + scenarioCount: 8, + scenarioIds: ["command-bridge-contract"], + }; + facts.qcloopStatusSidecars = [ + { + path: ".lime/qc/qcloop-status.pre-intervention-20260511-022817.json", + generatedAt: "2026-05-10T18:28:17.000Z", + jobId: "job-1", + verdictStatus: "stale", + counts: { success: 4, running: 1, pending: 3, stale: 1 }, + }, + { + path: ".lime/qc/qcloop-status.isolated-p0-full-v1-current.json", + generatedAt: "2026-05-10T18:38:51.000Z", + jobId: "job-1", + verdictStatus: "fail", + counts: { success: 5, running: 0, pending: 0, stale: 0 }, + }, + ]; + + const audit = buildAgentQcCompletionAudit(facts); + const item = audit.items.find((entry) => entry.id === "real-qcloop-evidence"); + + expect(audit.status).toBe("incomplete"); + expect(item?.evidence).toContain("qcloop-status.isolated-p0-full-v1-current.json"); + expect(item?.evidence).not.toContain("qcloop-status.pre-intervention-20260511-022817.json"); + expect(item?.gap).not.toContain("仍有 qcloop status sidecar 未终态"); + expect(item?.gap).not.toContain("其中 stale sidecar"); + }); + it("qcloop verifier prompt 缺少 worker evidence 占位符时应保持 incomplete", () => { const facts = completeFacts(); facts.qcloopPayload.verifierHasWorkerOutput = false; @@ -175,6 +248,34 @@ describe("agent-qc-completion-audit-core", () => { ); }); + it("ready payload coverage 不完整时应保持 incomplete", () => { + const facts = completeFacts(); + facts.payloadCoverage.coverage.passed = false; + facts.payloadCoverage.coverage.missingScenarioIds = ["workspace-ready-session-restore"]; + + const audit = buildAgentQcCompletionAudit(facts); + + expect(audit.status).toBe("incomplete"); + expect(audit.gaps.map((gap) => gap.id)).toContain("qcloop-payload-coverage"); + }); + + it("ready payload 未禁用 qcloop repair 时应保持 incomplete", () => { + const facts = completeFacts(); + facts.payloadCoverage.repairGuard = { + passed: false, + maxQcRounds: 3, + maxExecutorRetries: 0, + }; + + const audit = buildAgentQcCompletionAudit(facts); + + expect(audit.status).toBe("incomplete"); + expect(audit.gaps.map((gap) => gap.id)).toContain("qcloop-payload-coverage"); + expect(audit.items.find((item) => item.id === "qcloop-payload-coverage")?.evidence).toContain( + "repairGuard=fail", + ); + }); + it("结构化 evidence summary 契约缺失时应保持 incomplete", () => { const facts = completeFacts(); facts.qcloopPayload.workerPromptHasStructuredEvidence = false; diff --git a/scripts/lib/agent-qc-gui-owner-core.mjs b/scripts/lib/agent-qc-gui-owner-core.mjs index d20994409..6faa08f8f 100644 --- a/scripts/lib/agent-qc-gui-owner-core.mjs +++ b/scripts/lib/agent-qc-gui-owner-core.mjs @@ -24,6 +24,7 @@ function summarizeActiveGuiItems(status, guiScenarioIds) { return asArray(status?.items) .filter((item) => guiScenarioIdSet.has(normalizeScenarioId(item?.scenarioId))) .filter((item) => item?.terminal !== true) + .filter((item) => !isUnstartedPendingItem(item)) .map((item) => ({ scenarioId: normalizeScenarioId(item?.scenarioId), qcloopStatus: item?.qcloopStatus || "unknown", @@ -35,6 +36,19 @@ function summarizeActiveGuiItems(status, guiScenarioIds) { })); } +function isUnstartedPendingItem(item) { + const qcloopStatus = String(item?.qcloopStatus || "").toLowerCase(); + const workerStatus = String(item?.worker?.status || "unknown").toLowerCase(); + const attemptNo = Number(item?.currentAttemptNo || 0); + const qcNo = Number(item?.currentQcNo || 0); + return ( + qcloopStatus === "pending" && + attemptNo === 0 && + qcNo === 0 && + (workerStatus === "unknown" || workerStatus === "") + ); +} + function createAgentQcGuiOwnerReport({ manifest, statusSidecars = [], @@ -56,6 +70,10 @@ function createAgentQcGuiOwnerReport({ for (const sidecars of sidecarsByJobId.values()) { const sidecar = selectRepresentativeSidecar(sidecars); const status = sidecar?.status || sidecar; + if (isUnstartedPendingJob(status)) { + continue; + } + const activeItems = summarizeActiveGuiItems(status, guiScenarioIds); if (activeItems.length === 0) { continue; @@ -129,6 +147,24 @@ function createAgentQcGuiOwnerReport({ }; } +function isUnstartedPendingJob(status) { + const jobStatus = String(status?.job?.status || "").toLowerCase(); + if (jobStatus !== "pending") { + return false; + } + + const items = asArray(status?.items); + return ( + items.length === 0 || + items.every( + (item) => + String(item?.qcloopStatus || "").toLowerCase() === "pending" && + Number(item?.currentAttemptNo || 0) === 0 && + (item?.worker?.status || "unknown") === "unknown", + ) + ); +} + function createOwnerIntervention(activeOwners) { const staleOwners = activeOwners.filter((owner) => owner.activeItems.some((item) => item.stale), @@ -264,5 +300,6 @@ export { createAgentQcGuiOwnerWatchEntry, createAgentQcGuiOwnerReport, createOwnerIntervention, + isUnstartedPendingItem, renderAgentQcGuiOwnerSummary, }; diff --git a/scripts/lib/agent-qc-gui-owner-core.test.ts b/scripts/lib/agent-qc-gui-owner-core.test.ts index b6f2f8228..a4f9f69a4 100644 --- a/scripts/lib/agent-qc-gui-owner-core.test.ts +++ b/scripts/lib/agent-qc-gui-owner-core.test.ts @@ -116,6 +116,35 @@ describe("agent-qc-gui-owner-core", () => { expect(report.ownerCount).toBe(1); }); + it("未启动的 pending job 不应占用 GUI owner", () => { + const report = createAgentQcGuiOwnerReport({ + manifest, + statusSidecars: [ + { + path: ".lime/qc/qcloop-status.gui-pending.json", + status: { + job: { id: "job-pending", status: "pending", terminal: false }, + verdict: { status: "running" }, + counts: { pending: 1, running: 0, stale: 0 }, + items: [ + { + scenarioId: "claw-chat-ready-streaming", + qcloopStatus: "pending", + evidenceStatus: "blocked", + terminal: false, + currentAttemptNo: 0, + worker: { status: "unknown" }, + }, + ], + }, + }, + ], + }); + + expect(report.verdict.status).toBe("pass"); + expect(report.ownerCount).toBe(0); + }); + it("同一 running job 应选择 stale 秒数更新的 sidecar", () => { const createSidecar = (staleSeconds: number, generatedAt: string) => ({ generatedAt, diff --git a/scripts/lib/agent-qc-objective-checklist-core.mjs b/scripts/lib/agent-qc-objective-checklist-core.mjs new file mode 100644 index 000000000..ce57425f1 --- /dev/null +++ b/scripts/lib/agent-qc-objective-checklist-core.mjs @@ -0,0 +1,176 @@ +function itemById(audit) { + return new Map((audit.items || []).map((item) => [item.id, item])); +} + +function allPassed(items, ids) { + return ids.every((id) => items.get(id)?.passed === true); +} + +function evidenceFor(items, ids) { + return ids.map((id) => `${id}=${items.get(id)?.passed ? "pass" : "fail"}`).join("; "); +} + +function gapFor(items, ids) { + return ids + .map((id) => items.get(id)) + .filter((item) => item && !item.passed) + .map((item) => item.gap) + .filter(Boolean) + .join("; "); +} + +function hasOwnerProtocolEvidence(items, processOwner) { + return ( + items.get("qcloop-status-monitor")?.passed === true && + items.get("gui-owner-check")?.passed === true && + (processOwner?.verdict?.status === "pass" || + processOwner?.ownerIntervention?.status === "requires_owner_confirmation") + ); +} + +function ownerProtocolStatus(items, processOwner) { + if (!hasOwnerProtocolEvidence(items, processOwner)) { + return "fail"; + } + return processOwner?.verdict?.status === "busy" ? "pass_with_blocking_owner" : "pass"; +} + +function buildAgentQcObjectiveChecklist({ + audit, + processOwner, + guiOwner, + generatedAt = new Date().toISOString(), +}) { + const items = itemById(audit); + const manifestIds = ["scenario-manifest", "gui-flow-manifest", "evidence-schema"]; + const qcloopToolingIds = [ + "qcloop-payload-generator", + "qcloop-payload-coverage", + "qcloop-verifier-evidence-placeholders", + "structured-evidence-contract", + "qcloop-evidence-exporter", + "release-summary", + ]; + const checklist = [ + { + requirement: "在 docs/tests 下增加并维护 Agent/AI 测试标准文档", + artifacts: [ + "docs/tests/README.md", + "docs/tests/agent-ops-qc.md", + "docs/tests/ai-agent-testing-guide.md", + "docs/tests/lime-agent-autonomous-testing-plan.md", + ], + evidence: items.get("docs-tests-standard")?.evidence || "", + status: items.get("docs-tests-standard")?.passed ? "pass" : "fail", + gap: items.get("docs-tests-standard")?.gap || "", + }, + { + requirement: "提供 P0 scenario manifest、GUI flow manifest、Evidence Pack schema", + artifacts: [ + "docs/test/agent-qc-scenarios.manifest.json", + "docs/test/agent-qc-gui-flows.manifest.json", + "docs/test/agent-qc-evidence.schema.json", + ], + evidence: manifestIds.map((id) => items.get(id)?.evidence).filter(Boolean).join("; "), + status: allPassed(items, manifestIds) ? "pass" : "fail", + gap: gapFor(items, manifestIds), + }, + { + requirement: "qcloop payload / verifier / exporter / release summary gate 可生成和审查结构化证据", + artifacts: [ + "scripts/agent-qc-qcloop-job.mjs", + "scripts/agent-qc-payload-coverage.mjs", + "scripts/agent-qc-export-evidence.mjs", + "scripts/agent-qc-release-summary.mjs", + "docs/tests/lime-agent-qc-evidence-contract.md", + ], + evidence: evidenceFor(items, qcloopToolingIds), + status: allPassed(items, qcloopToolingIds) ? "pass" : "fail", + gap: gapFor(items, qcloopToolingIds), + }, + { + requirement: "qcloop / GUI owner / raw process owner 有只读取证与安全处置协议", + artifacts: [ + "scripts/agent-qc-qcloop-status.mjs", + "scripts/agent-qc-gui-owner-check.mjs", + "scripts/agent-qc-process-owner-check.mjs", + "scripts/lib/agent-qc-process-owner-core.mjs", + "docs/tests/lime-agent-qc-qcloop-operations.md", + ".lime/qc/stale-raw-gui-owner-intervention-request.json", + ], + evidence: `guiOwner=${guiOwner?.verdict?.status}; processOwner=${processOwner?.verdict?.status}; ${processOwner?.verdict?.summary || ""}; ownerIntervention=${processOwner?.ownerIntervention?.status}`, + status: ownerProtocolStatus(items, processOwner), + gap: + processOwner?.verdict?.status === "busy" + ? "raw process owner 仍 busy,不能启动完整 verify:local 或 full GUI P0。" + : "", + }, + { + requirement: "官方 .lime/qc/agent-qc-evidence.json 必须是真实 8/8 P0 pass Evidence Pack", + artifacts: [".lime/qc/agent-qc-evidence.json", "docs/test/agent-qc-scenarios.manifest.json"], + evidence: items.get("real-qcloop-evidence")?.evidence || "", + status: items.get("real-qcloop-evidence")?.passed ? "pass" : "fail", + gap: items.get("real-qcloop-evidence")?.gap || "", + }, + { + requirement: "仓库统一门禁 npm run verify:local 必须通过", + artifacts: [".lime/qc/verify-local-current.json", "npm run verify:local"], + evidence: items.get("local-verify-gate")?.evidence || "", + status: items.get("local-verify-gate")?.passed ? "pass" : "fail", + gap: items.get("local-verify-gate")?.gap || "", + }, + { + requirement: "在 Lime 仓库内不执行未授权 git commit / push / tag / release", + artifacts: ["git status", "user guardrail"], + evidence: "本轮未执行 git commit / push / tag / release;工作树存在其他并发进程产生的 unrelated 修改,未触碰。", + status: "pass", + gap: "", + }, + ]; + const blockers = checklist + .filter((item) => item.status !== "pass") + .map((item) => ({ requirement: item.requirement, status: item.status, gap: item.gap })); + return { + schemaVersion: "v1", + generatedAt, + objective: "实现 Agent QC / 测试体系整体目标,并以真实证据证明可发布", + status: blockers.length === 0 ? "complete" : "incomplete", + passedCount: checklist.filter((item) => item.status === "pass").length, + totalCount: checklist.length, + blockers, + checklist, + }; +} + +function renderAgentQcObjectiveChecklistMarkdown(result) { + const lines = [ + "# Objective Completion Checklist", + "", + `- Generated at: ${result.generatedAt}`, + `- Status: ${result.status}`, + `- Passed: ${result.passedCount}/${result.totalCount}`, + "", + "## Checklist", + "", + ]; + for (const item of result.checklist) { + lines.push( + `### ${item.status.toUpperCase()} ${item.requirement}`, + "", + `- Artifacts: ${item.artifacts.join(", ")}`, + `- Evidence: ${item.evidence || "none"}`, + `- Gap: ${item.gap || "none"}`, + "", + ); + } + lines.push("## Blockers", ""); + if (!result.blockers.length) { + lines.push("- none"); + } + for (const blocker of result.blockers) { + lines.push(`- ${blocker.requirement}: ${blocker.gap || blocker.status}`); + } + return `${lines.join("\n")}\n`; +} + +export { buildAgentQcObjectiveChecklist, renderAgentQcObjectiveChecklistMarkdown }; diff --git a/scripts/lib/agent-qc-objective-checklist-core.test.ts b/scripts/lib/agent-qc-objective-checklist-core.test.ts new file mode 100644 index 000000000..43dea725a --- /dev/null +++ b/scripts/lib/agent-qc-objective-checklist-core.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; + +import { + buildAgentQcObjectiveChecklist, + renderAgentQcObjectiveChecklistMarkdown, +} from "./agent-qc-objective-checklist-core.mjs"; + +const passedIds = [ + "docs-tests-standard", + "scenario-manifest", + "gui-flow-manifest", + "evidence-schema", + "qcloop-payload-generator", + "qcloop-payload-coverage", + "qcloop-verifier-evidence-placeholders", + "structured-evidence-contract", + "qcloop-evidence-exporter", + "release-summary", + "qcloop-status-monitor", + "gui-owner-check", + "real-qcloop-evidence", + "local-verify-gate", +]; + +function auditWith(overrides = {}) { + const overrideById = new Map(Object.entries(overrides)); + return { + items: passedIds.map((id) => ({ + id, + title: id, + passed: overrideById.get(id)?.passed ?? true, + evidence: overrideById.get(id)?.evidence ?? `${id}-evidence`, + gap: overrideById.get(id)?.gap ?? "", + })), + }; +} + +function passProcessOwner() { + return { + verdict: { status: "pass", summary: "activeGuiSmoke=0, cargoOrRust=0, qcloopRelated=0" }, + ownerIntervention: { status: "not_required" }, + }; +} + +function busyProcessOwner() { + return { + verdict: { + status: "busy", + summary: "activeGuiSmoke=1, cargoOrRust=0, qcloopRelated=0, staleActiveGuiSmoke=1", + }, + ownerIntervention: { status: "requires_owner_confirmation", processIds: [59011] }, + }; +} + +const guiOwner = { verdict: { status: "pass" } }; + +describe("agent-qc-objective-checklist-core", () => { + it("所有目标证据齐全且 owner clear 时应 complete", () => { + const checklist = buildAgentQcObjectiveChecklist({ + audit: auditWith(), + processOwner: passProcessOwner(), + guiOwner, + generatedAt: "2026-05-11T00:00:00.000Z", + }); + + expect(checklist.status).toBe("complete"); + expect(checklist.passedCount).toBe(7); + expect(checklist.blockers).toHaveLength(0); + }); + + it("raw process owner busy 时应保留 pass_with_blocking_owner 并保持 incomplete", () => { + const checklist = buildAgentQcObjectiveChecklist({ + audit: auditWith(), + processOwner: busyProcessOwner(), + guiOwner, + }); + + const ownerItem = checklist.checklist.find((item) => item.requirement.includes("raw process owner")); + expect(checklist.status).toBe("incomplete"); + expect(ownerItem?.status).toBe("pass_with_blocking_owner"); + expect(ownerItem?.gap).toContain("raw process owner 仍 busy"); + }); + + it("官方 evidence 或 verify:local 失败时应列为 blocker", () => { + const checklist = buildAgentQcObjectiveChecklist({ + audit: auditWith({ + "real-qcloop-evidence": { passed: false, gap: "official evidence fail" }, + "local-verify-gate": { passed: false, gap: "verify local fail" }, + }), + processOwner: passProcessOwner(), + guiOwner, + }); + + expect(checklist.status).toBe("incomplete"); + expect(checklist.blockers.map((item) => item.gap)).toContain("official evidence fail"); + expect(checklist.blockers.map((item) => item.gap)).toContain("verify local fail"); + }); + + it("应渲染 Markdown", () => { + const checklist = buildAgentQcObjectiveChecklist({ + audit: auditWith(), + processOwner: passProcessOwner(), + guiOwner, + }); + const markdown = renderAgentQcObjectiveChecklistMarkdown(checklist); + + expect(markdown).toContain("Objective Completion Checklist"); + expect(markdown).toContain("Status: complete"); + }); +}); diff --git a/scripts/lib/agent-qc-payload-coverage-core.mjs b/scripts/lib/agent-qc-payload-coverage-core.mjs new file mode 100644 index 000000000..33def4db1 --- /dev/null +++ b/scripts/lib/agent-qc-payload-coverage-core.mjs @@ -0,0 +1,172 @@ +function scenarioIdOf(value) { + return value?.scenario_id || value?.scenarioId || value?.id || ""; +} + +function parsePayloadItem(item) { + if (typeof item !== "string") { + return item; + } + return JSON.parse(item); +} + +function p0ScenarioIdsFromManifest(manifest) { + return (manifest?.scenarios || []) + .filter((scenario) => scenario?.risk === "P0") + .map(scenarioIdOf) + .filter(Boolean); +} + +function scenarioIdsFromPayload(payload) { + return (payload?.items || []).map(parsePayloadItem).map(scenarioIdOf).filter(Boolean); +} + +function buildRepairGuard(payload) { + const maxQcRounds = Number(payload?.max_qc_rounds); + const maxExecutorRetries = Number(payload?.max_executor_retries ?? 0); + const promptTemplate = String(payload?.prompt_template || ""); + const readOnlyPrompt = + promptTemplate.includes("不得修改源码") && + promptTemplate.includes("不要顺手修复") && + promptTemplate.includes("只允许执行场景命令"); + const maxQcRoundsPassed = maxQcRounds === 1; + const maxExecutorRetriesPassed = maxExecutorRetries === 0; + return { + passed: maxQcRoundsPassed && maxExecutorRetriesPassed && readOnlyPrompt, + maxQcRounds, + maxExecutorRetries, + maxQcRoundsPassed, + maxExecutorRetriesPassed, + readOnlyPrompt, + }; +} + +function buildAgentQcPayloadCoverageReport({ + manifest, + payload, + processOwner, + generatedAt = new Date().toISOString(), + manifestPath = "docs/test/agent-qc-scenarios.manifest.json", + payloadPath = "", +} = {}) { + const manifestScenarioIds = p0ScenarioIdsFromManifest(manifest); + const payloadScenarioIds = scenarioIdsFromPayload(payload); + const missingScenarioIds = manifestScenarioIds.filter((id) => !payloadScenarioIds.includes(id)); + const extraScenarioIds = payloadScenarioIds.filter((id) => !manifestScenarioIds.includes(id)); + const orderMatchesManifest = + manifestScenarioIds.length === payloadScenarioIds.length && + manifestScenarioIds.every((id, index) => id === payloadScenarioIds[index]); + const payloadValidation = payload?._validation || {}; + const payloadValidationPassed = payloadValidation.valid !== false; + const repairGuard = buildRepairGuard(payload); + const coveragePassed = + manifestScenarioIds.length > 0 && + payloadScenarioIds.length === manifestScenarioIds.length && + missingScenarioIds.length === 0 && + extraScenarioIds.length === 0 && + orderMatchesManifest && + payloadValidationPassed && + repairGuard.passed; + const ownerStatus = processOwner?.verdict?.status || "unknown"; + const status = coveragePassed ? (ownerStatus === "pass" ? "ready" : "blocked") : "fail"; + return { + schemaVersion: "v1", + generatedAt, + status, + manifest: manifestPath, + payload: payloadPath, + coverage: { + passed: coveragePassed, + manifestP0Count: manifestScenarioIds.length, + payloadItemCount: payloadScenarioIds.length, + manifestScenarioIds, + payloadScenarioIds, + missingScenarioIds, + extraScenarioIds, + orderMatchesManifest, + payloadValidationPassed, + }, + repairGuard, + ownerGate: { + status: ownerStatus, + summary: processOwner?.verdict?.summary || "", + ownerIntervention: processOwner?.ownerIntervention || null, + }, + guardrails: { + jobStarted: false, + officialEvidenceOverwritten: false, + qcloopDbModified: false, + gitMutation: false, + }, + startPreconditions: [ + "npm run agent-qc:process-owner-check -- --check", + "npm run agent-qc:gui-owner-check -- --check", + "npm run agent-qc:qcloop-preflight -- --require-devbridge --check", + ], + nextAction: + status === "ready" + ? "可以在 dedicated qcloop server / DB / port 上提交该 payload。" + : status === "blocked" + ? "payload 覆盖完整,但 owner gate 未通过;只能等待 owner 释放或按确认协议处理。" + : "修复 payload 与 manifest 的覆盖差异后再继续。", + }; +} + +function renderAgentQcPayloadCoverageMarkdown(report) { + const lines = [ + "# qcloop P0 Payload Coverage", + "", + `- Generated at: ${report.generatedAt}`, + `- Status: ${report.status}`, + `- Manifest: ${report.manifest}`, + `- Payload: ${report.payload}`, + `- Manifest P0 count: ${report.coverage.manifestP0Count}`, + `- Payload item count: ${report.coverage.payloadItemCount}`, + `- Missing scenarios: ${report.coverage.missingScenarioIds.join(", ") || "none"}`, + `- Extra scenarios: ${report.coverage.extraScenarioIds.join(", ") || "none"}`, + `- Order matches manifest: ${report.coverage.orderMatchesManifest}`, + `- Payload validation: ${report.coverage.payloadValidationPassed}`, + "", + "## Scenario Coverage", + "", + ]; + report.coverage.payloadScenarioIds.forEach((id, index) => { + lines.push(`${index + 1}. \`${id}\``); + }); + lines.push( + "", + "## Repair Guard", + "", + `- Status: ${report.repairGuard.passed ? "pass" : "fail"}`, + `- max_qc_rounds: ${report.repairGuard.maxQcRounds}`, + `- max_executor_retries: ${report.repairGuard.maxExecutorRetries}`, + `- Read-only prompt: ${report.repairGuard.readOnlyPrompt}`, + "", + ); + lines.push( + "", + "## Owner Gate", + "", + `- Status: ${report.ownerGate.status}`, + `- Summary: ${report.ownerGate.summary || "none"}`, + "", + "## Start Preconditions", + "", + ); + for (const command of report.startPreconditions) { + lines.push(`- \`${command}\``); + } + lines.push("", "## Guardrails", ""); + for (const [key, value] of Object.entries(report.guardrails)) { + lines.push(`- ${key}: ${String(value)}`); + } + lines.push("", "## Next Action", "", report.nextAction, ""); + return `${lines.join("\n")}\n`; +} + +export { + buildRepairGuard, + buildAgentQcPayloadCoverageReport, + p0ScenarioIdsFromManifest, + renderAgentQcPayloadCoverageMarkdown, + scenarioIdsFromPayload, +}; diff --git a/scripts/lib/agent-qc-payload-coverage-core.test.ts b/scripts/lib/agent-qc-payload-coverage-core.test.ts new file mode 100644 index 000000000..dbde9391d --- /dev/null +++ b/scripts/lib/agent-qc-payload-coverage-core.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { + buildRepairGuard, + buildAgentQcPayloadCoverageReport, + p0ScenarioIdsFromManifest, + renderAgentQcPayloadCoverageMarkdown, + scenarioIdsFromPayload, +} from "./agent-qc-payload-coverage-core.mjs"; + +const manifest = { + scenarios: [ + { id: "command-bridge-contract", risk: "P0" }, + { id: "workspace-ready-session-restore", risk: "P0" }, + { id: "p1-only", risk: "P1" }, + ], +}; + +function owner(status: string) { + return { + verdict: { status, summary: `owner=${status}` }, + ownerIntervention: status === "busy" ? { status: "requires_owner_confirmation" } : null, + }; +} + +function payload(items: unknown[], overrides = {}) { + return { + _validation: { valid: true }, + max_qc_rounds: 1, + max_executor_retries: 0, + prompt_template: "只允许执行场景命令,不要顺手修复;不得修改源码、配置、文档、锁文件或 git 状态。", + items, + ...overrides, + }; +} + +describe("agent-qc-payload-coverage-core", () => { + it("应从 manifest 提取 P0 场景", () => { + expect(p0ScenarioIdsFromManifest(manifest)).toEqual([ + "command-bridge-contract", + "workspace-ready-session-restore", + ]); + }); + + it("应解析 qcloop payload 中的字符串 item", () => { + const payload = { + items: [ + JSON.stringify({ scenario_id: "command-bridge-contract" }), + JSON.stringify({ scenario_id: "workspace-ready-session-restore" }), + ], + }; + + expect(scenarioIdsFromPayload(payload)).toEqual([ + "command-bridge-contract", + "workspace-ready-session-restore", + ]); + }); + + it("coverage 完整且 owner clear 时应 ready", () => { + const report = buildAgentQcPayloadCoverageReport({ + manifest, + payload: payload([ + JSON.stringify({ scenario_id: "command-bridge-contract" }), + JSON.stringify({ scenario_id: "workspace-ready-session-restore" }), + ]), + processOwner: owner("pass"), + generatedAt: "2026-05-11T00:00:00.000Z", + }); + + expect(report.status).toBe("ready"); + expect(report.coverage.passed).toBe(true); + expect(report.coverage.missingScenarioIds).toEqual([]); + expect(report.coverage.extraScenarioIds).toEqual([]); + }); + + it("coverage 完整但 owner busy 时应 blocked", () => { + const report = buildAgentQcPayloadCoverageReport({ + manifest, + payload: payload([ + { scenario_id: "command-bridge-contract" }, + { scenario_id: "workspace-ready-session-restore" }, + ]), + processOwner: owner("busy"), + }); + + expect(report.status).toBe("blocked"); + expect(report.ownerGate.ownerIntervention?.status).toBe("requires_owner_confirmation"); + }); + + it("缺少 P0 场景时应 fail 并渲染 Markdown", () => { + const report = buildAgentQcPayloadCoverageReport({ + manifest, + payload: payload([{ scenario_id: "command-bridge-contract" }]), + processOwner: owner("pass"), + }); + const markdown = renderAgentQcPayloadCoverageMarkdown(report); + + expect(report.status).toBe("fail"); + expect(report.coverage.missingScenarioIds).toEqual(["workspace-ready-session-restore"]); + expect(markdown).toContain("qcloop P0 Payload Coverage"); + expect(markdown).toContain("Missing scenarios"); + }); + + it("应阻断会触发 qcloop repair 的发布证据 payload", () => { + const report = buildAgentQcPayloadCoverageReport({ + manifest, + payload: payload( + [ + { scenario_id: "command-bridge-contract" }, + { scenario_id: "workspace-ready-session-restore" }, + ], + { max_qc_rounds: 3 }, + ), + processOwner: owner("pass"), + }); + + expect(buildRepairGuard(payload([], { max_qc_rounds: 3 })).passed).toBe(false); + expect(report.repairGuard.passed).toBe(false); + expect(report.status).toBe("fail"); + expect(report.repairGuard.maxQcRoundsPassed).toBe(false); + expect(renderAgentQcPayloadCoverageMarkdown(report)).toContain("Repair Guard"); + }); +}); diff --git a/scripts/lib/agent-qc-process-owner-core.mjs b/scripts/lib/agent-qc-process-owner-core.mjs new file mode 100644 index 000000000..32357aff8 --- /dev/null +++ b/scripts/lib/agent-qc-process-owner-core.mjs @@ -0,0 +1,243 @@ +const GUI_OWNER_PATTERNS = [ + "verify:gui-smoke", + "smoke:workspace-ready", + "smoke:browser-runtime", + "smoke:site-adapters", + "smoke:agent-service-skill-entry", + "smoke:agent-runtime-tool-surface", + "smoke:knowledge-gui", + "smoke:design-canvas", + "claw-chat-ready-streaming", + "browser-runtime-site-adapter", + "workspace-ready-session-restore", + "release-package-startup-smoke", +]; + +const CARGO_OWNER_PATTERNS = [ + "cargo ", + "cargo-fmt", + "rustc ", + "clippy-driver", + "tauri dev", +]; + +const QCLOOP_OWNER_PATTERNS = [ + "qcloop --db", + "./qcloop --db", + "qcloop serve", + "qcloop-worker", + "qcloop_worker_result", + "agent qc p0", + "只读执行 lime agent qc p0", +]; + +const OBSERVER_PROCESS_PATTERNS = [ + "while ps -p", + "while ps aux | rg", + "ps -eo pid,ppid,etime,pcpu,pmem,command | rg", + "ps aux | rg", +]; + +const PASSIVE_QCLOOP_SERVER_PATTERNS = [ + "qcloop --db", + "./qcloop --db", + "qcloop serve", +]; + +const ACTIVE_QCLOOP_WORKER_PATTERNS = [ + "qcloop-worker", + "qcloop_worker_result", + "agent qc p0", + "只读执行 lime agent qc p0", +]; + +const PASSIVE_TAURI_RUNTIME_PATTERNS = ["tauri dev"]; + +function parseEtimeSeconds(value) { + const text = String(value || "").trim(); + if (!text) { + return null; + } + const dayParts = text.split("-"); + const dayCount = dayParts.length === 2 ? Number(dayParts[0]) : 0; + const timeText = dayParts.length === 2 ? dayParts[1] : dayParts[0]; + const parts = timeText.split(":").map((part) => Number(part)); + if (parts.some((part) => !Number.isFinite(part))) { + return null; + } + if (parts.length === 3) { + return dayCount * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + if (parts.length === 2) { + return dayCount * 86400 + parts[0] * 60 + parts[1]; + } + if (parts.length === 1) { + return dayCount * 86400 + parts[0]; + } + return null; +} + +function sanitizeProcessCommand(command) { + return String(command || "") + .replace(/(--api-key(?:=|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1") + .replace(/(api[_-]?key(?:=|:|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1") + .replace(/ctx7sk-[A-Za-z0-9-]+/g, "ctx7sk-***") + .replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-***"); +} + +function commandHasAny(command, patterns) { + const normalized = String(command || "").toLowerCase(); + return patterns.some((pattern) => normalized.includes(pattern.toLowerCase())); +} + +function isObserverProcess(entry) { + return commandHasAny(entry?.command, OBSERVER_PROCESS_PATTERNS); +} + +function isPassiveQcloopServer(entry) { + return ( + commandHasAny(entry?.command, PASSIVE_QCLOOP_SERVER_PATTERNS) && + !commandHasAny(entry?.command, ACTIVE_QCLOOP_WORKER_PATTERNS) + ); +} + +function isPassiveTauriRuntime(entry) { + return commandHasAny(entry?.command, PASSIVE_TAURI_RUNTIME_PATTERNS); +} + +function uniqueByPid(entries) { + const seen = new Set(); + const result = []; + for (const entry of entries) { + if (seen.has(entry.pid)) { + continue; + } + seen.add(entry.pid); + result.push(entry); + } + return result.sort((left, right) => left.pid - right.pid); +} + +function normalizeProcessEntry(entry) { + const etime = String(entry?.etime || "unknown"); + return { + ...entry, + pid: Number(entry?.pid || 0), + ppid: Number(entry?.ppid || 0), + pgid: entry?.pgid ?? null, + stat: entry?.stat || "unknown", + etime, + durationSeconds: entry?.durationSeconds ?? parseEtimeSeconds(etime), + command: sanitizeProcessCommand(entry?.command || ""), + }; +} + +function createAgentQcProcessOwnerReport(processes, { + generatedAt = new Date().toISOString(), + platform = process.platform, + maxActiveGuiSmoke = 0, + maxCargoOrRust = 0, + maxQcloopRelated = 0, + staleMinutes = 30, +} = {}) { + const normalizedProcesses = processes + .map(normalizeProcessEntry) + .filter((entry) => entry.pid > 0 && entry.command.length > 0); + const observerProcesses = uniqueByPid(normalizedProcesses.filter(isObserverProcess)); + const ownerCandidateProcesses = normalizedProcesses.filter((entry) => !isObserverProcess(entry)); + const passiveQcloopServerProcesses = uniqueByPid( + ownerCandidateProcesses.filter(isPassiveQcloopServer), + ); + const passiveTauriRuntimeProcesses = uniqueByPid( + ownerCandidateProcesses.filter(isPassiveTauriRuntime), + ); + const activeGuiSmokeProcesses = uniqueByPid( + ownerCandidateProcesses.filter((entry) => commandHasAny(entry.command, GUI_OWNER_PATTERNS)), + ); + const qcloopProcesses = uniqueByPid( + ownerCandidateProcesses.filter( + (entry) => commandHasAny(entry.command, QCLOOP_OWNER_PATTERNS) && !isPassiveQcloopServer(entry), + ), + ); + const cargoProcesses = uniqueByPid( + ownerCandidateProcesses.filter( + (entry) => commandHasAny(entry.command, CARGO_OWNER_PATTERNS) && !isPassiveTauriRuntime(entry), + ), + ); + const staleThresholdSeconds = Math.max(0, Number(staleMinutes) || 0) * 60; + const staleActiveGuiSmokeProcesses = uniqueByPid( + activeGuiSmokeProcesses.filter( + (entry) => Number(entry.durationSeconds || 0) >= staleThresholdSeconds, + ), + ); + + const counts = { + activeGuiSmoke: activeGuiSmokeProcesses.length, + cargoOrRust: cargoProcesses.length, + qcloopRelated: qcloopProcesses.length, + staleActiveGuiSmoke: staleActiveGuiSmokeProcesses.length, + passiveQcloopServer: passiveQcloopServerProcesses.length, + passiveTauriRuntime: passiveTauriRuntimeProcesses.length, + observer: observerProcesses.length, + }; + const busy = + counts.activeGuiSmoke > maxActiveGuiSmoke || + counts.cargoOrRust > maxCargoOrRust || + counts.qcloopRelated > maxQcloopRelated; + + return { + schemaVersion: "v1", + generatedAt, + platform, + maxActiveGuiSmoke, + maxCargoOrRust, + maxQcloopRelated, + staleMinutes, + verdict: { + status: busy ? "busy" : "pass", + summary: `activeGuiSmoke=${counts.activeGuiSmoke}, cargoOrRust=${counts.cargoOrRust}, qcloopRelated=${counts.qcloopRelated}, staleActiveGuiSmoke=${counts.staleActiveGuiSmoke}, passiveQcloopServer=${counts.passiveQcloopServer}, passiveTauriRuntime=${counts.passiveTauriRuntime}, observer=${counts.observer}`, + nextAction: busy + ? "Do not start full verify:local or another GUI P0 batch while these owners are active; continue read-only observation or wait for natural completion." + : "No active raw GUI smoke, Cargo/Rust, or qcloop owner was observed; heavy gates may run if qcloop GUI owner and release evidence gates are also clear.", + }, + ownerIntervention: staleActiveGuiSmokeProcesses.length + ? { + status: "requires_owner_confirmation", + processIds: staleActiveGuiSmokeProcesses.map((entry) => entry.pid), + requiredConfirmationText: `确认处理 stale raw GUI owner PID ${staleActiveGuiSmokeProcesses.map((entry) => entry.pid).join(", ")},可以终止这些进程并记录 sidecar。`, + prohibitedUntilConfirmed: [ + "kill / pause / interrupt stale raw GUI owner", + "start full verify:local", + "start another full GUI P0 batch", + "overwrite .lime/qc/agent-qc-evidence.json", + "git commit / push / tag / release", + ], + nextAction: "等待这些进程自然释放,或由 owner 明确确认后再处理。", + } + : { + status: "not_required", + processIds: [], + requiredConfirmationText: "", + prohibitedUntilConfirmed: [], + nextAction: busy ? "等待 active owner 自然释放。" : "无需 stale raw owner 处置。", + }, + activeGuiSmokeProcesses, + qcloopProcesses, + cargoProcesses, + staleActiveGuiSmokeProcesses, + passiveQcloopServerProcesses, + passiveTauriRuntimeProcesses, + observerProcesses, + guardrails: [ + "best-effort process snapshot only", + "do not kill or restart listed processes from this sidecar", + "use this sidecar to decide whether heavy GUI or verify gates should wait", + ], + }; +} + +export { + createAgentQcProcessOwnerReport, + parseEtimeSeconds, + sanitizeProcessCommand, +}; diff --git a/scripts/lib/agent-qc-process-owner-core.test.ts b/scripts/lib/agent-qc-process-owner-core.test.ts new file mode 100644 index 000000000..d78bfe923 --- /dev/null +++ b/scripts/lib/agent-qc-process-owner-core.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { + createAgentQcProcessOwnerReport, + parseEtimeSeconds, + sanitizeProcessCommand, +} from "./agent-qc-process-owner-core.mjs"; + +describe("agent-qc-process-owner-core", () => { + it("解析 ps etime 为秒", () => { + expect(parseEtimeSeconds("07:04:53")).toBe(25493); + expect(parseEtimeSeconds("10:05")).toBe(605); + expect(parseEtimeSeconds("2-01:00:00")).toBe(176400); + expect(parseEtimeSeconds("unknown")).toBeNull(); + }); + + it("脱敏进程命令中的常见密钥", () => { + expect(sanitizeProcessCommand("cmd --api-key sk-1234567890abcdef api_key=ctx7sk-secret-value")).toContain( + "--api-key ", + ); + expect(sanitizeProcessCommand("cmd --api-key sk-1234567890abcdef api_key=ctx7sk-secret-value")).not.toContain( + "sk-1234567890abcdef", + ); + }); + + it("区分 active owner、passive runtime 与 observer", () => { + const report = createAgentQcProcessOwnerReport( + [ + { + pid: 10, + ppid: 1, + pgid: 10, + stat: "S", + etime: "07:04:53", + command: "npm run smoke:design-canvas -- --timeout-ms 600000", + }, + { + pid: 20, + ppid: 1, + pgid: 20, + stat: "S", + etime: "10:00:00", + command: "./qcloop --db .lime/qc/example.db serve --addr 127.0.0.1:18080 --workers 1", + }, + { + pid: 30, + ppid: 1, + pgid: 30, + stat: "S", + etime: "10:00:00", + command: "node node_modules/.bin/tauri dev --config src-tauri/tauri.conf.headless.json", + }, + { + pid: 40, + ppid: 1, + pgid: 40, + stat: "S", + etime: "00:01:00", + command: "while ps -p 123 >/dev/null 2>&1; do sleep 10; done; ps aux | rg verify:gui-smoke", + }, + ], + { generatedAt: "2026-05-11T00:00:00.000Z", platform: "darwin" }, + ); + + expect(report.verdict.status).toBe("busy"); + expect(report.activeGuiSmokeProcesses.map((entry) => entry.pid)).toEqual([10]); + expect(report.staleActiveGuiSmokeProcesses.map((entry) => entry.pid)).toEqual([10]); + expect(report.passiveQcloopServerProcesses.map((entry) => entry.pid)).toEqual([20]); + expect(report.passiveTauriRuntimeProcesses.map((entry) => entry.pid)).toEqual([30]); + expect(report.observerProcesses.map((entry) => entry.pid)).toEqual([40]); + expect(report.qcloopProcesses).toHaveLength(0); + expect(report.cargoProcesses).toHaveLength(0); + expect(report.ownerIntervention.status).toBe("requires_owner_confirmation"); + }); + + it("active qcloop worker 与 Cargo build 仍会阻断", () => { + const report = createAgentQcProcessOwnerReport( + [ + { + pid: 50, + ppid: 1, + pgid: 50, + stat: "S", + etime: "00:05:00", + command: "node codex exec 只读执行 Lime Agent QC P0 场景", + }, + { + pid: 60, + ppid: 1, + pgid: 60, + stat: "S", + etime: "00:05:00", + command: "cargo test --manifest-path src-tauri/Cargo.toml", + }, + ], + { generatedAt: "2026-05-11T00:00:00.000Z", platform: "darwin" }, + ); + + expect(report.verdict.status).toBe("busy"); + expect(report.qcloopProcesses.map((entry) => entry.pid)).toEqual([50]); + expect(report.cargoProcesses.map((entry) => entry.pid)).toEqual([60]); + expect(report.ownerIntervention.status).toBe("not_required"); + }); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a21f96d55..657fa8ba8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -334,7 +334,7 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", @@ -1438,7 +1438,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] @@ -2188,6 +2188,19 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types 0.5.0", + "libc", +] + [[package]] name = "core-graphics-types" version = "0.1.3" @@ -2401,6 +2414,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + [[package]] name = "cssparser-macros" version = "0.6.1" @@ -2413,14 +2439,20 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.9" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" dependencies = [ - "quote", - "syn 2.0.117", + "ctor-proc-macro", + "dtor", ] +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + [[package]] name = "darling" version = "0.20.11" @@ -2769,12 +2801,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dispatch2" version = "0.3.0" @@ -2784,7 +2810,7 @@ dependencies = [ "bitflags 2.11.0", "block2 0.6.2", "libc", - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] @@ -2839,6 +2865,21 @@ dependencies = [ "litrs", ] +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set 0.8.0", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", + "precomputed-hash", + "selectors 0.36.1", + "tendril 0.5.0", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -2875,6 +2916,21 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dunce" version = "1.0.5" @@ -3849,7 +3905,7 @@ checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" dependencies = [ "crossbeam-channel", "keyboard-types", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "once_cell", "serde", @@ -4125,6 +4181,16 @@ dependencies = [ "match_token", ] +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever 0.38.0", +] + [[package]] name = "http" version = "0.2.12" @@ -4318,7 +4384,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.5.10", "system-configuration 0.7.0", "tokio", "tower-service", @@ -4338,7 +4404,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.56.0", ] [[package]] @@ -5065,7 +5131,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.33.0" +version = "1.34.0" dependencies = [ "anyhow", "arboard", @@ -5172,7 +5238,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.33.0" +version = "1.34.0" dependencies = [ "anyhow", "aster-core", @@ -5201,7 +5267,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.33.0" +version = "1.34.0" dependencies = [ "chrono", "futures", @@ -5218,7 +5284,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.33.0" +version = "1.34.0" dependencies = [ "clap", "lime-core", @@ -5230,7 +5296,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.33.0" +version = "1.34.0" dependencies = [ "async-trait", "lime-core", @@ -5246,7 +5312,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.33.0" +version = "1.34.0" dependencies = [ "aster-models", "async-trait", @@ -5299,7 +5365,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.33.0" +version = "1.34.0" dependencies = [ "aes", "axum 0.7.9", @@ -5329,7 +5395,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.33.0" +version = "1.34.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5349,7 +5415,7 @@ dependencies = [ [[package]] name = "lime-knowledge" -version = "1.33.0" +version = "1.34.0" dependencies = [ "chrono", "hex", @@ -5362,7 +5428,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.33.0" +version = "1.34.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5378,7 +5444,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.33.0" +version = "1.34.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5411,7 +5477,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.33.0" +version = "1.34.0" dependencies = [ "async-trait", "lime-core", @@ -5430,7 +5496,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.33.0" +version = "1.34.0" dependencies = [ "anyhow", "async-stream", @@ -5485,7 +5551,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.33.0" +version = "1.34.0" dependencies = [ "aster-core", "async-stream", @@ -5529,7 +5595,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.33.0" +version = "1.34.0" dependencies = [ "axum 0.7.9", "futures", @@ -5544,7 +5610,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.33.0" +version = "1.34.0" dependencies = [ "anyhow", "aster-core", @@ -5589,7 +5655,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.33.0" +version = "1.34.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5607,7 +5673,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.33.0" +version = "1.34.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5718,9 +5784,9 @@ dependencies = [ "log", "phf 0.11.3", "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", - "tendril", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", ] [[package]] @@ -5732,9 +5798,20 @@ dependencies = [ "log", "phf 0.11.3", "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", - "tendril", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril 0.5.0", + "web_atoms", ] [[package]] @@ -5935,23 +6012,23 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.1" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" dependencies = [ "crossbeam-channel", "dpi", "gtk", "keyboard-types", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6348,9 +6425,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -6380,7 +6457,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", "block2 0.6.2", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -6393,7 +6470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -6415,7 +6492,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -6427,7 +6504,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", "dispatch2", - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] @@ -6438,7 +6515,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.11.0", "dispatch2", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] @@ -6461,7 +6538,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -6471,7 +6548,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -6482,7 +6559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", ] @@ -6523,7 +6600,7 @@ dependencies = [ "bitflags 2.11.0", "block2 0.6.2", "libc", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -6534,7 +6611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -6557,7 +6634,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -6582,7 +6659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.11.0", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -6595,7 +6672,7 @@ checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.0", "block2 0.6.2", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-cloud-kit", "objc2-core-data 0.3.2", "objc2-core-foundation", @@ -6614,7 +6691,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -6626,7 +6703,7 @@ checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ "bitflags 2.11.0", "block2 0.6.2", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -6870,7 +6947,7 @@ dependencies = [ "android_system_properties", "log", "nix 0.30.1", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", "objc2-ui-kit", "serde", @@ -6884,7 +6961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -6893,7 +6970,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", "objc2-osa-kit", "serde", @@ -7074,6 +7151,7 @@ checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros 0.13.1", "phf_shared 0.13.1", + "serde", ] [[package]] @@ -7106,6 +7184,16 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.8.0" @@ -7619,7 +7707,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.36", - "socket2 0.6.2", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -7656,7 +7744,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -8085,7 +8173,7 @@ dependencies = [ "gtk-sys", "js-sys", "log", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -8507,7 +8595,7 @@ dependencies = [ "html5ever 0.27.0", "once_cell", "selectors 0.25.0", - "tendril", + "tendril 0.4.3", ] [[package]] @@ -8593,6 +8681,25 @@ dependencies = [ "smallvec", ] +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.0", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash 2.1.1", + "servo_arc 0.4.3", + "smallvec", +] + [[package]] name = "self_cell" version = "1.2.2" @@ -8990,6 +9097,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.6" @@ -9054,7 +9170,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 6.0.0", + "dirs 5.0.1", ] [[package]] @@ -9215,7 +9331,7 @@ dependencies = [ "bytemuck", "js-sys", "ndk 0.9.0", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -9534,6 +9650,18 @@ dependencies = [ "serde", ] +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + [[package]] name = "string_cache_codegen" version = "0.5.4" @@ -9546,6 +9674,18 @@ dependencies = [ "quote", ] +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -9715,35 +9855,35 @@ checksum = "c0e973b34477b7823833469eb0f5a3a60370fef7a453e02d751b59180d0a5a05" [[package]] name = "tao" -version = "0.34.5" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" +checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" dependencies = [ "bitflags 2.11.0", "block2 0.6.2", "core-foundation 0.10.1", - "core-graphics 0.24.0", + "core-graphics 0.25.0", "crossbeam-channel", - "dispatch", + "dbus", + "dispatch2", "dlopen2", "dpi", "gdkwayland-sys", "gdkx11-sys", "gtk", "jni", - "lazy_static", "libc", "log", "ndk 0.9.0", - "ndk-context", "ndk-sys 0.6.0+11769913", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "objc2-ui-kit", "once_cell", "parking_lot", + "percent-encoding", "raw-window-handle", - "scopeguard", "tao-macros", "unicode-segmentation", "url", @@ -9789,9 +9929,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.10.3" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +checksum = "b93bd86d231f0a8138f11a02a584769fe4b703dc36ae133d783228dbc4801405" dependencies = [ "anyhow", "bytes", @@ -9810,7 +9950,7 @@ dependencies = [ "log", "mime", "muda", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit", @@ -9841,9 +9981,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.5.6" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +checksum = "3a318b234cc2dea65f575467bafcfb76286bce228ebc3778e337d61d03213007" dependencies = [ "anyhow", "cargo_toml", @@ -9857,15 +9997,14 @@ dependencies = [ "serde_json", "tauri-utils", "tauri-winres", - "toml 0.9.12+spec-1.1.0", "walkdir", ] [[package]] name = "tauri-codegen" -version = "2.5.5" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +checksum = "6bd11644962add2549a60b7e7c6800f17d7020156e02f516021d8103e80cc528" dependencies = [ "base64 0.22.1", "brotli", @@ -9890,9 +10029,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.5.5" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +checksum = "fed9d3742a37a355d2e47c9af924e9fbc112abb76f9835d35d4780e318419502" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -9935,9 +10074,9 @@ dependencies = [ [[package]] name = "tauri-plugin-deep-link" -version = "2.4.7" +version = "2.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" dependencies = [ "dunce", "plist", @@ -9956,9 +10095,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.6.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" dependencies = [ "log", "raw-window-handle", @@ -9974,13 +10113,15 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.4.5" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" dependencies = [ "anyhow", "dunce", "glob", + "log", + "objc2-foundation 0.3.2", "percent-encoding", "schemars 0.8.22", "serde", @@ -9990,7 +10131,7 @@ dependencies = [ "tauri-plugin", "tauri-utils", "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "toml 1.0.6+spec-1.1.0", "url", ] @@ -10032,9 +10173,9 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.4.0" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" dependencies = [ "serde", "serde_json", @@ -10047,9 +10188,9 @@ dependencies = [ [[package]] name = "tauri-plugin-updater" -version = "2.10.0" +version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" dependencies = [ "base64 0.22.1", "dirs 6.0.0", @@ -10080,16 +10221,16 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.10.1" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +checksum = "8fef478ba1d2ac21c2d528740b24d0cb315e1e8b1111aae53fafac34804371fc" dependencies = [ "cookie", "dpi", "gtk", "http 1.4.0", "jni", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-ui-kit", "objc2-web-kit", "raw-window-handle", @@ -10105,15 +10246,15 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.10.1" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0" dependencies = [ "gtk", "http 1.4.0", "jni", "log", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "once_cell", "percent-encoding", @@ -10131,14 +10272,15 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.8.3" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +checksum = "d57200389a2f82b4b0a40ae29ca19b6978116e8f4d4e974c3234ce40c0ffbdec" dependencies = [ "anyhow", "brotli", "cargo_metadata", "ctor", + "dom_query", "dunce", "glob", "html5ever 0.29.1", @@ -10148,7 +10290,8 @@ dependencies = [ "kuchikiki", "log", "memchr", - "phf 0.11.3", + "phf 0.13.1", + "plist", "proc-macro2", "quote", "regex", @@ -10160,7 +10303,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "toml 1.0.6+spec-1.1.0", "url", "urlpattern", "uuid", @@ -10202,6 +10345,16 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + [[package]] name = "termios" version = "0.2.2" @@ -10519,6 +10672,21 @@ dependencies = [ "winnow 0.7.14", ] +[[package]] +name = "toml" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.14", +] + [[package]] name = "toml_datetime" version = "0.6.3" @@ -10537,6 +10705,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" @@ -10771,24 +10948,24 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.21.3" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" dependencies = [ "crossbeam-channel", "dirs 6.0.0", "libappindicator", "muda", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "once_cell", - "png 0.17.16", + "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11422,6 +11599,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + [[package]] name = "webbrowser" version = "1.1.0" @@ -11432,7 +11621,7 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-foundation 0.3.2", "url", "web-sys", @@ -11634,7 +11823,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -11649,7 +11838,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -11664,7 +11853,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "010797bd7c40396fbc59d3105089fed0885fe267a0ef4a0a4646df54e28647f6" dependencies = [ - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -12544,27 +12733,26 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wry" -version = "0.54.2" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb26159b420aa77684589a744ae9a9461a95395b848764ad12290a14d960a11a" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", "block2 0.6.2", "cookie", "crossbeam-channel", "dirs 6.0.0", + "dom_query", "dpi", "dunce", "gdkx11", "gtk", - "html5ever 0.29.1", "http 1.4.0", "javascriptcore-rs", "jni", - "kuchikiki", "libc", "ndk 0.9.0", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c03625a16..4536e8ecf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -9,7 +9,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "1.33.0" +version = "1.34.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -139,14 +139,14 @@ rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-ch # Tauri -tauri = { version = "2.10.3", features = ["tray-icon", "image-png", "unstable", "macos-private-api"] } -tauri-build = { version = "2", features = [] } -tauri-plugin-shell = "2.3" -tauri-plugin-autostart = "2.3" -tauri-plugin-dialog = "2.5.0" -tauri-plugin-single-instance = "2.3" -tauri-plugin-global-shortcut = "2.3" -tauri-plugin-updater = "2" +tauri = { version = "2.11.1", features = ["tray-icon", "image-png", "unstable", "macos-private-api"] } +tauri-build = { version = "2.6.1", features = [] } +tauri-plugin-shell = "2.3.5" +tauri-plugin-autostart = "2.5.1" +tauri-plugin-dialog = "2.7.1" +tauri-plugin-single-instance = "2.4.2" +tauri-plugin-global-shortcut = "2.3.1" +tauri-plugin-updater = "2.10.1" # 测试 proptest = "1" @@ -194,11 +194,11 @@ version = "0.26" version = "0.2" [workspace.dependencies.tauri-plugin-deep-link] -version = "2.4" +version = "2.4.9" [package] name = "lime" -version = "1.33.0" +version = "1.34.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/aster-rust/crates/aster/src/permission/policy/mod.rs b/src-tauri/crates/aster-rust/crates/aster/src/permission/policy/mod.rs index 0e1bd584a..94c58871c 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/permission/policy/mod.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/permission/policy/mod.rs @@ -1,6 +1,6 @@ //! Tool Policy 系统模块 //! -//! 本模块实现了 OpenClaw 风格的 Tool Policy 系统,提供: +//! 本模块实现 Agent 工具策略系统,提供: //! - Profile 预设配置(minimal, coding, messaging, full, custom) //! - Tool Groups 工具分组(group:fs, group:runtime, group:memory, group:web, group:session) //! - 多层策略合并(Profile → Global → Agent → Session) diff --git a/src-tauri/crates/aster-rust/crates/aster/src/scheduler/types.rs b/src-tauri/crates/aster-rust/crates/aster/src/scheduler/types.rs index 44121295d..1accfeba0 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/scheduler/types.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/scheduler/types.rs @@ -9,7 +9,7 @@ //! //! ## 设计参考 //! -//! 类型设计参考 OpenClaw 的 Cron/Scheduler 系统 +//! 类型设计参考通用 Cron/Scheduler 系统 use chrono::{DateTime, Utc}; use chrono_tz::Tz; diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index f38cf60b5..fbfe82e67 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -869,7 +869,7 @@ impl Default for NavigationConfig { } } -const CURRENT_CONFIGURABLE_NAV_ITEM_IDS: &[&str] = &["plugins", "openclaw", "companion"]; +const CURRENT_CONFIGURABLE_NAV_ITEM_IDS: &[&str] = &["plugins", "companion"]; fn normalize_navigation_enabled_items(items: &[String]) -> Vec { let mut normalized = Vec::new(); @@ -2898,7 +2898,6 @@ mod unit_tests { "video".to_string(), "image-gen".to_string(), "automation".to_string(), - "openclaw".to_string(), "resources".to_string(), "memory".to_string(), ]; @@ -2906,10 +2905,7 @@ mod unit_tests { let changed = config.normalize_workspace_preferences(); assert!(changed); - assert_eq!( - config.navigation.enabled_items, - vec!["openclaw".to_string()] - ); + assert_eq!(config.navigation.enabled_items, Vec::::new()); } #[test] diff --git a/src-tauri/crates/core/src/lib.rs b/src-tauri/crates/core/src/lib.rs index 25e2166ec..2dfb07da4 100644 --- a/src-tauri/crates/core/src/lib.rs +++ b/src-tauri/crates/core/src/lib.rs @@ -59,7 +59,6 @@ pub mod event_emit; // 网络工具 pub mod network; -pub mod openclaw_install; // 凭证清理(敏感信息过滤) pub mod sanitizer; diff --git a/src-tauri/crates/core/src/openclaw_install.rs b/src-tauri/crates/core/src/openclaw_install.rs deleted file mode 100644 index 47ef78090..000000000 --- a/src-tauri/crates/core/src/openclaw_install.rs +++ /dev/null @@ -1,458 +0,0 @@ -use std::path::{Path, PathBuf}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ShellPlatform { - Windows, - Unix, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum OpenClawInstallDependencyKind { - Node, - Git, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum WindowsDependencyInstallPlan { - Winget { package_id: &'static str }, - OfficialInstaller, - ManualDownload, -} - -pub fn command_bin_dir_for(platform: ShellPlatform, binary_path: &str) -> Option { - let separators: &[char] = match platform { - ShellPlatform::Windows => &['\\', '/'], - ShellPlatform::Unix => &['/'], - }; - - let index = binary_path.rfind(separators)?; - if index == 0 { - Some(binary_path[..1].to_string()) - } else { - Some(binary_path[..index].to_string()) - } -} - -fn shell_escape(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\"'\"'")) -} - -pub fn shell_command_escape_for(platform: ShellPlatform, value: &str) -> String { - match platform { - ShellPlatform::Windows => format!("\"{}\"", value.replace('"', "\"\"")), - ShellPlatform::Unix => shell_escape(value), - } -} - -pub fn shell_npm_prefix_assignment_for(platform: ShellPlatform, value: &str) -> String { - match platform { - ShellPlatform::Windows => { - format!( - "set \"NPM_CONFIG_PREFIX={}\" && ", - value.replace('"', "\"\"") - ) - } - ShellPlatform::Unix => format!("NPM_CONFIG_PREFIX={} ", shell_escape(value)), - } -} - -pub fn shell_path_assignment_for(platform: ShellPlatform, binary_path: &str) -> String { - let Some(bin_dir) = command_bin_dir_for(platform, binary_path) else { - return String::new(); - }; - - match platform { - ShellPlatform::Windows => { - format!("set \"PATH={};%PATH%\" && ", bin_dir.replace('"', "\"\"")) - } - ShellPlatform::Unix => format!("PATH={}:$PATH ", shell_escape(&bin_dir)), - } -} - -pub fn shell_command_invocation_prefix_for(platform: ShellPlatform, binary_path: &str) -> String { - match platform { - ShellPlatform::Windows if windows_shell_requires_call(binary_path) => "call ".to_string(), - _ => String::new(), - } -} - -fn windows_shell_requires_call(binary_path: &str) -> bool { - Path::new(binary_path) - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "cmd" | "bat")) - .unwrap_or(false) -} - -fn shell_environment_prefix( - platform: ShellPlatform, - binary_path: &str, - npm_prefix: Option<&str>, -) -> String { - format!( - "{}{}", - shell_path_assignment_for(platform, binary_path), - npm_prefix - .map(|prefix| shell_npm_prefix_assignment_for(platform, prefix)) - .unwrap_or_default() - ) -} - -pub fn build_openclaw_cleanup_command( - platform: ShellPlatform, - npm_path: &str, - npm_prefix: Option<&str>, -) -> String { - format!( - "{}{}{} uninstall -g openclaw @qingchencloud/openclaw-zh", - shell_environment_prefix(platform, npm_path, npm_prefix), - shell_command_invocation_prefix_for(platform, npm_path), - shell_command_escape_for(platform, npm_path) - ) -} - -pub fn build_openclaw_install_command( - platform: ShellPlatform, - npm_path: &str, - npm_prefix: Option<&str>, - package: &str, - registry: Option<&str>, -) -> String { - let registry_suffix = registry - .map(|value| format!(" --registry={value}")) - .unwrap_or_default(); - format!( - "{}{}{} install -g {}{}", - shell_environment_prefix(platform, npm_path, npm_prefix), - shell_command_invocation_prefix_for(platform, npm_path), - shell_command_escape_for(platform, npm_path), - package, - registry_suffix - ) -} - -pub fn resolve_windows_dependency_install_plan( - dependency: OpenClawInstallDependencyKind, - has_winget: bool, -) -> WindowsDependencyInstallPlan { - match (dependency, has_winget) { - (OpenClawInstallDependencyKind::Node, true) => WindowsDependencyInstallPlan::Winget { - package_id: "OpenJS.NodeJS.LTS", - }, - (OpenClawInstallDependencyKind::Node, false) => { - WindowsDependencyInstallPlan::OfficialInstaller - } - (OpenClawInstallDependencyKind::Git, true) => WindowsDependencyInstallPlan::Winget { - package_id: "Git.Git", - }, - (OpenClawInstallDependencyKind::Git, false) => WindowsDependencyInstallPlan::ManualDownload, - } -} - -pub fn build_winget_install_command(winget_path: &str, package_id: &str) -> String { - format!( - "{}{} install --id {} -e --accept-source-agreements --accept-package-agreements", - shell_path_assignment_for(ShellPlatform::Windows, winget_path), - shell_command_escape_for(ShellPlatform::Windows, winget_path), - package_id - ) -} - -pub fn windows_manual_install_message(dependency: OpenClawInstallDependencyKind) -> &'static str { - match dependency { - OpenClawInstallDependencyKind::Node => { - "当前系统缺少 winget,暂时无法一键安装 Node.js,请点击“手动下载 Node.js”完成安装后重试。" - } - OpenClawInstallDependencyKind::Git => { - "当前系统缺少 winget,暂时无法一键安装 Git,请点击“手动下载 Git”完成安装后重试。" - } - } -} - -fn command_path_rank(path: &Path) -> u8 { - match path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.to_ascii_lowercase()) - .as_deref() - { - Some("exe") => 0, - Some("cmd") => 1, - Some("bat") => 2, - _ => 3, - } -} - -pub fn select_preferred_path_candidate(candidates: Vec) -> Option { - candidates - .into_iter() - .min_by_key(|path| command_path_rank(path)) -} - -fn is_better_semver_candidate( - current_best: Option<&(PathBuf, (u64, u64, u64))>, - candidate_path: &Path, - candidate_version: (u64, u64, u64), -) -> bool { - let Some((best_path, best_version)) = current_best else { - return true; - }; - - candidate_version > *best_version - || (candidate_version == *best_version - && command_path_rank(candidate_path) < command_path_rank(best_path)) -} - -type SemVer = (u64, u64, u64); - -pub fn select_best_semver_candidate( - candidates: Vec<(PathBuf, Option)>, - min_version: SemVer, -) -> Option { - let fallback = - select_preferred_path_candidate(candidates.iter().map(|(path, _)| path.clone()).collect()); - let mut best_supported: Option<(PathBuf, (u64, u64, u64))> = None; - let mut best_any: Option<(PathBuf, (u64, u64, u64))> = None; - - for (path, version) in candidates { - let Some(version) = version else { - continue; - }; - - if is_better_semver_candidate(best_any.as_ref(), &path, version) { - best_any = Some((path.clone(), version)); - } - - if version >= min_version - && is_better_semver_candidate(best_supported.as_ref(), &path, version) - { - best_supported = Some((path, version)); - } - } - - best_supported - .or(best_any) - .map(|(path, _)| path) - .or(fallback) -} - -#[cfg(test)] -mod tests { - use super::{ - build_openclaw_cleanup_command, build_openclaw_install_command, - build_winget_install_command, command_bin_dir_for, resolve_windows_dependency_install_plan, - select_best_semver_candidate, select_preferred_path_candidate, shell_command_escape_for, - shell_command_invocation_prefix_for, shell_npm_prefix_assignment_for, - shell_path_assignment_for, windows_manual_install_message, OpenClawInstallDependencyKind, - ShellPlatform, WindowsDependencyInstallPlan, - }; - use std::path::PathBuf; - - const OPENCLAW_CN_PACKAGE: &str = "@qingchencloud/openclaw-zh@latest"; - const OPENCLAW_DEFAULT_PACKAGE: &str = "openclaw@latest"; - const NPM_MIRROR_CN: &str = "https://registry.npmmirror.com"; - - #[test] - fn windows_command_bin_dir_supports_backslash_paths() { - assert_eq!( - command_bin_dir_for(ShellPlatform::Windows, r"C:\Program Files\nodejs\npm.cmd"), - Some(r"C:\Program Files\nodejs".to_string()) - ); - } - - #[test] - fn windows_shell_command_escape_keeps_cmd_compatible_quotes() { - assert_eq!( - shell_command_escape_for(ShellPlatform::Windows, r#"C:\Program Files\nodejs\npm.cmd"#), - r#""C:\Program Files\nodejs\npm.cmd""# - ); - assert_eq!( - shell_command_escape_for(ShellPlatform::Windows, "C:\\demo\\na\"me\\npm.cmd"), - r#""C:\demo\na""me\npm.cmd""# - ); - } - - #[test] - fn windows_shell_npm_prefix_assignment_uses_set_syntax() { - assert_eq!( - shell_npm_prefix_assignment_for( - ShellPlatform::Windows, - r"C:\Users\demo\AppData\Roaming\npm" - ), - r#"set "NPM_CONFIG_PREFIX=C:\Users\demo\AppData\Roaming\npm" && "# - ); - } - - #[test] - fn windows_shell_path_assignment_prepends_binary_directory() { - assert_eq!( - shell_path_assignment_for(ShellPlatform::Windows, r"C:\Program Files\nodejs\npm.cmd"), - r#"set "PATH=C:\Program Files\nodejs;%PATH%" && "# - ); - } - - #[test] - fn windows_cmd_scripts_use_call_invocation_prefix() { - assert_eq!( - shell_command_invocation_prefix_for( - ShellPlatform::Windows, - r"C:\Program Files\nodejs\npm.cmd" - ), - "call " - ); - assert!(shell_command_invocation_prefix_for( - ShellPlatform::Windows, - r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe" - ) - .is_empty()); - } - - #[test] - fn windows_cleanup_command_uses_cmd_compatible_syntax_without_true_fallback() { - let command = build_openclaw_cleanup_command( - ShellPlatform::Windows, - r"C:\Program Files\nodejs\npm.cmd", - Some(r"C:\Users\demo\AppData\Roaming\npm"), - ); - - assert_eq!( - command, - concat!( - "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", - "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", - "call \"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh" - ) - ); - assert!(!command.contains("|| true")); - } - - #[test] - fn windows_install_command_adds_registry_when_using_china_package() { - let command = build_openclaw_install_command( - ShellPlatform::Windows, - r"C:\Program Files\nodejs\npm.cmd", - Some(r"C:\Users\demo\AppData\Roaming\npm"), - OPENCLAW_CN_PACKAGE, - Some(NPM_MIRROR_CN), - ); - - assert_eq!( - command, - concat!( - "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", - "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", - "call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ", - "--registry=https://registry.npmmirror.com" - ) - ); - } - - #[test] - fn windows_install_command_omits_registry_for_default_package() { - let command = build_openclaw_install_command( - ShellPlatform::Windows, - r"C:\Program Files\nodejs\npm.cmd", - None, - OPENCLAW_DEFAULT_PACKAGE, - None, - ); - - assert_eq!( - command, - concat!( - "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", - "call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest" - ) - ); - assert!(!command.contains("--registry=")); - } - - #[test] - fn preferred_path_candidate_prioritizes_windows_executable_extensions() { - let preferred = select_preferred_path_candidate(vec![ - PathBuf::from(r"C:\nvm4w\nodejs\openclaw"), - PathBuf::from(r"C:\nvm4w\nodejs\openclaw.bat"), - PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"), - PathBuf::from(r"C:\nvm4w\nodejs\openclaw.exe"), - ]); - - assert_eq!( - preferred, - Some(PathBuf::from(r"C:\nvm4w\nodejs\openclaw.exe")) - ); - } - - #[test] - fn semver_selection_prefers_windows_launcher_over_bare_file_when_versions_equal() { - let preferred = select_best_semver_candidate( - vec![ - (PathBuf::from(r"C:\nvm4w\nodejs\openclaw"), Some((23, 1, 0))), - ( - PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"), - Some((23, 1, 0)), - ), - ], - (22, 0, 0), - ); - - assert_eq!( - preferred, - Some(PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd")) - ); - } - - #[test] - fn windows_node_prefers_winget_when_available() { - assert_eq!( - resolve_windows_dependency_install_plan(OpenClawInstallDependencyKind::Node, true), - WindowsDependencyInstallPlan::Winget { - package_id: "OpenJS.NodeJS.LTS" - } - ); - } - - #[test] - fn windows_node_falls_back_to_official_installer_without_winget() { - assert_eq!( - resolve_windows_dependency_install_plan(OpenClawInstallDependencyKind::Node, false), - WindowsDependencyInstallPlan::OfficialInstaller - ); - } - - #[test] - fn windows_git_prefers_winget_when_available() { - assert_eq!( - resolve_windows_dependency_install_plan(OpenClawInstallDependencyKind::Git, true), - WindowsDependencyInstallPlan::Winget { - package_id: "Git.Git" - } - ); - } - - #[test] - fn windows_git_requires_manual_download_without_winget() { - assert_eq!( - resolve_windows_dependency_install_plan(OpenClawInstallDependencyKind::Git, false), - WindowsDependencyInstallPlan::ManualDownload - ); - assert_eq!( - windows_manual_install_message(OpenClawInstallDependencyKind::Git), - "当前系统缺少 winget,暂时无法一键安装 Git,请点击“手动下载 Git”完成安装后重试。" - ); - } - - #[test] - fn winget_install_command_uses_expected_windows_flags() { - assert_eq!( - build_winget_install_command( - r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe", - "OpenJS.NodeJS.LTS" - ), - concat!( - "set \"PATH=C:\\Users\\demo\\AppData\\Local\\Microsoft\\WindowsApps;%PATH%\" && ", - "\"C:\\Users\\demo\\AppData\\Local\\Microsoft\\WindowsApps\\winget.exe\" install ", - "--id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements" - ) - ); - } -} diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 5e3b02798..613acbaef 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -265,7 +265,6 @@ pub fn run() { .manage(lime_gateway::wechat::WechatGatewayState::default()) .manage(lime_gateway::wechat::WechatLoginState::default()) .manage(gateway_tunnel_state) - .manage(crate::services::openclaw_service::OpenClawServiceState::default()) .manage(commands::telegram_remote_cmd::TelegramRemoteState::default()) .on_window_event(move |window, event| { // 处理窗口关闭事件 @@ -1051,32 +1050,6 @@ pub fn run() { commands::companion_cmd::companion_get_pet_status, commands::companion_cmd::companion_launch_pet, commands::companion_cmd::companion_send_pet_command, - // OpenClaw commands - commands::openclaw_cmd::openclaw_check_installed, - commands::openclaw_cmd::openclaw_get_environment_status, - commands::openclaw_cmd::openclaw_check_node_version, - commands::openclaw_cmd::openclaw_check_git_available, - commands::openclaw_cmd::openclaw_get_node_download_url, - commands::openclaw_cmd::openclaw_get_git_download_url, - commands::openclaw_cmd::openclaw_get_command_preview, - commands::openclaw_cmd::openclaw_get_progress_logs, - commands::openclaw_cmd::openclaw_list_runtime_candidates, - commands::openclaw_cmd::openclaw_install, - commands::openclaw_cmd::openclaw_install_dependency, - commands::openclaw_cmd::openclaw_check_update, - commands::openclaw_cmd::openclaw_uninstall, - commands::openclaw_cmd::openclaw_perform_update, - commands::openclaw_cmd::openclaw_set_preferred_runtime, - commands::openclaw_cmd::openclaw_cleanup_temp_artifacts, - commands::openclaw_cmd::openclaw_start_gateway, - commands::openclaw_cmd::openclaw_stop_gateway, - commands::openclaw_cmd::openclaw_restart_gateway, - commands::openclaw_cmd::openclaw_get_status, - commands::openclaw_cmd::openclaw_check_health, - commands::openclaw_cmd::openclaw_get_dashboard_url, - commands::openclaw_cmd::openclaw_get_channels, - commands::openclaw_cmd::openclaw_sync_provider_config, - commands::openclaw_cmd::openclaw_install_event, // MCP commands commands::mcp_cmd::get_mcp_servers, commands::mcp_cmd::add_mcp_server, @@ -1710,7 +1683,7 @@ mod tests { #[test] fn should_only_minimize_main_window_to_tray() { assert!(should_minimize_to_tray("main", true)); - assert!(!should_minimize_to_tray("openclaw-dashboard", true)); + assert!(!should_minimize_to_tray("secondary-window", true)); assert!(!should_minimize_to_tray("main", false)); } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index efd37c3ca..204ea6595 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -39,7 +39,6 @@ pub mod memory_search_cmd; pub(crate) mod modality_runtime_contracts; pub mod model_registry_cmd; pub mod models_cmd; -pub mod openclaw_cmd; pub mod persona_cmd; pub mod plugin_cmd; pub mod plugin_install_cmd; diff --git a/src-tauri/src/commands/openclaw_cmd.rs b/src-tauri/src/commands/openclaw_cmd.rs deleted file mode 100644 index a5b7f1b2f..000000000 --- a/src-tauri/src/commands/openclaw_cmd.rs +++ /dev/null @@ -1,262 +0,0 @@ -use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; -use crate::database::DbConnection; -use crate::services::openclaw_service::{ - openclaw_install_event_name, ActionResult, BinaryAvailabilityStatus, BinaryInstallStatus, - ChannelInfo, CommandPreview, EnvironmentStatus, GatewayStatusInfo, HealthInfo, - InstallProgressEvent, NodeCheckResult, OpenClawRuntimeCandidate, OpenClawServiceState, - SyncModelEntry, UpdateInfo, -}; -use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, State}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OpenClawSyncConfigRequest { - pub provider_id: String, - pub primary_model_id: String, - #[serde(default)] - pub models: Vec, -} - -#[tauri::command] -pub async fn openclaw_check_installed( - service: State<'_, OpenClawServiceState>, -) -> Result { - let service = service.0.lock().await; - service.check_installed().await -} - -#[tauri::command] -pub async fn openclaw_get_environment_status( - service: State<'_, OpenClawServiceState>, -) -> Result { - let service = service.0.lock().await; - service.get_environment_status().await -} - -#[tauri::command] -pub async fn openclaw_check_node_version( - service: State<'_, OpenClawServiceState>, -) -> Result { - let service = service.0.lock().await; - service.check_node_version().await -} - -#[tauri::command] -pub async fn openclaw_check_git_available( - service: State<'_, OpenClawServiceState>, -) -> Result { - let service = service.0.lock().await; - service.check_git_available().await -} - -#[tauri::command] -pub async fn openclaw_get_node_download_url( - service: State<'_, OpenClawServiceState>, -) -> Result { - let service = service.0.lock().await; - Ok(service.get_node_download_url()) -} - -#[tauri::command] -pub async fn openclaw_get_git_download_url( - service: State<'_, OpenClawServiceState>, -) -> Result { - let service = service.0.lock().await; - Ok(service.get_git_download_url()) -} - -#[tauri::command] -pub async fn openclaw_get_command_preview( - app: AppHandle, - service: State<'_, OpenClawServiceState>, - operation: String, - port: Option, -) -> Result { - let mut service = service.0.lock().await; - service.get_command_preview(&app, &operation, port).await -} - -#[tauri::command] -pub async fn openclaw_install( - app: AppHandle, - service: State<'_, OpenClawServiceState>, -) -> Result { - let mut service = service.0.lock().await; - service.clear_progress_logs(); - service.install(&app).await -} - -#[tauri::command] -pub async fn openclaw_install_dependency( - app: AppHandle, - service: State<'_, OpenClawServiceState>, - kind: String, -) -> Result { - let mut service = service.0.lock().await; - service.clear_progress_logs(); - service.install_dependency(&app, &kind).await -} - -#[tauri::command] -pub async fn openclaw_cleanup_temp_artifacts( - app: AppHandle, - service: State<'_, OpenClawServiceState>, -) -> Result { - let mut service = service.0.lock().await; - service.cleanup_temp_artifacts(Some(&app)).await -} - -#[tauri::command] -pub async fn openclaw_check_update( - service: State<'_, OpenClawServiceState>, -) -> Result { - let service = service.0.lock().await; - service.check_update().await -} - -#[tauri::command] -pub async fn openclaw_uninstall( - app: AppHandle, - service: State<'_, OpenClawServiceState>, -) -> Result { - let mut service = service.0.lock().await; - service.clear_progress_logs(); - service.uninstall(&app).await -} - -#[tauri::command] -pub async fn openclaw_perform_update( - app: AppHandle, - service: State<'_, OpenClawServiceState>, -) -> Result { - let mut service = service.0.lock().await; - service.clear_progress_logs(); - service.perform_update(&app).await -} - -#[tauri::command] -pub async fn openclaw_start_gateway( - app: AppHandle, - port: Option, - service: State<'_, OpenClawServiceState>, -) -> Result { - let mut service = service.0.lock().await; - service.clear_progress_logs(); - service.start_gateway(Some(&app), port).await -} - -#[tauri::command] -pub async fn openclaw_stop_gateway( - app: AppHandle, - service: State<'_, OpenClawServiceState>, -) -> Result { - let mut service = service.0.lock().await; - service.clear_progress_logs(); - service.stop_gateway(Some(&app)).await -} - -#[tauri::command] -pub async fn openclaw_restart_gateway( - app: AppHandle, - service: State<'_, OpenClawServiceState>, -) -> Result { - let mut service = service.0.lock().await; - service.clear_progress_logs(); - service.restart_gateway(&app).await -} - -#[tauri::command] -pub async fn openclaw_get_progress_logs( - service: State<'_, OpenClawServiceState>, -) -> Result, String> { - let service = service.0.lock().await; - Ok(service.get_progress_logs()) -} - -#[tauri::command] -pub async fn openclaw_list_runtime_candidates( - service: State<'_, OpenClawServiceState>, -) -> Result, String> { - let service = service.0.lock().await; - service.list_runtime_candidates().await -} - -#[tauri::command] -pub async fn openclaw_set_preferred_runtime( - runtime_id: Option, - service: State<'_, OpenClawServiceState>, -) -> Result { - let service = service.0.lock().await; - service.set_preferred_runtime(runtime_id.as_deref()).await -} - -#[tauri::command] -pub async fn openclaw_get_status( - service: State<'_, OpenClawServiceState>, -) -> Result { - let mut service = service.0.lock().await; - service.get_status().await -} - -#[tauri::command] -pub async fn openclaw_check_health( - service: State<'_, OpenClawServiceState>, -) -> Result { - let mut service = service.0.lock().await; - service.check_health().await -} - -#[tauri::command] -pub async fn openclaw_get_dashboard_url( - service: State<'_, OpenClawServiceState>, -) -> Result { - let mut service = service.0.lock().await; - Ok(service.get_dashboard_url()) -} - -#[tauri::command] -pub async fn openclaw_get_channels( - service: State<'_, OpenClawServiceState>, -) -> Result, String> { - let mut service = service.0.lock().await; - service.get_channels().await -} - -#[tauri::command] -pub async fn openclaw_sync_provider_config( - request: OpenClawSyncConfigRequest, - db: State<'_, DbConnection>, - api_key_service: State<'_, ApiKeyProviderServiceState>, - service: State<'_, OpenClawServiceState>, -) -> Result { - let provider = api_key_service - .0 - .get_provider(&db, &request.provider_id)? - .ok_or_else(|| "未找到指定 Provider。".to_string())?; - - if !provider.provider.enabled { - return Ok(ActionResult { - success: false, - message: "该 Provider 已被禁用。".to_string(), - }); - } - - let api_key = api_key_service - .0 - .get_next_api_key(&db, &request.provider_id)? - .unwrap_or_default(); - - let mut service = service.0.lock().await; - service.sync_provider_config( - &provider.provider, - &api_key, - &request.primary_model_id, - &request.models, - ) -} - -#[tauri::command] -pub fn openclaw_install_event() -> String { - openclaw_install_event_name().to_string() -} diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 1906a0d6b..9fde55c69 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -18,7 +18,6 @@ mod media_tasks; mod memory; mod memory_runtime; mod models; -mod openclaw; mod plugins; mod project_resources; mod providers; @@ -159,10 +158,6 @@ pub async fn handle_command( return Ok(result); } - if let Some(result) = openclaw::try_handle(state, cmd, args.as_ref()).await? { - return Ok(result); - } - if let Some(result) = plugins::try_handle(state, cmd, args.as_ref()).await? { return Ok(result); } diff --git a/src-tauri/src/dev_bridge/dispatcher/openclaw.rs b/src-tauri/src/dev_bridge/dispatcher/openclaw.rs deleted file mode 100644 index 6c5de3444..000000000 --- a/src-tauri/src/dev_bridge/dispatcher/openclaw.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::dev_bridge::DevBridgeState; -use serde_json::Value as JsonValue; -use std::sync::Arc; -use tauri::{AppHandle, Manager}; -use tokio::sync::Mutex; - -mod operations; -mod queries; - -type DynError = Box; -type OpenClawServiceHandle = Arc>; - -fn openclaw_service_handle(app_handle: &AppHandle) -> OpenClawServiceHandle { - app_handle - .state::() - .0 - .clone() -} - -fn openclaw_context( - state: &DevBridgeState, -) -> Result<(AppHandle, OpenClawServiceHandle), DynError> { - let app_handle = super::require_app_handle(state)?; - let service = openclaw_service_handle(&app_handle); - Ok((app_handle, service)) -} - -pub(super) async fn try_handle( - state: &DevBridgeState, - cmd: &str, - args: Option<&JsonValue>, -) -> Result, DynError> { - if let Some(result) = queries::try_handle(state, cmd, args).await? { - return Ok(Some(result)); - } - - operations::try_handle(state, cmd, args).await -} diff --git a/src-tauri/src/dev_bridge/dispatcher/openclaw/operations.rs b/src-tauri/src/dev_bridge/dispatcher/openclaw/operations.rs deleted file mode 100644 index a6b2c12b5..000000000 --- a/src-tauri/src/dev_bridge/dispatcher/openclaw/operations.rs +++ /dev/null @@ -1,129 +0,0 @@ -use super::super::{args_or_default, get_db, get_string_arg, parse_nested_arg}; -use super::{openclaw_context, DynError}; -use crate::dev_bridge::DevBridgeState; -use serde_json::Value as JsonValue; - -pub(super) async fn try_handle( - state: &DevBridgeState, - cmd: &str, - args: Option<&JsonValue>, -) -> Result, DynError> { - let result = match cmd { - "openclaw_get_command_preview" => { - let (app_handle, service) = openclaw_context(state)?; - let args = args_or_default(args); - let operation = get_string_arg(&args, "operation", "operation")?; - let port = args - .get("port") - .and_then(|value| value.as_u64()) - .map(|value| value as u16); - let mut service = service.lock().await; - serde_json::to_value( - service - .get_command_preview(&app_handle, &operation, port) - .await?, - )? - } - "openclaw_install" => { - let (app_handle, service) = openclaw_context(state)?; - let mut service = service.lock().await; - service.clear_progress_logs(); - serde_json::to_value(service.install(&app_handle).await?)? - } - "openclaw_install_dependency" => { - let (app_handle, service) = openclaw_context(state)?; - let args = args_or_default(args); - let kind = get_string_arg(&args, "kind", "kind")?; - let mut service = service.lock().await; - service.clear_progress_logs(); - serde_json::to_value(service.install_dependency(&app_handle, &kind).await?)? - } - "openclaw_uninstall" => { - let (app_handle, service) = openclaw_context(state)?; - let mut service = service.lock().await; - service.clear_progress_logs(); - serde_json::to_value(service.uninstall(&app_handle).await?)? - } - "openclaw_check_update" => { - let (_app_handle, service) = openclaw_context(state)?; - let service = service.lock().await; - serde_json::to_value(service.check_update().await?)? - } - "openclaw_cleanup_temp_artifacts" => { - let (app_handle, service) = openclaw_context(state)?; - let mut service = service.lock().await; - serde_json::to_value(service.cleanup_temp_artifacts(Some(&app_handle)).await?)? - } - "openclaw_perform_update" => { - let (app_handle, service) = openclaw_context(state)?; - let mut service = service.lock().await; - service.clear_progress_logs(); - serde_json::to_value(service.perform_update(&app_handle).await?)? - } - "openclaw_set_preferred_runtime" => { - let (_app_handle, service) = openclaw_context(state)?; - let args = args_or_default(args); - let runtime_id = args - .get("runtimeId") - .and_then(|value| value.as_str()) - .map(str::to_string); - let service = service.lock().await; - serde_json::to_value(service.set_preferred_runtime(runtime_id.as_deref()).await?)? - } - "openclaw_start_gateway" => { - let (app_handle, service) = openclaw_context(state)?; - let port = args - .and_then(|value| value.get("port")) - .and_then(|value| value.as_u64()) - .map(|value| value as u16); - let mut service = service.lock().await; - service.clear_progress_logs(); - serde_json::to_value(service.start_gateway(Some(&app_handle), port).await?)? - } - "openclaw_stop_gateway" => { - let (app_handle, service) = openclaw_context(state)?; - let mut service = service.lock().await; - service.clear_progress_logs(); - serde_json::to_value(service.stop_gateway(Some(&app_handle)).await?)? - } - "openclaw_restart_gateway" => { - let (app_handle, service) = openclaw_context(state)?; - let mut service = service.lock().await; - service.clear_progress_logs(); - serde_json::to_value(service.restart_gateway(&app_handle).await?)? - } - "openclaw_sync_provider_config" => { - let (_app_handle, service) = openclaw_context(state)?; - let args = args_or_default(args); - let request: crate::commands::openclaw_cmd::OpenClawSyncConfigRequest = - parse_nested_arg(&args, "request")?; - let db = get_db(state)?; - let provider = state - .api_key_provider_service - .get_provider(db, &request.provider_id)? - .ok_or_else(|| "未找到指定 Provider。".to_string())?; - - if !provider.provider.enabled { - return Ok(Some(serde_json::json!({ - "success": false, - "message": "该 Provider 已被禁用。" - }))); - } - - let api_key = state - .api_key_provider_service - .get_next_api_key(db, &request.provider_id)? - .unwrap_or_default(); - let mut service = service.lock().await; - serde_json::to_value(service.sync_provider_config( - &provider.provider, - &api_key, - &request.primary_model_id, - &request.models, - )?)? - } - _ => return Ok(None), - }; - - Ok(Some(result)) -} diff --git a/src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs b/src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs deleted file mode 100644 index 30e787022..000000000 --- a/src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs +++ /dev/null @@ -1,82 +0,0 @@ -use super::{openclaw_context, DynError}; -use crate::dev_bridge::DevBridgeState; -use serde_json::Value as JsonValue; - -pub(super) async fn try_handle( - state: &DevBridgeState, - cmd: &str, - _args: Option<&JsonValue>, -) -> Result, DynError> { - let result = match cmd { - "openclaw_check_installed" => { - let (_app_handle, service) = openclaw_context(state)?; - let service = service.lock().await; - let result = service.check_installed().await?; - serde_json::to_value(result)? - } - "openclaw_get_environment_status" => { - let (_app_handle, service) = openclaw_context(state)?; - let service = service.lock().await; - let result = service.get_environment_status().await?; - serde_json::to_value(result)? - } - "openclaw_check_node_version" => { - let (_app_handle, service) = openclaw_context(state)?; - let service = service.lock().await; - let result = service.check_node_version().await?; - serde_json::to_value(result)? - } - "openclaw_check_git_available" => { - let (_app_handle, service) = openclaw_context(state)?; - let service = service.lock().await; - let result = service.check_git_available().await?; - serde_json::to_value(result)? - } - "openclaw_get_node_download_url" => { - let (_app_handle, service) = openclaw_context(state)?; - let service = service.lock().await; - let result = service.get_node_download_url(); - serde_json::json!(result) - } - "openclaw_get_git_download_url" => { - let (_app_handle, service) = openclaw_context(state)?; - let service = service.lock().await; - let result = service.get_git_download_url(); - serde_json::json!(result) - } - "openclaw_get_status" => { - let (_app_handle, service) = openclaw_context(state)?; - let mut service = service.lock().await; - serde_json::to_value(service.get_status().await?)? - } - "openclaw_check_health" => { - let (_app_handle, service) = openclaw_context(state)?; - let mut service = service.lock().await; - serde_json::to_value(service.check_health().await?)? - } - "openclaw_get_dashboard_url" => { - let (_app_handle, service) = openclaw_context(state)?; - let mut service = service.lock().await; - serde_json::json!(service.get_dashboard_url()) - } - "openclaw_get_channels" => { - let (_app_handle, service) = openclaw_context(state)?; - let mut service = service.lock().await; - serde_json::to_value(service.get_channels().await?)? - } - "openclaw_get_progress_logs" => { - let (_app_handle, service) = openclaw_context(state)?; - let service = service.lock().await; - let result = service.get_progress_logs(); - serde_json::to_value(result)? - } - "openclaw_list_runtime_candidates" => { - let (_app_handle, service) = openclaw_context(state)?; - let service = service.lock().await; - serde_json::to_value(service.list_runtime_candidates().await?)? - } - _ => return Ok(None), - }; - - Ok(Some(result)) -} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 42ccc3dd2..4a7a9f3d7 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -30,7 +30,6 @@ pub mod memory_import_parser_service; pub mod memory_profile_prompt_service; pub mod memory_rules_loader_service; pub mod memory_source_resolver_service; -pub mod openclaw_service; pub mod runtime_agents_template_service; pub mod runtime_analysis_handoff_service; pub mod runtime_auxiliary_projection_service; diff --git a/src-tauri/src/services/openclaw_service/config.rs b/src-tauri/src/services/openclaw_service/config.rs deleted file mode 100644 index 476880854..000000000 --- a/src-tauri/src/services/openclaw_service/config.rs +++ /dev/null @@ -1,341 +0,0 @@ -use super::*; - -impl OpenClawService { - pub fn sync_provider_config( - &mut self, - provider: &ApiKeyProvider, - api_key: &str, - primary_model_id: &str, - models: &[SyncModelEntry], - ) -> Result { - if api_key.trim().is_empty() && provider.provider_type != ApiProviderType::Ollama { - return Ok(ActionResult { - success: false, - message: "该 Provider 没有可用的 API Key。".to_string(), - }); - } - - let api_type = determine_api_type(provider.provider_type)?; - let base_url = format_provider_base_url(provider)?; - let provider_key = format!("lime-{}", provider.id); - - let normalized_models = if models.is_empty() { - vec![SyncModelEntry { - id: primary_model_id.to_string(), - name: primary_model_id.to_string(), - context_window: None, - }] - } else { - let mut items = models.to_vec(); - if !items.iter().any(|item| item.id == primary_model_id) { - items.insert( - 0, - SyncModelEntry { - id: primary_model_id.to_string(), - name: primary_model_id.to_string(), - context_window: None, - }, - ); - } - items - }; - - self.ensure_runtime_config( - Some(( - &provider_key, - json!({ - "baseUrl": base_url, - "apiKey": api_key, - "api": api_type, - "models": normalized_models - .iter() - .map(sync_model_entry_to_config_value) - .collect::>() - }), - )), - Some(format!("{provider_key}/{primary_model_id}")), - )?; - - Ok(ActionResult { - success: true, - message: format!("已同步 Provider“{}”到 OpenClaw。", provider.name), - }) - } - pub(crate) fn ensure_runtime_config( - &mut self, - provider_entry: Option<(&str, Value)>, - primary_model: Option, - ) -> Result<(), String> { - let config_dir = openclaw_config_dir(); - std::fs::create_dir_all(&config_dir).map_err(|e| format!("创建配置目录失败: {e}"))?; - - let lime_config_path = openclaw_lime_config_path(); - let mut config = read_base_openclaw_config()?; - sanitize_runtime_config(&mut config); - - if self.gateway_auth_token.is_empty() { - self.gateway_auth_token = generate_auth_token(); - } - - apply_gateway_runtime_defaults(&mut config, self.gateway_port, &self.gateway_auth_token); - - if let Some((provider_key, provider_value)) = provider_entry { - set_json_path( - &mut config, - &["models", "mode"], - Value::String("merge".to_string()), - ); - set_json_path( - &mut config, - &["models", "providers", provider_key], - provider_value, - ); - } - - if let Some(primary) = primary_model { - set_json_path( - &mut config, - &["agents", "defaults", "model", "primary"], - Value::String(primary), - ); - } - - let content = - serde_json::to_string_pretty(&config).map_err(|e| format!("序列化配置失败: {e}"))?; - std::fs::write(lime_config_path, content).map_err(|e| format!("写入配置失败: {e}"))?; - Ok(()) - } -} - -pub(crate) fn openclaw_config_dir() -> PathBuf { - home_dir() - .or_else(data_dir) - .unwrap_or_else(|| PathBuf::from(".")) - .join(".openclaw") -} - -pub(crate) fn openclaw_original_config_path() -> PathBuf { - openclaw_config_dir().join("openclaw.json") -} - -pub(crate) fn openclaw_lime_config_path() -> PathBuf { - openclaw_config_dir().join("openclaw.lime.json") -} - -pub(crate) fn read_base_openclaw_config() -> Result { - let lime_path = openclaw_lime_config_path(); - if lime_path.exists() { - return read_json_file(&lime_path); - } - - let original_path = openclaw_original_config_path(); - if original_path.exists() { - return read_json_file(&original_path); - } - - Ok(json!({})) -} - -pub(crate) fn read_json_file(path: &Path) -> Result { - let content = std::fs::read_to_string(path) - .map_err(|e| format!("读取配置文件失败({}): {e}", path.display()))?; - serde_json::from_str(&content).map_err(|e| format!("解析配置文件失败({}): {e}", path.display())) -} - -pub(crate) fn ensure_path_object<'a>( - root: &'a mut Value, - path: &[&str], -) -> &'a mut Map { - let mut current = root; - for segment in path { - let object = ensure_value_object(current); - current = object - .entry((*segment).to_string()) - .or_insert_with(|| Value::Object(Map::new())); - } - ensure_value_object(current) -} - -pub(crate) fn set_json_path(root: &mut Value, path: &[&str], value: Value) { - if path.is_empty() { - *root = value; - return; - } - - let parent = ensure_path_object(root, &path[..path.len() - 1]); - parent.insert(path[path.len() - 1].to_string(), value); -} - -pub(crate) fn sync_model_entry_to_config_value(model: &SyncModelEntry) -> Value { - let mut entry = Map::new(); - entry.insert("id".to_string(), Value::String(model.id.clone())); - entry.insert("name".to_string(), Value::String(model.name.clone())); - if let Some(context_window) = model.context_window { - entry.insert( - "contextWindow".to_string(), - Value::Number(context_window.into()), - ); - } - Value::Object(entry) -} - -pub(crate) fn sanitize_runtime_config(config: &mut Value) { - let Some(providers) = config - .get_mut("models") - .and_then(|models| models.get_mut("providers")) - .and_then(Value::as_object_mut) - else { - return; - }; - - for provider in providers.values_mut() { - let Some(models) = provider.get_mut("models").and_then(Value::as_array_mut) else { - continue; - }; - - for model in models { - let Some(entry) = model.as_object_mut() else { - continue; - }; - - if matches!(entry.get("contextWindow"), Some(Value::Null)) { - entry.remove("contextWindow"); - } - } - } -} - -pub(crate) fn apply_gateway_runtime_defaults( - config: &mut Value, - gateway_port: u16, - gateway_auth_token: &str, -) { - ensure_path_object(config, &["gateway"]); - set_json_path( - config, - &["gateway", "mode"], - Value::String("local".to_string()), - ); - set_json_path( - config, - &["gateway", "bind"], - Value::String("loopback".to_string()), - ); - set_json_path( - config, - &["gateway", "port"], - Value::Number(gateway_port.into()), - ); - set_json_path( - config, - &["gateway", "auth", "mode"], - Value::String("token".to_string()), - ); - set_json_path( - config, - &["gateway", "auth", "token"], - Value::String(gateway_auth_token.to_string()), - ); - set_json_path( - config, - &["gateway", "remote", "token"], - Value::String(gateway_auth_token.to_string()), - ); -} - -pub(crate) fn ensure_value_object(value: &mut Value) -> &mut Map { - if !value.is_object() { - *value = Value::Object(Map::new()); - } - value.as_object_mut().expect("value should be object") -} - -pub(crate) fn extract_gateway_auth_token(config: &Value) -> Option { - config - .get("gateway") - .and_then(|gateway| { - gateway - .get("auth") - .and_then(|auth| auth.get("token")) - .or_else(|| gateway.get("remote").and_then(|remote| remote.get("token"))) - }) - .and_then(Value::as_str) - .map(str::trim) - .filter(|token| !token.is_empty()) - .map(ToString::to_string) -} - -pub(crate) fn determine_api_type(provider_type: ApiProviderType) -> Result<&'static str, String> { - match provider_type { - ApiProviderType::Anthropic | ApiProviderType::AnthropicCompatible => { - Ok("anthropic-messages") - } - ApiProviderType::OpenaiResponse => Ok("openai-responses"), - ApiProviderType::Openai - | ApiProviderType::Codex - | ApiProviderType::Gemini - | ApiProviderType::Ollama - | ApiProviderType::Fal - | ApiProviderType::NewApi - | ApiProviderType::Gateway => Ok("openai-completions"), - ApiProviderType::AzureOpenai | ApiProviderType::Vertexai | ApiProviderType::AwsBedrock => { - Err("当前暂不支持将该 Provider 同步到 OpenClaw。".to_string()) - } - } -} - -pub(crate) fn format_provider_base_url(provider: &ApiKeyProvider) -> Result { - let api_host = trim_trailing_slash(&provider.api_host); - - match provider.provider_type { - ApiProviderType::Anthropic | ApiProviderType::AnthropicCompatible => Ok(api_host), - ApiProviderType::Gemini => { - if api_host.contains("generativelanguage.googleapis.com") { - if api_host.ends_with("/v1beta/openai") { - Ok(api_host) - } else { - Ok(format!("{api_host}/v1beta/openai")) - } - } else if has_api_version(&api_host) { - Ok(api_host) - } else { - Ok(format!("{api_host}/v1")) - } - } - ApiProviderType::Gateway => { - if api_host.ends_with("/v1/ai") { - Ok(api_host.trim_end_matches("/ai").to_string()) - } else if has_api_version(&api_host) { - Ok(api_host) - } else { - Ok(format!("{api_host}/v1")) - } - } - ApiProviderType::Openai - | ApiProviderType::OpenaiResponse - | ApiProviderType::Codex - | ApiProviderType::Ollama - | ApiProviderType::Fal - | ApiProviderType::NewApi => { - if has_api_version(&api_host) { - Ok(api_host) - } else { - Ok(format!("{api_host}/v1")) - } - } - ApiProviderType::AzureOpenai | ApiProviderType::Vertexai | ApiProviderType::AwsBedrock => { - Err("当前暂不支持将该 Provider 同步到 OpenClaw。".to_string()) - } - } -} - -pub(crate) fn trim_trailing_slash(value: &str) -> String { - value.trim().trim_end_matches('/').to_string() -} - -pub(crate) fn has_api_version(url: &str) -> bool { - static VERSION_RE: OnceLock = OnceLock::new(); - VERSION_RE - .get_or_init(|| Regex::new(r"/v\d+(?:[./]|$)").expect("regex should compile")) - .is_match(url) -} diff --git a/src-tauri/src/services/openclaw_service/diagnostics.rs b/src-tauri/src/services/openclaw_service/diagnostics.rs deleted file mode 100644 index cee6e9d65..000000000 --- a/src-tauri/src/services/openclaw_service/diagnostics.rs +++ /dev/null @@ -1,482 +0,0 @@ -use super::*; - -impl OpenClawService { - pub async fn get_environment_status(&self) -> Result { - let node = inspect_node_dependency_status().await?; - let git = inspect_git_dependency_status().await?; - let openclaw = inspect_openclaw_dependency_status().await?; - let diagnostics = collect_environment_diagnostics().await; - - Ok(build_environment_status(node, git, openclaw, diagnostics)) - } - - pub async fn check_installed(&self) -> Result { - let openclaw = inspect_openclaw_dependency_status().await?; - Ok(BinaryInstallStatus { - installed: openclaw.status == "ok", - path: openclaw.path, - }) - } - - pub async fn check_git_available(&self) -> Result { - let git = inspect_git_dependency_status().await?; - Ok(BinaryAvailabilityStatus { - available: git.status == "ok", - path: git.path, - }) - } - - pub async fn check_node_version(&self) -> Result { - let node = inspect_node_dependency_status().await?; - Ok(NodeCheckResult { - status: match node.status.as_str() { - "missing" => "not_found".to_string(), - other => other.to_string(), - }, - version: node.version, - path: node.path, - }) - } - - pub fn get_node_download_url(&self) -> String { - if cfg!(target_os = "windows") { - "https://nodejs.org/en/download".to_string() - } else if cfg!(target_os = "macos") { - "https://nodejs.org/en/download".to_string() - } else if cfg!(target_os = "linux") { - "https://nodejs.org/en/download".to_string() - } else { - "https://nodejs.org/en/download".to_string() - } - } - - pub fn get_git_download_url(&self) -> String { - if cfg!(target_os = "windows") { - "https://git-scm.com/download/win".to_string() - } else if cfg!(target_os = "macos") { - "https://git-scm.com/download/mac".to_string() - } else if cfg!(target_os = "linux") { - "https://git-scm.com/download/linux".to_string() - } else { - "https://git-scm.com/downloads".to_string() - } - } -} - -#[cfg(any(target_os = "windows", test))] -pub(crate) fn windows_dependency_setup_message( - dependency: DependencyKind, - status: &DependencyStatus, -) -> String { - let guidance = match dependency { - DependencyKind::Node => format!( - "Windows 下请先从 nodejs.org 安装或升级 Node.js {}+,完成后点击“重新检测”,再安装 OpenClaw。", - NODE_MIN_VERSION.0 - ), - DependencyKind::Git => { - "Windows 下请先从 git-scm.com 安装 Git(安装时请勾选加入 PATH),完成后点击“重新检测”,再安装 OpenClaw。" - .to_string() - } - }; - - format!("{} {}", status.message, guidance) -} - -#[cfg(any(target_os = "windows", test))] -pub(crate) fn windows_dependency_action_result( - dependency: DependencyKind, - status: &DependencyStatus, -) -> ActionResult { - ActionResult { - success: false, - message: windows_dependency_setup_message(dependency, status), - } -} - -#[cfg(any(target_os = "windows", test))] -pub(crate) fn windows_install_block_result( - node_status: &DependencyStatus, - git_status: &DependencyStatus, -) -> Option { - if node_status.status != "ok" { - return Some(windows_dependency_action_result( - DependencyKind::Node, - node_status, - )); - } - - if git_status.status != "ok" { - return Some(windows_dependency_action_result( - DependencyKind::Git, - git_status, - )); - } - - None -} - -pub(crate) fn dependency_setup_summary(dependency: DependencyKind) -> String { - if cfg!(target_os = "windows") { - return match dependency { - DependencyKind::Node => format!( - "当前缺少可用的 Node.js {}+ 运行时,Windows 下请先手动安装 Node.js,完成后点击“重新检测”,再安装 OpenClaw。", - NODE_MIN_VERSION.0 - ), - DependencyKind::Git => { - "当前缺少可用的 Git,Windows 下请先手动安装 Git(安装时请勾选加入 PATH),完成后点击“重新检测”,再安装 OpenClaw。" - .to_string() - } - }; - } - - if cfg!(target_os = "macos") { - return match dependency { - DependencyKind::Node => format!( - "当前缺少可用的 Node.js {}+ 运行时,建议先一键安装或修复 Node.js。", - format_semver(NODE_MIN_VERSION) - ), - DependencyKind::Git => "当前缺少可用的 Git,建议先一键安装或修复 Git。".to_string(), - }; - } - - match dependency { - DependencyKind::Node => format!( - "当前缺少可用的 Node.js {}+ 运行时,请先手动安装后重新检测。", - format_semver(NODE_MIN_VERSION) - ), - DependencyKind::Git => "当前缺少可用的 Git,请先手动安装后重新检测。".to_string(), - } -} - -pub(crate) fn build_environment_status( - node: DependencyStatus, - git: DependencyStatus, - mut openclaw: DependencyStatus, - diagnostics: EnvironmentDiagnostics, -) -> EnvironmentStatus { - let node_ready = node.status == "ok"; - let git_ready = git.status == "ok"; - openclaw.auto_install_supported = node_ready && git_ready; - - let (recommended_action, summary) = if !node_ready { - ( - "install_node".to_string(), - dependency_setup_summary(DependencyKind::Node), - ) - } else if !git_ready { - ( - "install_git".to_string(), - dependency_setup_summary(DependencyKind::Git), - ) - } else if openclaw.status == "needs_reload" { - ( - "refresh_openclaw_env".to_string(), - "已检测到 OpenClaw 包,但命令尚未生效;请点击“重新检测”,必要时重启 Lime。".to_string(), - ) - } else if openclaw.status != "ok" { - ( - "install_openclaw".to_string(), - "运行环境已就绪,可以继续一键安装 OpenClaw。".to_string(), - ) - } else { - ( - "ready".to_string(), - "Node.js、Git 和 OpenClaw 均已就绪,可以继续配置与启动。".to_string(), - ) - }; - - EnvironmentStatus { - node, - git, - openclaw, - recommended_action, - summary, - diagnostics, - temp_artifacts: collect_temp_artifact_paths(None) - .into_iter() - .filter(|path| path.exists()) - .map(|path| path.display().to_string()) - .collect(), - } -} - -pub(crate) async fn inspect_node_dependency_status() -> Result { - let Some(path) = find_command_in_shell("node").await? else { - return Ok(DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: format!( - "未检测到 Node.js,需要安装 {}+。", - format_semver(NODE_MIN_VERSION) - ), - auto_install_supported: cfg!(target_os = "macos"), - }); - }; - - let version_text = read_command_version_text(&path, &["--version"]).await?; - let Some(version) = parse_semver_from_text(&version_text) else { - return Ok(DependencyStatus { - status: "version_low".to_string(), - version: Some(version_text.clone()), - path: Some(path), - message: format!( - "检测到 Node.js,但无法识别版本:{version_text}。请安装 {}+。", - format_semver(NODE_MIN_VERSION) - ), - auto_install_supported: cfg!(target_os = "macos"), - }); - }; - - let normalized = format_semver(version); - if version >= NODE_MIN_VERSION { - Ok(DependencyStatus { - status: "ok".to_string(), - version: Some(normalized.clone()), - path: Some(path), - message: format!("Node.js 已就绪:{normalized}"), - auto_install_supported: cfg!(target_os = "macos"), - }) - } else { - Ok(DependencyStatus { - status: "version_low".to_string(), - version: Some(normalized.clone()), - path: Some(path), - message: format!( - "Node.js 版本过低:{normalized},需要 {}+。", - format_semver(NODE_MIN_VERSION) - ), - auto_install_supported: cfg!(target_os = "macos"), - }) - } -} - -pub(crate) async fn inspect_git_dependency_status() -> Result { - let Some(path) = find_command_in_shell("git").await? else { - return Ok(DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "未检测到 Git。".to_string(), - auto_install_supported: git_auto_install_supported().await?, - }); - }; - - let version_text = read_command_version_text(&path, &["--version"]).await?; - let version = parse_semver_from_text(&version_text).map(format_semver); - let detail = version.clone().unwrap_or(version_text); - - Ok(DependencyStatus { - status: "ok".to_string(), - version, - path: Some(path), - message: format!("Git 已就绪:{detail}"), - auto_install_supported: git_auto_install_supported().await?, - }) -} - -pub(crate) async fn inspect_openclaw_dependency_status() -> Result { - let Some(command) = resolve_openclaw_command().await? else { - if let Some(status) = inspect_openclaw_package_reload_status().await? { - return Ok(status); - } - - return Ok(DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "未检测到 OpenClaw,可在环境就绪后一键安装。".to_string(), - auto_install_supported: false, - }); - }; - - let version_text = read_openclaw_version_from_command(&command) - .await? - .unwrap_or_default(); - Ok(DependencyStatus { - status: "ok".to_string(), - version: if version_text.is_empty() { - None - } else { - Some(version_text.clone()) - }, - path: Some(command.install_path_display()), - message: if matches!(command, ResolvedOpenClawCommand::NodeCli { .. }) { - if version_text.is_empty() { - "已检测到 OpenClaw 包,Lime 将通过当前 Node 运行时直接启动。".to_string() - } else { - format!("已检测到 OpenClaw 包,Lime 将通过当前 Node 运行时直接启动:{version_text}") - } - } else if version_text.is_empty() { - "已检测到 OpenClaw。".to_string() - } else { - format!("已检测到 OpenClaw:{version_text}") - }, - auto_install_supported: false, - }) -} - -pub(crate) async fn inspect_openclaw_package_reload_status( -) -> Result, String> { - let Some(npm_path) = find_command_in_standard_locations("npm").await? else { - return Ok(None); - }; - let Some(prefix) = detect_npm_global_prefix(&npm_path).await else { - return Ok(None); - }; - let Some(package) = find_installed_openclaw_package_details(&prefix) else { - return Ok(None); - }; - - let version_suffix = package - .version - .as_deref() - .map(|item| format!("({item})")) - .unwrap_or_default(); - - Ok(Some(DependencyStatus { - status: "needs_reload".to_string(), - version: package.version.clone(), - path: Some(prefix.clone()), - message: format!( - "已在 npm 全局目录检测到 {}{},但当前进程尚未解析到 openclaw 命令。请点击“重新检测”;若仍失败,请重启 Lime,或确认 {prefix} 已加入 PATH。", package.name, version_suffix - ), - auto_install_supported: false, - })) -} - -pub(crate) async fn git_auto_install_supported() -> Result { - #[cfg(target_os = "macos")] - { - Ok(true) - } - - #[cfg(not(target_os = "macos"))] - { - Ok(false) - } -} - -pub(crate) async fn read_command_version_text( - command_path: &str, - args: &[&str], -) -> Result { - let mut command = Command::new(command_path); - apply_binary_runtime_path(&mut command, command_path); - for arg in args { - command.arg(arg); - } - let output = command - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| format!("执行命令失败({command_path}): {e}"))?; - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if !stdout.is_empty() { - Ok(stdout) - } else { - Ok(stderr) - } -} - -pub(crate) async fn collect_environment_diagnostics() -> EnvironmentDiagnostics { - let npm_path = find_command_in_standard_locations("npm") - .await - .ok() - .flatten(); - let npm_global_prefix = match npm_path.as_deref() { - Some(path) => detect_npm_global_prefix(path).await, - None => None, - }; - - #[cfg(target_os = "windows")] - let where_candidates = find_commands_via_where("openclaw") - .await - .unwrap_or_default() - .into_iter() - .map(|path| path.display().to_string()) - .collect(); - - #[cfg(not(target_os = "windows"))] - let where_candidates = Vec::new(); - - let supplemental_search_dirs = - collect_supplemental_openclaw_search_dirs(npm_global_prefix.as_deref()); - let supplemental_command_candidates = - find_all_commands_in_paths("openclaw", &supplemental_search_dirs) - .into_iter() - .map(|path| path.display().to_string()) - .collect(); - let openclaw_package_path = npm_global_prefix - .as_deref() - .and_then(find_installed_openclaw_package_details) - .map(|package| package.path.display().to_string()); - - #[cfg(target_os = "windows")] - let git_where_candidates = find_commands_via_where("git") - .await - .unwrap_or_default() - .into_iter() - .map(|path| path.display().to_string()) - .collect(); - - #[cfg(not(target_os = "windows"))] - let git_where_candidates = Vec::new(); - - let git_supplemental_search_dirs = collect_supplemental_git_search_dirs(); - let git_supplemental_command_candidates = - find_all_commands_in_paths("git", &git_supplemental_search_dirs) - .into_iter() - .map(|path| path.display().to_string()) - .collect(); - - EnvironmentDiagnostics { - npm_path, - npm_global_prefix, - openclaw_package_path, - where_candidates, - supplemental_search_dirs: supplemental_search_dirs - .into_iter() - .map(|path| path.display().to_string()) - .collect(), - supplemental_command_candidates, - git_where_candidates, - git_supplemental_search_dirs: git_supplemental_search_dirs - .into_iter() - .map(|path| path.display().to_string()) - .collect(), - git_supplemental_command_candidates, - } -} - -pub(crate) fn collect_supplemental_openclaw_search_dirs( - npm_global_prefix: Option<&str>, -) -> Vec { - let mut dirs = Vec::new(); - - #[cfg(target_os = "windows")] - { - dirs.extend(windows_known_command_dirs_from_env()); - } - - if let Some(prefix) = npm_global_prefix { - dirs.extend(npm_global_command_dirs(prefix)); - } - - collect_existing_unique_dirs(dirs) -} - -pub(crate) fn collect_supplemental_git_search_dirs() -> Vec { - #[cfg(target_os = "windows")] - { - return collect_existing_unique_dirs(windows_known_git_command_dirs_from_env()); - } - - #[cfg(not(target_os = "windows"))] - { - Vec::new() - } -} diff --git a/src-tauri/src/services/openclaw_service/gateway.rs b/src-tauri/src/services/openclaw_service/gateway.rs deleted file mode 100644 index 803b352be..000000000 --- a/src-tauri/src/services/openclaw_service/gateway.rs +++ /dev/null @@ -1,871 +0,0 @@ -use super::*; - -impl OpenClawService { - pub async fn start_gateway( - &mut self, - app: Option<&AppHandle>, - port: Option, - ) -> Result { - if let Some(next_port) = port { - self.gateway_port = next_port.max(1); - } - - if let Some(app) = app { - emit_install_progress( - app, - &format!("准备启动 Gateway,目标端口 {}。", self.gateway_port), - "info", - ); - } - - self.ensure_runtime_config(None, None)?; - self.refresh_process_state().await?; - - if self.gateway_status == GatewayStatus::Running { - if let Some(app) = app { - emit_install_progress( - app, - &format!("检测到 Gateway 已在端口 {} 运行。", self.gateway_port), - "info", - ); - } - return Ok(ActionResult { - success: true, - message: format!("Gateway 已在端口 {} 运行", self.gateway_port), - }); - } - - let Some(openclaw_command) = resolve_openclaw_command().await? else { - self.gateway_status = GatewayStatus::Error; - if let Some(app) = app { - emit_install_progress(app, "未检测到 OpenClaw 可执行文件,请先安装。", "error"); - } - return Ok(ActionResult { - success: false, - message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(), - }); - }; - - self.gateway_status = GatewayStatus::Starting; - - let config_path = openclaw_lime_config_path(); - if let Some(app) = app { - emit_install_progress( - app, - &format!("使用配置文件启动 Gateway: {}", config_path.display()), - "info", - ); - } - let start_args = gateway_start_args(self.gateway_port, &self.gateway_auth_token); - let mut command = openclaw_command.build_command_with_args(&start_args); - command - .env(OPENCLAW_CONFIG_ENV, &config_path) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let mut child = command - .spawn() - .map_err(|e| format!("启动 Gateway 失败: {e}"))?; - - let gateway_error_lines = Arc::new(StdMutex::new(Vec::::new())); - - if let Some(stdout) = child.stdout.take() { - let app = app.cloned(); - tokio::spawn(async move { - let mut lines = BufReader::new(stdout).lines(); - while let Ok(Some(line)) = lines.next_line().await { - tracing::info!(target: "openclaw", "Gateway stdout: {}", line); - if let Some(app) = app.as_ref() { - emit_install_progress(app, &line, classify_progress_level(&line, "info")); - } - } - }); - } - - if let Some(stderr) = child.stderr.take() { - let app = app.cloned(); - let gateway_error_lines = gateway_error_lines.clone(); - tokio::spawn(async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - tracing::warn!(target: "openclaw", "Gateway stderr: {}", line); - if let Ok(mut slot) = gateway_error_lines.lock() { - push_gateway_error_line(&mut slot, &line); - } - if let Some(app) = app.as_ref() { - emit_install_progress(app, &line, classify_progress_level(&line, "warn")); - } - } - }); - } - - self.gateway_process = Some(child); - self.gateway_started_at = Some(SystemTime::now()); - - if let Some(app) = app { - emit_install_progress(app, "Gateway 进程已拉起,等待服务就绪。", "info"); - } - - let start_at = tokio::time::Instant::now(); - while start_at.elapsed() < Duration::from_secs(30) { - sleep(Duration::from_millis(300)).await; - self.refresh_process_state().await?; - let gateway_error_lines = gateway_error_lines - .lock() - .ok() - .map(|slot| slot.clone()) - .unwrap_or_default(); - - if self.gateway_process.is_none() && self.gateway_status == GatewayStatus::Error { - let message = format_gateway_start_failure_message( - select_gateway_start_failure_detail(&gateway_error_lines), - ); - if let Some(app) = app { - emit_install_progress(app, &message, "error"); - } - return Ok(ActionResult { - success: false, - message, - }); - } - - if self.gateway_status == GatewayStatus::Running { - if let Some(app) = app { - emit_install_progress( - app, - &format!("Gateway 启动成功,监听端口 {}。", self.gateway_port), - "info", - ); - } - return Ok(ActionResult { - success: true, - message: format!("Gateway 已启动,端口 {}", self.gateway_port), - }); - } - - if self.check_port_open().await { - self.gateway_status = GatewayStatus::Running; - if let Some(app) = app { - emit_install_progress( - app, - &format!("Gateway 探测成功,监听端口 {}。", self.gateway_port), - "info", - ); - } - return Ok(ActionResult { - success: true, - message: format!("Gateway 已启动,端口 {}", self.gateway_port), - }); - } - } - - self.gateway_status = GatewayStatus::Error; - let gateway_error_lines = gateway_error_lines - .lock() - .ok() - .map(|slot| slot.clone()) - .unwrap_or_default(); - let message = format_gateway_start_failure_message(select_gateway_start_failure_detail( - &gateway_error_lines, - )); - if let Some(app) = app { - emit_install_progress(app, &message, "error"); - } - Ok(ActionResult { - success: false, - message, - }) - } - - pub async fn stop_gateway(&mut self, app: Option<&AppHandle>) -> Result { - if let Some(app) = app { - emit_install_progress(app, "准备停止 Gateway。", "info"); - } - - self.restore_auth_token_from_config(); - - if let Some(mut child) = self.gateway_process.take() { - if let Some(app) = app { - emit_install_progress(app, "正在终止当前托管的 Gateway 子进程。", "info"); - } - let _ = child.kill().await; - let _ = timeout(Duration::from_secs(3), child.wait()).await; - } - - if self - .wait_for_gateway_shutdown(Duration::from_secs(3)) - .await? - { - if let Some(app) = app { - emit_install_progress(app, "Gateway 已停止。", "info"); - } - return Ok(ActionResult { - success: true, - message: "Gateway 已停止。".to_string(), - }); - } - - let stop_commands = self.collect_gateway_stop_commands().await?; - if stop_commands.is_empty() { - if let Some(app) = app { - emit_install_progress( - app, - "未检测到可用的 OpenClaw 停止命令,将尝试按端口回收旧 Gateway。", - "warn", - ); - } - } else { - for command_spec in &stop_commands { - self.request_gateway_stop_via_command(command_spec, app) - .await; - if self - .wait_for_gateway_shutdown(Duration::from_secs(4)) - .await? - { - if let Some(app) = app { - emit_install_progress(app, "Gateway 已停止。", "info"); - } - return Ok(ActionResult { - success: true, - message: "Gateway 已停止。".to_string(), - }); - } - } - } - - let reclaimed_by_pid = self.force_stop_gateway_listener_processes(app).await?; - if reclaimed_by_pid - && self - .wait_for_gateway_shutdown(Duration::from_secs(5)) - .await? - { - if let Some(app) = app { - emit_install_progress(app, "Gateway 已停止。", "info"); - } - return Ok(ActionResult { - success: true, - message: "Gateway 已停止。".to_string(), - }); - } - - self.refresh_process_state().await?; - let message = if self.check_port_open().await { - format!( - "Gateway 停止失败:端口 {} 仍被旧进程占用,升级已中止。请使用“立即重启生效”或结束旧 OpenClaw 进程后重试。", - self.gateway_port - ) - } else { - "Gateway 停止流程已结束,但未能确认运行态完全退出,请重试。".to_string() - }; - - if let Some(app) = app { - emit_install_progress(app, &message, "error"); - } - - Ok(ActionResult { - success: false, - message, - }) - } - - pub async fn restart_gateway(&mut self, app: &AppHandle) -> Result { - emit_install_progress(app, "开始重启 Gateway。", "info"); - let stop_result = self.stop_gateway(Some(app)).await?; - if !stop_result.success { - return Ok(stop_result); - } - emit_install_progress(app, "Gateway 停止阶段结束,开始重新启动。", "info"); - self.start_gateway(Some(app), Some(self.gateway_port)).await - } - - pub async fn get_status(&mut self) -> Result { - self.refresh_process_state().await?; - Ok(GatewayStatusInfo { - status: self.gateway_status.clone(), - port: self.gateway_port, - }) - } - - pub async fn check_health(&mut self) -> Result { - self.refresh_process_state().await?; - - self.restore_auth_token_from_config(); - - let health_snapshot = self.fetch_authenticated_gateway_health_json().await; - let healthy = self.gateway_status == GatewayStatus::Running - && self.check_port_open().await - && health_snapshot - .as_ref() - .and_then(|value| value.get("ok").and_then(Value::as_bool)) - .unwrap_or(false); - let version = self.read_openclaw_version().await.ok().flatten(); - let uptime = self.gateway_started_at.and_then(|start| { - SystemTime::now() - .duration_since(start) - .ok() - .map(|elapsed| elapsed.as_secs()) - }); - - Ok(HealthInfo { - status: if healthy { "healthy" } else { "unhealthy" }.to_string(), - gateway_port: self.gateway_port, - uptime, - version, - }) - } - - pub fn get_dashboard_url(&mut self) -> String { - self.restore_auth_token_from_config(); - let mut url = format!("http://127.0.0.1:{}", self.gateway_port); - if !self.gateway_auth_token.is_empty() { - url.push_str(&format!( - "/#token={}", - urlencoding::encode(&self.gateway_auth_token) - )); - } - url - } - - pub async fn get_channels(&mut self) -> Result, String> { - self.refresh_process_state().await?; - if self.gateway_status != GatewayStatus::Running { - return Ok(Vec::new()); - } - - self.restore_auth_token_from_config(); - - let Some(body) = self.fetch_authenticated_gateway_health_json().await else { - return Ok(Vec::new()); - }; - - let channels_map = body - .get("channels") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - let labels = body - .get("channelLabels") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); - let ordered_ids = body - .get("channelOrder") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - - let mut ordered = Vec::new(); - for channel_id in ordered_ids.iter().filter_map(Value::as_str) { - if let Some(entry) = channels_map.get(channel_id) { - ordered.push(build_channel_info( - channel_id, - entry, - labels.get(channel_id), - )); - } - } - - if ordered.is_empty() { - ordered = channels_map - .iter() - .map(|(channel_id, entry)| { - build_channel_info(channel_id, entry, labels.get(channel_id)) - }) - .collect(); - } - - Ok(ordered) - } - - async fn wait_for_gateway_shutdown(&mut self, max_wait: Duration) -> Result { - let start_at = tokio::time::Instant::now(); - while start_at.elapsed() < max_wait { - self.refresh_process_state().await?; - if !self.check_port_open().await { - self.clear_gateway_runtime_state(); - return Ok(true); - } - sleep(Duration::from_millis(250)).await; - } - - self.refresh_process_state().await?; - if !self.check_port_open().await { - self.clear_gateway_runtime_state(); - return Ok(true); - } - - Ok(false) - } - - async fn collect_gateway_stop_commands(&self) -> Result, String> { - let mut commands = Vec::new(); - - if let Some(command) = resolve_openclaw_command().await? { - commands.push(command); - } - - let mut runtime_candidates = list_openclaw_runtime_candidates().await?; - runtime_candidates.sort_by(compare_openclaw_runtime_candidates); - commands.extend( - runtime_candidates - .iter() - .filter_map(resolve_openclaw_command_from_runtime_candidate), - ); - - Ok(dedupe_openclaw_commands(commands)) - } - - async fn request_gateway_stop_via_command( - &self, - command_spec: &ResolvedOpenClawCommand, - app: Option<&AppHandle>, - ) { - let binary_label = command_spec.invocation_display(); - if let Some(app) = app { - emit_install_progress( - app, - &format!("尝试通过 {} 停止 Gateway。", binary_label), - "info", - ); - } - - let stop_args = vec![ - "gateway".to_string(), - "stop".to_string(), - "--url".to_string(), - self.gateway_ws_url(), - "--token".to_string(), - self.gateway_auth_token.clone(), - ]; - let mut command = command_spec.build_command_with_args(&stop_args); - let output = timeout( - Duration::from_secs(8), - command - .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output(), - ) - .await; - - match output { - Ok(Ok(result)) => { - for line in command_output_lines(&result.stdout) { - if let Some(app) = app { - emit_install_progress(app, &line, classify_progress_level(&line, "info")); - } - } - for line in command_output_lines(&result.stderr) { - if let Some(app) = app { - emit_install_progress(app, &line, classify_progress_level(&line, "warn")); - } - } - - if let Some(app) = app { - if result.status.success() { - emit_install_progress(app, "已发送 Gateway 停止命令。", "info"); - } else { - emit_install_progress( - app, - &format!("Gateway 停止命令返回异常状态: {:?}", result.status.code()), - "warn", - ); - } - } - } - Ok(Err(error)) => { - if let Some(app) = app { - emit_install_progress( - app, - &format!("执行 Gateway 停止命令失败: {error}"), - "warn", - ); - } - } - Err(_) => { - if let Some(app) = app { - emit_install_progress( - app, - "Gateway 停止命令超时,继续尝试自动回收旧进程。", - "warn", - ); - } - } - } - } - - async fn force_stop_gateway_listener_processes( - &mut self, - app: Option<&AppHandle>, - ) -> Result { - let listener_pids = collect_listening_port_pids(self.gateway_port).await; - if listener_pids.is_empty() { - return Ok(false); - } - - let mut system = System::new_all(); - system.refresh_all(); - - let target_pids = collect_openclaw_process_family_pids(&system, &listener_pids); - if target_pids.is_empty() { - if let Some(app) = app { - emit_install_progress( - app, - &format!( - "检测到端口 {} 仍被占用,但监听进程不是 OpenClaw,未执行自动终止。", - self.gateway_port - ), - "warn", - ); - } - return Ok(false); - } - - if let Some(app) = app { - emit_install_progress( - app, - &format!( - "检测到旧 Gateway 仍占用端口 {},准备回收进程:{}。", - self.gateway_port, - target_pids - .iter() - .map(|pid| pid.as_u32().to_string()) - .collect::>() - .join(", ") - ), - "warn", - ); - } - - terminate_sysinfo_processes(&mut system, &target_pids).await; - Ok(true) - } - - pub(crate) async fn refresh_process_state(&mut self) -> Result<(), String> { - let mut process_exited = false; - - if let Some(child) = self.gateway_process.as_mut() { - match child.try_wait() { - Ok(Some(status)) => { - tracing::info!(target: "openclaw", "Gateway 进程已退出: {}", status); - process_exited = true; - } - Ok(None) => {} - Err(error) => { - tracing::warn!(target: "openclaw", "检查 Gateway 进程状态失败: {}", error); - process_exited = true; - } - } - } - - if process_exited { - self.gateway_process = None; - self.gateway_started_at = None; - } - - let openclaw_command = resolve_openclaw_command().await?; - let running = self.check_port_open().await - || self.check_gateway_status(openclaw_command.as_ref()).await?; - - self.gateway_status = if running { - GatewayStatus::Running - } else if self.gateway_status == GatewayStatus::Starting { - GatewayStatus::Error - } else { - GatewayStatus::Stopped - }; - - if !running { - self.gateway_process = None; - self.gateway_started_at = None; - } - - Ok(()) - } - - async fn check_port_open(&self) -> bool { - timeout( - Duration::from_secs(2), - TcpStream::connect(("127.0.0.1", self.gateway_port)), - ) - .await - .map(|result| result.is_ok()) - .unwrap_or(false) - } - - async fn check_gateway_status( - &self, - command_spec: Option<&ResolvedOpenClawCommand>, - ) -> Result { - let Some(command_spec) = command_spec else { - return Ok(false); - }; - - let status_args = vec![ - "gateway".to_string(), - "status".to_string(), - "--url".to_string(), - self.gateway_ws_url(), - "--token".to_string(), - self.gateway_auth_token.clone(), - ]; - let mut command = command_spec.build_command_with_args(&status_args); - let output = command - .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await; - - match output { - Ok(result) => { - let stdout = String::from_utf8_lossy(&result.stdout).to_lowercase(); - let stderr = String::from_utf8_lossy(&result.stderr).to_lowercase(); - Ok(result.status.success() - && (stdout.contains("listening") - || stdout.contains("running") - || stderr.contains("listening"))) - } - Err(_) => Ok(false), - } - } - - pub(crate) async fn read_openclaw_version(&self) -> Result, String> { - let Some(command_spec) = resolve_openclaw_command().await? else { - return Ok(None); - }; - - read_openclaw_version_from_command(&command_spec).await - } - - pub(crate) fn gateway_ws_url(&self) -> String { - format!("ws://127.0.0.1:{}", self.gateway_port) - } - - pub(crate) fn restore_auth_token_from_config(&mut self) { - if !self.gateway_auth_token.is_empty() { - return; - } - - match read_base_openclaw_config() - .ok() - .and_then(|config| extract_gateway_auth_token(&config)) - { - Some(token) => { - self.gateway_auth_token = token; - } - None => { - tracing::warn!( - target: "openclaw", - "未能从 OpenClaw 配置恢复 gateway token,Dashboard 访问可能鉴权失败" - ); - } - } - } - - async fn fetch_authenticated_gateway_health_json(&self) -> Option { - if self.gateway_auth_token.is_empty() { - return None; - } - - let Some(command_spec) = resolve_openclaw_command().await.ok().flatten() else { - return None; - }; - - let health_args = vec![ - "gateway".to_string(), - "health".to_string(), - "--url".to_string(), - self.gateway_ws_url(), - "--token".to_string(), - self.gateway_auth_token.clone(), - "--json".to_string(), - ]; - let mut command = command_spec.build_command_with_args(&health_args); - let output = command - .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await; - - match output { - Ok(output) if output.status.success() => { - serde_json::from_slice::(&output.stdout) - .map_err(|error| { - tracing::warn!( - target: "openclaw", - "解析 Gateway 官方健康检查结果失败: {}", - error - ); - error - }) - .ok() - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - tracing::warn!( - target: "openclaw", - "Gateway 官方健康检查失败: {}", - stderr.trim() - ); - None - } - Err(error) => { - tracing::warn!(target: "openclaw", "执行 Gateway 官方健康检查失败: {}", error); - None - } - } - } -} - -pub(crate) fn gateway_start_args(gateway_port: u16, gateway_auth_token: &str) -> Vec { - vec![ - "gateway".to_string(), - "--allow-unconfigured".to_string(), - "--bind".to_string(), - "loopback".to_string(), - "--auth".to_string(), - "token".to_string(), - "--token".to_string(), - gateway_auth_token.to_string(), - "--port".to_string(), - gateway_port.to_string(), - ] -} - -pub(crate) fn push_gateway_error_line(lines: &mut Vec, line: &str) { - let trimmed = line.trim(); - if trimmed.is_empty() { - return; - } - - if lines.len() >= 32 { - lines.remove(0); - } - lines.push(trimmed.to_string()); -} - -pub(crate) fn select_gateway_start_failure_detail<'a>(lines: &'a [String]) -> Option<&'a str> { - lines - .iter() - .filter_map(|line| { - let trimmed = line.trim(); - let score = gateway_failure_line_score(trimmed); - (score > 0).then_some((score, trimmed)) - }) - .max_by_key(|(score, _)| *score) - .map(|(_, line)| line) -} - -pub(crate) fn gateway_failure_line_score(line: &str) -> u8 { - if line.is_empty() { - return 0; - } - - let normalized = line.to_ascii_lowercase(); - if normalized.starts_with("run: openclaw doctor") - || normalized == "config invalid" - || normalized.starts_with("file:") - || normalized == "problem:" - { - return 0; - } - - if normalized.contains("invalid config") { - return 100; - } - - if normalized.contains("contextwindow") && normalized.contains("received null") { - return 95; - } - - if normalized.contains("address already in use") - || normalized.contains("eaddrinuse") - || normalized.contains("resolved to non-loopback host") - { - return 90; - } - - if normalized.contains("missing config") - || normalized.contains("gateway.mode=local") - || normalized.contains("gateway.auth.mode") - { - return 85; - } - - if normalized.starts_with("- ") { - return 60; - } - - 20 -} - -pub(crate) fn format_gateway_start_failure_message(detail: Option<&str>) -> String { - let Some(detail) = detail.map(str::trim).filter(|value| !value.is_empty()) else { - return "Gateway 启动超时,请检查配置或端口占用。".to_string(); - }; - - let normalized = detail.to_ascii_lowercase(); - if normalized.contains("invalid config") || normalized.contains("config invalid") { - if normalized.contains("contextwindow") && normalized.contains("received null") { - return "Gateway 启动失败:当前 OpenClaw 配置包含空的 contextWindow 字段。Lime 已修正后续配置写入,请重新启动;如仍失败,请重新同步模型配置。" - .to_string(); - } - return "Gateway 启动失败:OpenClaw 配置文件无效,请重新同步模型配置后再试。".to_string(); - } - - if normalized.contains("missing config") || normalized.contains("gateway.mode=local") { - return "Gateway 启动失败:OpenClaw 本地网关配置缺失,已自动补齐默认配置,请重试。" - .to_string(); - } - - if normalized.contains("gateway.auth.mode") { - return "Gateway 启动失败:缺少网关认证模式,已自动切换为 token 模式,请重试。".to_string(); - } - - if normalized.contains("address already in use") || normalized.contains("eaddrinuse") { - return "Gateway 启动失败:目标端口已被占用,请更换端口或停止占用进程。".to_string(); - } - - if normalized.contains("resolved to non-loopback host") { - return "Gateway 启动失败:当前环境无法绑定到本地回环地址 127.0.0.1,请检查本机网络或代理配置。".to_string(); - } - - if normalized.contains("allowedorigins") || normalized.contains("host-header origin fallback") { - return "Gateway 启动失败:当前绑定方式需要配置 Control UI 允许来源,请检查 gateway.controlUi.allowedOrigins。".to_string(); - } - - if normalized.contains("doctor --fix") { - return "Gateway 启动失败:OpenClaw 检测到本地环境或配置异常,请先在安装页执行“重新检测”或“修复环境”后再试。" - .to_string(); - } - - format!("Gateway 启动失败:{detail}") -} - -pub(crate) fn build_channel_info( - channel_id: &str, - entry: &Value, - label: Option<&Value>, -) -> ChannelInfo { - ChannelInfo { - id: channel_id.to_string(), - name: entry - .get("name") - .and_then(Value::as_str) - .or_else(|| label.and_then(Value::as_str)) - .unwrap_or("未命名通道") - .to_string(), - channel_type: entry - .get("type") - .and_then(Value::as_str) - .unwrap_or("unknown") - .to_string(), - status: entry - .get("status") - .and_then(Value::as_str) - .unwrap_or("unknown") - .to_string(), - } -} diff --git a/src-tauri/src/services/openclaw_service/install.rs b/src-tauri/src/services/openclaw_service/install.rs deleted file mode 100644 index 0db0ff2fa..000000000 --- a/src-tauri/src/services/openclaw_service/install.rs +++ /dev/null @@ -1,810 +0,0 @@ -use super::*; - -impl OpenClawService { - pub async fn install(&mut self, app: &AppHandle) -> Result { - emit_install_progress(app, "开始准备 OpenClaw 环境。", "info"); - - #[cfg(target_os = "windows")] - { - let node_status = self.inspect_dependency_status(DependencyKind::Node).await?; - let git_status = self.inspect_dependency_status(DependencyKind::Git).await?; - if let Some(result) = windows_install_block_result(&node_status, &git_status) { - emit_install_progress(app, &result.message, "warn"); - return Ok(result); - } - } - - let node_result = self - .ensure_dependency_ready(app, DependencyKind::Node) - .await?; - if !node_result.success { - return Ok(node_result); - } - - let git_result = self - .ensure_dependency_ready(app, DependencyKind::Git) - .await?; - if !git_result.success { - return Ok(git_result); - } - - let (_, npm_path, npm_prefix, cleanup_command, install_command) = - self.resolve_install_commands(app).await?; - - emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info"); - if let Some(prefix) = npm_prefix { - emit_install_progress(app, &format!("npm 全局前缀: {prefix}"), "info"); - } - emit_install_progress(app, "安装前先清理已有 OpenClaw 全局包。", "info"); - let cleanup_result = run_shell_command_with_progress(app, &cleanup_command).await?; - if !cleanup_result.success { - emit_install_progress( - app, - &format!( - "清理旧版 OpenClaw 失败,继续尝试安装:{}", - cleanup_result.message - ), - "warn", - ); - } - - emit_install_progress(app, &format!("执行安装命令: {install_command}"), "info"); - let result = run_shell_command_with_progress(app, &install_command).await?; - if !result.success { - return Ok(result); - } - - let installed = self.check_installed().await?; - if installed.installed { - emit_install_progress(app, "已检测到 OpenClaw 可执行文件。", "info"); - return Ok(ActionResult { - success: true, - message: installed - .path - .map(|path| format!("OpenClaw 安装完成:{path}")) - .unwrap_or_else(|| "OpenClaw 安装完成。".to_string()), - }); - } - - Ok(ActionResult { - success: false, - message: - "安装命令执行完成,但仍未检测到 OpenClaw 可执行文件,请检查 npm 全局目录或权限设置。" - .to_string(), - }) - } - - pub async fn install_dependency( - &mut self, - app: &AppHandle, - kind: &str, - ) -> Result { - let dependency = match kind { - "node" => DependencyKind::Node, - "git" => DependencyKind::Git, - _ => return Err(format!("不支持的依赖类型: {kind}")), - }; - - #[cfg(target_os = "windows")] - { - let status = self.inspect_dependency_status(dependency).await?; - if status.status == "ok" { - emit_install_progress( - app, - &format!( - "{} 已就绪{}。", - dependency.label(), - status - .version - .as_deref() - .map(|version| format!(" · {version}")) - .unwrap_or_default() - ), - "info", - ); - return Ok(ActionResult { - success: true, - message: format!("{} 已满足要求。", dependency.label()), - }); - } - - let result = windows_dependency_action_result(dependency, &status); - emit_install_progress(app, &result.message, "warn"); - return Ok(result); - } - - #[cfg(not(target_os = "windows"))] - { - self.ensure_dependency_ready(app, dependency).await - } - } - - pub async fn cleanup_temp_artifacts( - &mut self, - app: Option<&AppHandle>, - ) -> Result { - let mut removed = Vec::new(); - let mut failed = Vec::new(); - - for target in collect_temp_artifact_paths(app) { - if !target.exists() { - continue; - } - - let result = if target.is_dir() { - std::fs::remove_dir_all(&target) - } else { - std::fs::remove_file(&target) - }; - - match result { - Ok(_) => { - if let Some(app) = app { - emit_install_progress( - app, - &format!("已清理临时文件:{}", target.display()), - "info", - ); - } - removed.push(target.display().to_string()); - } - Err(error) => { - if let Some(app) = app { - emit_install_progress( - app, - &format!("清理临时文件失败({}): {error}", target.display()), - "warn", - ); - } - failed.push(format!("{}: {error}", target.display())); - } - } - } - - if failed.is_empty() { - Ok(ActionResult { - success: true, - message: if removed.is_empty() { - "未发现需要清理的 OpenClaw 临时文件。".to_string() - } else { - format!("已清理 {} 项临时文件。", removed.len()) - }, - }) - } else { - Ok(ActionResult { - success: false, - message: format!("部分临时文件清理失败:{}", failed.join(";")), - }) - } - } - - pub async fn uninstall(&mut self, app: &AppHandle) -> Result { - if self.gateway_status == GatewayStatus::Running || self.gateway_process.is_some() { - let _ = self.stop_gateway(None).await; - } - - let (npm_path, npm_prefix, command) = self.resolve_uninstall_command().await?; - - emit_install_progress(app, &format!("使用 npm: {npm_path}"), "info"); - if let Some(prefix) = npm_prefix { - emit_install_progress(app, &format!("npm 全局前缀: {prefix}"), "info"); - } - emit_install_progress(app, &format!("执行卸载命令: {command}"), "info"); - run_shell_command_with_progress(app, &command).await - } - - async fn ensure_dependency_ready( - &mut self, - app: &AppHandle, - dependency: DependencyKind, - ) -> Result { - let status = self.inspect_dependency_status(dependency).await?; - if status.status == "ok" { - emit_install_progress( - app, - &format!( - "{} 已就绪{}。", - dependency.label(), - status - .version - .as_deref() - .map(|version| format!(" · {version}")) - .unwrap_or_default() - ), - "info", - ); - return Ok(ActionResult { - success: true, - message: format!("{} 已满足要求。", dependency.label()), - }); - } - - emit_install_progress( - app, - &format!("{},开始修复 {} 环境。", status.message, dependency.label()), - "warn", - ); - - match dependency { - DependencyKind::Node => self.install_node_runtime(app).await, - DependencyKind::Git => self.install_git_runtime(app).await, - } - } - - async fn inspect_dependency_status( - &self, - dependency: DependencyKind, - ) -> Result { - match dependency { - DependencyKind::Node => inspect_node_dependency_status().await, - DependencyKind::Git => inspect_git_dependency_status().await, - } - } - - async fn install_node_runtime(&mut self, app: &AppHandle) -> Result { - #[cfg(target_os = "windows")] - { - let winget_path = find_command_in_shell("winget").await?; - match resolve_windows_dependency_install_plan( - DependencyKind::Node, - winget_path.is_some(), - ) { - WindowsDependencyInstallPlan::Winget { package_id } => { - let winget_path = winget_path.expect("winget path should exist"); - emit_install_progress( - app, - "检测到 winget,准备通过 winget 安装 Node.js。", - "info", - ); - let command = build_winget_install_command(&winget_path, package_id); - let result = run_shell_command_with_progress(app, &command).await?; - if !result.success { - return Ok(result); - } - return self - .verify_dependency_after_install(app, DependencyKind::Node) - .await; - } - WindowsDependencyInstallPlan::OfficialInstaller => { - emit_install_progress( - app, - "未检测到 winget,准备下载官方 Node.js 安装器。", - "warn", - ); - let asset = resolve_node_installer_asset().await?; - let installer_path = download_installer_asset(app, &asset).await?; - launch_installer(&installer_path)?; - return self - .wait_for_dependency_ready(app, DependencyKind::Node, 900) - .await; - } - WindowsDependencyInstallPlan::ManualDownload => { - unreachable!("Node.js 在 Windows 上不应返回手动下载计划") - } - } - } - - #[cfg(target_os = "macos")] - { - if let Some(brew_path) = find_command_in_shell("brew").await? { - emit_install_progress( - app, - "检测到 Homebrew,准备通过 Homebrew 安装 Node.js。", - "info", - ); - let brew_cmd = shell_command_escape(&brew_path); - let path_env = shell_path_assignment(&brew_path); - let command = format!( - "{path_env}{brew_cmd} install node || {path_env}{brew_cmd} upgrade node" - ); - let result = run_shell_command_with_progress(app, &command).await?; - if !result.success { - return Ok(result); - } - return self - .verify_dependency_after_install(app, DependencyKind::Node) - .await; - } - - emit_install_progress( - app, - "未检测到 Homebrew,准备下载官方 Node.js 安装器。", - "warn", - ); - let asset = resolve_node_installer_asset().await?; - let installer_path = download_installer_asset(app, &asset).await?; - launch_installer(&installer_path)?; - return self - .wait_for_dependency_ready(app, DependencyKind::Node, 900) - .await; - } - - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - { - let message = "当前平台暂不支持应用内自动安装 Node.js,请手动安装 Node.js 22+ 后重试。" - .to_string(); - emit_install_progress(app, &message, "warn"); - Ok(ActionResult { - success: false, - message, - }) - } - } - - async fn install_git_runtime(&mut self, app: &AppHandle) -> Result { - #[cfg(target_os = "windows")] - { - let winget_path = find_command_in_shell("winget").await?; - match resolve_windows_dependency_install_plan( - DependencyKind::Git, - winget_path.is_some(), - ) { - WindowsDependencyInstallPlan::Winget { package_id } => { - let winget_path = winget_path.expect("winget path should exist"); - emit_install_progress(app, "检测到 winget,准备通过 winget 安装 Git。", "info"); - let command = build_winget_install_command(&winget_path, package_id); - let result = run_shell_command_with_progress(app, &command).await?; - if !result.success { - return Ok(result); - } - return self - .verify_dependency_after_install(app, DependencyKind::Git) - .await; - } - WindowsDependencyInstallPlan::OfficialInstaller => { - unreachable!("Git 在 Windows 上不应返回官方安装器计划") - } - WindowsDependencyInstallPlan::ManualDownload => { - let message = windows_manual_install_message(DependencyKind::Git).to_string(); - emit_install_progress(app, &message, "warn"); - return Ok(ActionResult { - success: false, - message, - }); - } - } - } - - #[cfg(target_os = "macos")] - { - if let Some(brew_path) = find_command_in_shell("brew").await? { - emit_install_progress(app, "检测到 Homebrew,准备通过 Homebrew 安装 Git。", "info"); - let brew_cmd = shell_command_escape(&brew_path); - let path_env = shell_path_assignment(&brew_path); - let command = - format!("{path_env}{brew_cmd} install git || {path_env}{brew_cmd} upgrade git"); - let result = run_shell_command_with_progress(app, &command).await?; - if !result.success { - return Ok(result); - } - return self - .verify_dependency_after_install(app, DependencyKind::Git) - .await; - } - - emit_install_progress( - app, - "未检测到 Homebrew,准备拉起 macOS Command Line Tools 安装器。", - "warn", - ); - let trigger_result = trigger_macos_command_line_tools_install().await?; - emit_install_progress(app, &trigger_result, "info"); - return self - .wait_for_dependency_ready(app, DependencyKind::Git, 1200) - .await; - } - - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - { - let message = "当前平台暂不支持应用内自动安装 Git,请使用系统包管理器手动安装后重试。" - .to_string(); - emit_install_progress(app, &message, "warn"); - Ok(ActionResult { - success: false, - message, - }) - } - } - - async fn verify_dependency_after_install( - &self, - app: &AppHandle, - dependency: DependencyKind, - ) -> Result { - // 在 Windows 上刷新 PATH 环境变量 - #[cfg(target_os = "windows")] - { - if let Err(e) = refresh_windows_path_from_registry() { - emit_install_progress(app, &format!("刷新环境变量失败: {}", e), "warn"); - } else { - emit_install_progress(app, "已刷新系统环境变量。", "info"); - } - } - - let status = self.inspect_dependency_status(dependency).await?; - if status.status == "ok" { - emit_install_progress( - app, - &format!( - "{} 已准备完成{}。", - dependency.label(), - status - .version - .as_deref() - .map(|version| format!(" · {version}")) - .unwrap_or_default() - ), - "info", - ); - return Ok(ActionResult { - success: true, - message: format!("{} 已安装完成。", dependency.label()), - }); - } - - Ok(ActionResult { - success: false, - message: format!( - "{} 安装完成后仍未通过校验:{}", - dependency.label(), - status.message - ), - }) - } - - async fn wait_for_dependency_ready( - &self, - app: &AppHandle, - dependency: DependencyKind, - timeout_secs: u64, - ) -> Result { - emit_install_progress( - app, - &format!( - "已拉起 {} 安装器,正在等待安装完成(最长 {} 秒)。", - dependency.label(), - timeout_secs - ), - "info", - ); - - let start = tokio::time::Instant::now(); - let mut last_notice_at = 0_u64; - #[cfg(target_os = "windows")] - let mut last_refresh_at = 0_u64; - - while start.elapsed() < Duration::from_secs(timeout_secs) { - let elapsed = start.elapsed().as_secs(); - - // 每 10 秒刷新一次 Windows PATH(因为用户可能在安装过程中) - #[cfg(target_os = "windows")] - if elapsed >= last_refresh_at + 10 { - last_refresh_at = elapsed; - let _ = refresh_windows_path_from_registry(); - } - - if elapsed >= last_notice_at + 15 { - last_notice_at = elapsed; - emit_install_progress( - app, - &format!("正在等待 {} 安装完成…", dependency.label()), - "info", - ); - } - - sleep(Duration::from_secs(2)).await; - let status = self.inspect_dependency_status(dependency).await?; - if status.status == "ok" { - emit_install_progress( - app, - &format!( - "{} 已检测通过{}。", - dependency.label(), - status - .version - .as_deref() - .map(|version| format!(" · {version}")) - .unwrap_or_default() - ), - "info", - ); - return Ok(ActionResult { - success: true, - message: format!("{} 已安装完成。", dependency.label()), - }); - } - } - - Ok(ActionResult { - success: false, - message: format!( - "等待 {} 安装完成超时,请完成安装后重新点击重试。", - dependency.label() - ), - }) - } - pub(crate) async fn resolve_install_commands( - &self, - app: &AppHandle, - ) -> Result<(String, String, Option, String, String), String> { - let npm_path = find_command_in_shell("npm") - .await? - .ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?; - let npm_prefix = detect_npm_global_prefix(&npm_path).await; - let use_china_package = should_use_china_package(app).await; - let package = if use_china_package { - OPENCLAW_CN_PACKAGE - } else { - OPENCLAW_DEFAULT_PACKAGE - }; - let shell_platform = current_shell_platform(); - let cleanup_command = - build_openclaw_cleanup_command(shell_platform, &npm_path, npm_prefix.as_deref()); - let install_command = build_openclaw_install_command( - shell_platform, - &npm_path, - npm_prefix.as_deref(), - package, - use_china_package.then_some(NPM_MIRROR_CN), - ); - Ok(( - package.to_string(), - npm_path, - npm_prefix, - cleanup_command, - install_command, - )) - } - - pub(crate) async fn resolve_uninstall_command( - &self, - ) -> Result<(String, Option, String), String> { - let npm_path = find_command_in_shell("npm") - .await? - .ok_or_else(|| "未检测到 npm,可先安装或修复 Node.js 环境。".to_string())?; - let npm_prefix = detect_npm_global_prefix(&npm_path).await; - let command = build_openclaw_cleanup_command( - current_shell_platform(), - &npm_path, - npm_prefix.as_deref(), - ); - Ok((npm_path, npm_prefix, command)) - } -} - -pub(crate) fn openclaw_installer_download_dir(app: &AppHandle) -> Result { - let _ = app; - let app_data_dir = lime_core::app_paths::preferred_data_dir() - .map_err(|e| format!("无法获取应用数据目录: {e}"))?; - let dir = app_data_dir.join("downloads").join("openclaw-installers"); - std::fs::create_dir_all(&dir).map_err(|e| format!("创建 OpenClaw 下载目录失败: {e}"))?; - Ok(dir) -} - -pub(crate) fn collect_temp_artifact_paths(app: Option<&AppHandle>) -> Vec { - let mut targets = Vec::new(); - - #[cfg(not(target_os = "windows"))] - { - targets.push(PathBuf::from(OPENCLAW_TEMP_CARGO_CHECK_DIR)); - } - - if let Some(app) = app { - if let Ok(dir) = openclaw_installer_download_dir(app) { - targets.push(dir); - } - } - - targets -} - -pub(crate) async fn resolve_node_installer_asset() -> Result { - let client = reqwest::Client::new(); - let response = client - .get("https://nodejs.org/dist/index.json") - .header("User-Agent", OPENCLAW_INSTALLER_USER_AGENT) - .send() - .await - .map_err(|e| format!("请求 Node.js 版本列表失败: {e}"))?; - - if !response.status().is_success() { - return Err(format!( - "获取 Node.js 版本列表失败: HTTP {}", - response.status() - )); - } - - let releases: Vec = response - .json() - .await - .map_err(|e| format!("解析 Node.js 版本列表失败: {e}"))?; - - let select_version = |only_lts: bool| -> Option { - releases.iter().find_map(|release| { - let version = release.get("version")?.as_str()?; - let parsed = parse_semver(version)?; - let is_lts = release - .get("lts") - .map(|value| match value { - Value::Bool(flag) => *flag, - Value::String(text) => !text.trim().is_empty() && text != "false", - _ => false, - }) - .unwrap_or(false); - if parsed >= NODE_MIN_VERSION && (!only_lts || is_lts) { - Some(version.to_string()) - } else { - None - } - }) - }; - - let version = select_version(true) - .or_else(|| select_version(false)) - .ok_or_else(|| "未找到满足要求的 Node.js 官方安装包版本。".to_string())?; - - #[cfg(target_os = "windows")] - let filename = { - #[cfg(target_arch = "aarch64")] - { - format!("node-{version}-arm64.msi") - } - #[cfg(not(target_arch = "aarch64"))] - { - format!("node-{version}-x64.msi") - } - }; - - #[cfg(target_os = "macos")] - let filename = format!("node-{version}.pkg"); - - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - let filename = String::new(); - - if filename.is_empty() { - return Err("当前平台暂不支持自动下载官方 Node.js 安装器。".to_string()); - } - - Ok(InstallerAsset { - download_url: format!("https://nodejs.org/dist/{version}/{filename}"), - filename, - }) -} - -pub(crate) async fn download_installer_asset( - app: &AppHandle, - asset: &InstallerAsset, -) -> Result { - let download_dir = openclaw_installer_download_dir(app)?; - let installer_path = download_dir.join(&asset.filename); - if installer_path.exists() { - let _ = std::fs::remove_file(&installer_path); - } - - emit_install_progress( - app, - &format!("开始下载安装器:{}", asset.download_url), - "info", - ); - - let client = reqwest::Client::new(); - let response = client - .get(&asset.download_url) - .header("User-Agent", OPENCLAW_INSTALLER_USER_AGENT) - .send() - .await - .map_err(|e| format!("下载官方安装器失败: {e}"))?; - - if !response.status().is_success() { - return Err(format!("下载安装器失败: HTTP {}", response.status())); - } - - let bytes = response - .bytes() - .await - .map_err(|e| format!("读取安装器文件失败: {e}"))?; - std::fs::write(&installer_path, bytes) - .map_err(|e| format!("保存安装器失败({}): {e}", installer_path.display()))?; - - emit_install_progress( - app, - &format!("安装器已保存到:{}", installer_path.display()), - "info", - ); - - Ok(installer_path) -} - -pub(crate) fn launch_installer(file_path: &Path) -> Result<(), String> { - let extension = file_path - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or_default() - .to_ascii_lowercase(); - - match extension.as_str() { - "exe" => { - #[cfg(target_os = "windows")] - { - std::process::Command::new(file_path) - .spawn() - .map_err(|e| format!("启动安装程序失败: {e}"))?; - } - - #[cfg(not(target_os = "windows"))] - { - return Err("EXE 安装器只能在 Windows 上运行。".to_string()); - } - } - "msi" => { - #[cfg(target_os = "windows")] - { - std::process::Command::new("msiexec") - .arg("/i") - .arg(file_path) - .spawn() - .map_err(|e| format!("启动 MSI 安装程序失败: {e}"))?; - } - - #[cfg(not(target_os = "windows"))] - { - return Err("MSI 安装器只能在 Windows 上运行。".to_string()); - } - } - "pkg" | "dmg" => { - #[cfg(target_os = "macos")] - { - std::process::Command::new("open") - .arg(file_path) - .spawn() - .map_err(|e| format!("打开 macOS 安装器失败: {e}"))?; - } - - #[cfg(not(target_os = "macos"))] - { - return Err("该安装器只能在 macOS 上运行。".to_string()); - } - } - _ => return Err(format!("不支持的安装器文件类型: {extension}")), - } - - Ok(()) -} - -#[cfg(target_os = "macos")] -pub(crate) async fn trigger_macos_command_line_tools_install() -> Result { - let output = Command::new("/usr/bin/xcode-select") - .arg("--install") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| format!("拉起 macOS 开发者工具安装器失败: {e}"))?; - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let combined = if !stderr.is_empty() { stderr } else { stdout }; - let lower = combined.to_ascii_lowercase(); - - if output.status.success() - || lower.contains("install requested") - || lower.contains("already been requested") - { - return Ok("已拉起 macOS 开发者工具安装器。".to_string()); - } - - if lower.contains("already installed") { - return Err( - "系统提示 Command Line Tools 已安装,但当前仍未检测到 Git,请先执行系统更新或安装 Homebrew 后重试。" - .to_string(), - ); - } - - Err(format!("拉起 macOS 开发者工具安装器失败: {combined}")) -} - -#[cfg(not(target_os = "macos"))] -#[allow(dead_code)] -pub(crate) async fn trigger_macos_command_line_tools_install() -> Result { - Err("当前平台不支持拉起 macOS 开发者工具安装器。".to_string()) -} diff --git a/src-tauri/src/services/openclaw_service/mod.rs b/src-tauri/src/services/openclaw_service/mod.rs deleted file mode 100644 index 36208e698..000000000 --- a/src-tauri/src/services/openclaw_service/mod.rs +++ /dev/null @@ -1,155 +0,0 @@ -use crate::app::AppState; -use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType}; -use dirs::{data_dir, home_dir}; -use lime_core::openclaw_install::{ - build_openclaw_cleanup_command as core_build_openclaw_cleanup_command, - build_openclaw_install_command as core_build_openclaw_install_command, - build_winget_install_command as core_build_winget_install_command, - command_bin_dir_for as core_command_bin_dir_for, - resolve_windows_dependency_install_plan as core_resolve_windows_dependency_install_plan, - select_best_semver_candidate as core_select_best_semver_candidate, - select_preferred_path_candidate as core_select_preferred_path_candidate, - shell_command_escape_for as core_shell_command_escape_for, - shell_command_invocation_prefix_for as core_shell_command_invocation_prefix_for, - shell_npm_prefix_assignment_for as core_shell_npm_prefix_assignment_for, - shell_path_assignment_for as core_shell_path_assignment_for, - windows_manual_install_message as core_windows_manual_install_message, - OpenClawInstallDependencyKind, ShellPlatform, WindowsDependencyInstallPlan, -}; -use rand::{distributions::Alphanumeric, Rng}; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Map, Value}; -use std::cmp::Ordering; -use std::collections::{HashSet, VecDeque}; -use std::ffi::OsString; -use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::sync::{Arc, Mutex as StdMutex, OnceLock}; -use std::time::SystemTime; -use sysinfo::{Pid, Signal, System}; -use tauri::{AppHandle, Emitter, Manager}; -use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, BufReader}; -use tokio::net::TcpStream; -use tokio::process::{Child, Command}; -use tokio::sync::Mutex; -use tokio::time::{sleep, timeout, Duration}; -#[cfg(target_os = "windows")] -use winapi::shared::minwindef::{DWORD, HKEY}; -#[cfg(target_os = "windows")] -use winapi::shared::winerror::ERROR_SUCCESS; -#[cfg(target_os = "windows")] -use winapi::um::winreg::{RegOpenKeyExW, RegQueryValueExW, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; - -const DEFAULT_GATEWAY_PORT: u16 = 18790; -const OPENCLAW_INSTALL_EVENT: &str = "openclaw:install-progress"; -const OPENCLAW_CONFIG_ENV: &str = "OPENCLAW_CONFIG_PATH"; - -fn shell_escape(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\"'\"'")) -} -const OPENCLAW_CN_PACKAGE: &str = "@qingchencloud/openclaw-zh@latest"; -const OPENCLAW_DEFAULT_PACKAGE: &str = "openclaw@latest"; -const NPM_MIRROR_CN: &str = "https://registry.npmmirror.com"; -const NODE_MIN_VERSION: (u64, u64, u64) = (22, 12, 0); -const OPENCLAW_PROGRESS_LOG_LIMIT: usize = 400; -const OPENCLAW_INSTALLER_USER_AGENT: &str = "Lime-OpenClaw"; -#[cfg(not(target_os = "windows"))] -const OPENCLAW_TEMP_CARGO_CHECK_DIR: &str = "/tmp/lime-cargo-check"; -#[cfg(target_os = "windows")] -const CREATE_NO_WINDOW: u32 = 0x08000000; - -static OPENCLAW_PREFERRED_RUNTIME_BIN_DIR: OnceLock>> = OnceLock::new(); - -mod config; -mod diagnostics; -mod gateway; -mod install; -mod platform; -mod preview; -mod process; -mod progress; -mod runtime; -#[cfg(test)] -mod tests; -mod types; -mod update; - -pub(crate) use self::config::*; -pub(crate) use self::diagnostics::*; -pub(crate) use self::gateway::*; -pub(crate) use self::install::*; -pub(crate) use self::platform::*; -pub(crate) use self::process::*; -pub(crate) use self::progress::*; -pub(crate) use self::runtime::*; -pub use self::types::{ - ActionResult, BinaryAvailabilityStatus, BinaryInstallStatus, ChannelInfo, CommandPreview, - DependencyStatus, EnvironmentDiagnostics, EnvironmentStatus, GatewayStatus, GatewayStatusInfo, - HealthInfo, InstallProgressEvent, NodeCheckResult, OpenClawRuntimeCandidate, SyncModelEntry, - UpdateInfo, -}; -pub(crate) use self::types::{ - DependencyKind, InstallerAsset, OpenClawDirectUpgradePlan, OpenClawDirectUpgradeResult, - OpenClawUpdateExecutionContext, ResolvedOpenClawCommand, -}; -#[cfg(test)] -pub(crate) use self::update::*; - -#[derive(Debug)] -pub struct OpenClawService { - gateway_process: Option, - gateway_status: GatewayStatus, - gateway_port: u16, - gateway_auth_token: String, - gateway_started_at: Option, - progress_logs: VecDeque, -} - -impl Default for OpenClawService { - fn default() -> Self { - Self { - gateway_process: None, - gateway_status: GatewayStatus::Stopped, - gateway_port: DEFAULT_GATEWAY_PORT, - gateway_auth_token: String::new(), - gateway_started_at: None, - progress_logs: VecDeque::new(), - } - } -} - -pub struct OpenClawServiceState(pub std::sync::Arc>); - -impl Default for OpenClawServiceState { - fn default() -> Self { - Self(std::sync::Arc::new(Mutex::new(OpenClawService::default()))) - } -} - -impl OpenClawService { - pub fn clear_progress_logs(&mut self) { - self.progress_logs.clear(); - } - - pub fn get_progress_logs(&self) -> Vec { - self.progress_logs.iter().cloned().collect() - } - - fn clear_gateway_runtime_state(&mut self) { - self.gateway_process = None; - self.gateway_started_at = None; - self.gateway_status = GatewayStatus::Stopped; - } - fn push_progress_log(&mut self, message: String, level: String) { - if self.progress_logs.len() >= OPENCLAW_PROGRESS_LOG_LIMIT { - self.progress_logs.pop_front(); - } - self.progress_logs - .push_back(InstallProgressEvent { message, level }); - } -} - -pub fn openclaw_install_event_name() -> &'static str { - OPENCLAW_INSTALL_EVENT -} diff --git a/src-tauri/src/services/openclaw_service/platform.rs b/src-tauri/src/services/openclaw_service/platform.rs deleted file mode 100644 index 14a842440..000000000 --- a/src-tauri/src/services/openclaw_service/platform.rs +++ /dev/null @@ -1,292 +0,0 @@ -use super::*; - -pub(crate) fn generate_auth_token() -> String { - rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(48) - .map(char::from) - .collect() -} - -pub(crate) async fn should_use_china_package(app: &AppHandle) -> bool { - if let Some(app_state) = app.try_state::() { - let language = { - let state = app_state.read().await; - state.config.language.clone() - }; - - if language.starts_with("zh") { - return true; - } - } - - let locale = std::env::var("LC_ALL") - .ok() - .or_else(|| std::env::var("LANG").ok()) - .unwrap_or_default() - .to_lowercase(); - let timezone = std::env::var("TZ").unwrap_or_default().to_lowercase(); - locale.contains("zh_cn") || locale.contains("zh-hans") || timezone.contains("shanghai") -} - -pub(crate) async fn detect_npm_global_prefix(npm_path: &str) -> Option { - let mut command = Command::new(npm_path); - apply_binary_runtime_path(&mut command, npm_path); - let output = command - .arg("config") - .arg("get") - .arg("prefix") - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .await - .ok()?; - - if !output.status.success() { - return None; - } - - let prefix = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if prefix.is_empty() || prefix.eq_ignore_ascii_case("undefined") { - None - } else { - Some(prefix) - } -} - -pub(crate) fn current_shell_platform() -> ShellPlatform { - if cfg!(target_os = "windows") { - ShellPlatform::Windows - } else { - ShellPlatform::Unix - } -} - -#[allow(dead_code)] -pub(crate) fn command_bin_dir_for(platform: ShellPlatform, binary_path: &str) -> Option { - core_command_bin_dir_for(platform, binary_path) -} - -pub(crate) fn shell_command_escape_for(platform: ShellPlatform, value: &str) -> String { - core_shell_command_escape_for(platform, value) -} - -pub(crate) fn shell_command_invocation_prefix_for( - platform: ShellPlatform, - binary_path: &str, -) -> String { - core_shell_command_invocation_prefix_for(platform, binary_path) -} - -#[cfg_attr(target_os = "windows", allow(dead_code))] -pub(crate) fn shell_command_escape(value: &str) -> String { - shell_command_escape_for(current_shell_platform(), value) -} - -#[allow(dead_code)] -pub(crate) fn shell_npm_prefix_assignment_for(platform: ShellPlatform, value: &str) -> String { - core_shell_npm_prefix_assignment_for(platform, value) -} - -pub(crate) fn shell_path_assignment_for(platform: ShellPlatform, binary_path: &str) -> String { - core_shell_path_assignment_for(platform, binary_path) -} - -#[cfg_attr(target_os = "windows", allow(dead_code))] -pub(crate) fn shell_path_assignment(binary_path: &str) -> String { - shell_path_assignment_for(current_shell_platform(), binary_path) -} - -pub(crate) fn build_openclaw_cleanup_command( - platform: ShellPlatform, - npm_path: &str, - npm_prefix: Option<&str>, -) -> String { - core_build_openclaw_cleanup_command(platform, npm_path, npm_prefix) -} - -pub(crate) fn build_openclaw_install_command( - platform: ShellPlatform, - npm_path: &str, - npm_prefix: Option<&str>, - package: &str, - registry: Option<&str>, -) -> String { - core_build_openclaw_install_command(platform, npm_path, npm_prefix, package, registry) -} - -pub(crate) fn build_openclaw_pnpm_install_command( - platform: ShellPlatform, - pnpm_path: &str, - package: &str, - registry: Option<&str>, -) -> String { - let mut command = format!( - "{}{}{} add -g {}", - shell_path_assignment_for(platform, pnpm_path), - shell_command_invocation_prefix_for(platform, pnpm_path), - shell_command_escape_for(platform, pnpm_path), - shell_command_escape_for(platform, package), - ); - - if let Some(registry) = registry { - command.push(' '); - command.push_str("--registry="); - command.push_str(&shell_command_escape_for(platform, registry)); - } - - command -} - -#[allow(dead_code)] -pub(crate) fn resolve_windows_dependency_install_plan( - dependency: DependencyKind, - has_winget: bool, -) -> WindowsDependencyInstallPlan { - core_resolve_windows_dependency_install_plan( - match dependency { - DependencyKind::Node => OpenClawInstallDependencyKind::Node, - DependencyKind::Git => OpenClawInstallDependencyKind::Git, - }, - has_winget, - ) -} - -#[allow(dead_code)] -pub(crate) fn build_winget_install_command(winget_path: &str, package_id: &str) -> String { - core_build_winget_install_command(winget_path, package_id) -} - -#[allow(dead_code)] -pub(crate) fn windows_manual_install_message(dependency: DependencyKind) -> &'static str { - core_windows_manual_install_message(match dependency { - DependencyKind::Node => OpenClawInstallDependencyKind::Node, - DependencyKind::Git => OpenClawInstallDependencyKind::Git, - }) -} - -pub(crate) fn prepend_path(dir: &Path) -> Option { - let mut paths = vec![dir.to_path_buf()]; - if let Some(current) = std::env::var_os("PATH") { - paths.extend(std::env::split_paths(¤t)); - } - std::env::join_paths(paths).ok() -} - -pub(crate) fn apply_binary_runtime_path(command: &mut Command, binary_path: &str) { - apply_windows_no_window(command); - - let Some(bin_dir) = Path::new(binary_path).parent() else { - return; - }; - if let Some(path) = prepend_path(bin_dir) { - command.env("PATH", path); - } -} - -pub(crate) fn apply_windows_no_window(_command: &mut Command) { - #[cfg(target_os = "windows")] - { - _command.creation_flags(CREATE_NO_WINDOW); - } -} - -#[cfg(target_os = "windows")] -pub(crate) fn refresh_windows_path_from_registry() -> Result<(), String> { - unsafe { - let mut combined_path = String::new(); - - // 读取系统 PATH (HKEY_LOCAL_MACHINE) - if let Ok(system_path) = read_registry_path(HKEY_LOCAL_MACHINE) { - combined_path.push_str(&system_path); - } - - // 读取用户 PATH (HKEY_CURRENT_USER) - if let Ok(user_path) = read_registry_path(HKEY_CURRENT_USER) { - if !combined_path.is_empty() { - combined_path.push(';'); - } - combined_path.push_str(&user_path); - } - - if !combined_path.is_empty() { - std::env::set_var("PATH", combined_path); - } - } - - Ok(()) -} - -#[cfg(target_os = "windows")] -pub(crate) unsafe fn read_registry_path(root_key: HKEY) -> Result { - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; - use std::ptr; - - let subkey: Vec = OsStr::new("Environment") - .encode_wide() - .chain(std::iter::once(0)) - .collect(); - - let value_name: Vec = OsStr::new("Path") - .encode_wide() - .chain(std::iter::once(0)) - .collect(); - - let mut key: HKEY = ptr::null_mut(); - let result = RegOpenKeyExW( - root_key, - subkey.as_ptr(), - 0, - winapi::um::winnt::KEY_READ, - &mut key, - ); - - if result != ERROR_SUCCESS as i32 { - return Err(format!("无法打开注册表键: {}", result)); - } - - let mut buffer_size: DWORD = 0; - let result = RegQueryValueExW( - key, - value_name.as_ptr(), - ptr::null_mut(), - ptr::null_mut(), - ptr::null_mut(), - &mut buffer_size, - ); - - if result != ERROR_SUCCESS as i32 { - winapi::um::winreg::RegCloseKey(key); - return Err(format!("无法查询注册表值大小: {}", result)); - } - - let mut buffer: Vec = vec![0; (buffer_size / 2) as usize + 1]; - let result = RegQueryValueExW( - key, - value_name.as_ptr(), - ptr::null_mut(), - ptr::null_mut(), - buffer.as_mut_ptr() as *mut u8, - &mut buffer_size, - ); - - winapi::um::winreg::RegCloseKey(key); - - if result != ERROR_SUCCESS as i32 { - return Err(format!("无法读取注册表值: {}", result)); - } - - // 移除尾部的 null 字符 - if let Some(null_pos) = buffer.iter().position(|&c| c == 0) { - buffer.truncate(null_pos); - } - - Ok(String::from_utf16_lossy(&buffer)) -} - -#[cfg(not(target_os = "windows"))] -#[allow(dead_code)] -pub(crate) fn refresh_windows_path_from_registry() -> Result<(), String> { - Ok(()) -} diff --git a/src-tauri/src/services/openclaw_service/preview.rs b/src-tauri/src/services/openclaw_service/preview.rs deleted file mode 100644 index d2a928270..000000000 --- a/src-tauri/src/services/openclaw_service/preview.rs +++ /dev/null @@ -1,117 +0,0 @@ -use super::*; - -impl OpenClawService { - pub async fn get_command_preview( - &mut self, - app: &AppHandle, - operation: &str, - port: Option, - ) -> Result { - match operation { - "install" => self.build_install_command_preview(app).await, - "uninstall" => self.build_uninstall_command_preview().await, - "restart" => self.build_restart_command_preview(port).await, - "start" => self.build_start_command_preview(port).await, - "stop" => self.build_stop_command_preview(port).await, - _ => Err(format!("不支持的 OpenClaw 操作预览: {operation}")), - } - } - - async fn build_install_command_preview( - &self, - app: &AppHandle, - ) -> Result { - let (package, npm_path, npm_prefix, cleanup_command, install_command) = - self.resolve_install_commands(app).await?; - let prefix_note = npm_prefix - .map(|prefix| format!("npm: {npm_path}\nprefix: {prefix}\n")) - .unwrap_or_else(|| format!("npm: {npm_path}\n")); - Ok(CommandPreview { - title: format!("安装 {package}"), - command: format!("{prefix_note}{cleanup_command}\n{install_command}"), - }) - } - - async fn build_uninstall_command_preview(&self) -> Result { - let (npm_path, npm_prefix, command) = self.resolve_uninstall_command().await?; - let prefix_note = npm_prefix - .map(|prefix| format!("npm: {npm_path}\nprefix: {prefix}\n")) - .unwrap_or_else(|| format!("npm: {npm_path}\n")); - Ok(CommandPreview { - title: "卸载 OpenClaw".to_string(), - command: format!("{prefix_note}{command}"), - }) - } - - async fn build_start_command_preview( - &mut self, - port: Option, - ) -> Result { - if let Some(next_port) = port { - self.gateway_port = next_port.max(1); - } - self.restore_auth_token_from_config(); - if self.gateway_auth_token.is_empty() { - self.gateway_auth_token = generate_auth_token(); - } - let openclaw_command = resolve_openclaw_command() - .await? - .ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?; - let config_path = openclaw_lime_config_path(); - let command = gateway_start_args(self.gateway_port, &self.gateway_auth_token) - .into_iter() - .map(|arg| shell_escape(&arg)) - .collect::>() - .join(" "); - Ok(CommandPreview { - title: "启动 Gateway".to_string(), - command: format!( - "{}OPENCLAW_CONFIG_PATH={} {} {}", - if cfg!(target_os = "windows") { - "set " - } else { - "" - }, - shell_escape(config_path.to_string_lossy().as_ref()), - openclaw_command.preview_invocation(), - command - ), - }) - } - - async fn build_stop_command_preview( - &mut self, - port: Option, - ) -> Result { - if let Some(next_port) = port { - self.gateway_port = next_port.max(1); - } - self.restore_auth_token_from_config(); - let openclaw_command = resolve_openclaw_command() - .await? - .ok_or_else(|| "未检测到 OpenClaw 可执行文件,请先安装。".to_string())?; - let config_path = openclaw_lime_config_path(); - Ok(CommandPreview { - title: "停止 Gateway".to_string(), - command: format!( - "OPENCLAW_CONFIG_PATH={} {} gateway stop --url {} --token {}", - shell_escape(config_path.to_string_lossy().as_ref()), - openclaw_command.preview_invocation(), - self.gateway_ws_url(), - shell_escape(&self.gateway_auth_token) - ), - }) - } - - async fn build_restart_command_preview( - &mut self, - port: Option, - ) -> Result { - let stop = self.build_stop_command_preview(port).await?; - let start = self.build_start_command_preview(port).await?; - Ok(CommandPreview { - title: "重启 Gateway".to_string(), - command: format!("{}\n{}", stop.command, start.command), - }) - } -} diff --git a/src-tauri/src/services/openclaw_service/process.rs b/src-tauri/src/services/openclaw_service/process.rs deleted file mode 100644 index 73af52210..000000000 --- a/src-tauri/src/services/openclaw_service/process.rs +++ /dev/null @@ -1,196 +0,0 @@ -use super::*; - -pub(crate) fn command_output_lines(output: &[u8]) -> Vec { - String::from_utf8_lossy(output) - .lines() - .map(sanitize_progress_line) - .filter(|line| !line.is_empty()) - .collect() -} - -pub(crate) fn normalize_process_probe_text(value: &str) -> String { - value.replace('\\', "/").to_ascii_lowercase() -} - -pub(crate) fn process_looks_like_openclaw_process( - process_name: &str, - exe_path: Option<&Path>, - command_args: &[OsString], -) -> bool { - let process_name = normalize_process_probe_text(process_name); - let exe_path = exe_path - .map(|path| normalize_process_probe_text(&path.display().to_string())) - .unwrap_or_default(); - let command_line = normalize_process_probe_text( - &command_args - .iter() - .map(|arg| arg.to_string_lossy()) - .collect::>() - .join(" "), - ); - - process_name.contains("openclaw") - || exe_path.contains("openclaw") - || command_line.contains("openclaw") -} - -pub(crate) fn collect_openclaw_process_family_pids( - system: &System, - listener_pids: &[u32], -) -> Vec { - let mut target_pids = HashSet::new(); - - for listener_pid in listener_pids { - let pid = Pid::from_u32(*listener_pid); - let Some(process) = system.process(pid) else { - continue; - }; - if !process_looks_like_openclaw_process( - &process.name().to_string_lossy(), - process.exe(), - process.cmd(), - ) { - continue; - } - - target_pids.insert(pid); - let mut parent_pid = process.parent(); - while let Some(next_parent) = parent_pid { - let Some(parent_process) = system.process(next_parent) else { - break; - }; - if !process_looks_like_openclaw_process( - &parent_process.name().to_string_lossy(), - parent_process.exe(), - parent_process.cmd(), - ) { - break; - } - - target_pids.insert(next_parent); - parent_pid = parent_process.parent(); - } - } - - let mut target_pids = target_pids.into_iter().collect::>(); - target_pids.sort_by_key(|pid| pid.as_u32()); - target_pids -} - -pub(crate) async fn terminate_sysinfo_processes(system: &mut System, target_pids: &[Pid]) { - for pid in target_pids { - if let Some(process) = system.process(*pid) { - let terminated = process.kill_with(Signal::Term).unwrap_or(false); - if !terminated { - let _ = process.kill(); - } - } - } - - sleep(Duration::from_millis(900)).await; - system.refresh_all(); - - for pid in target_pids { - if let Some(process) = system.process(*pid) { - let _ = process.kill(); - } - } -} - -#[cfg_attr(target_os = "windows", allow(dead_code))] -pub(crate) fn parse_lsof_listener_pids(output: &str) -> Vec { - let mut pids = output - .lines() - .filter_map(|line| line.trim().parse::().ok()) - .collect::>(); - pids.sort_unstable(); - pids.dedup(); - pids -} - -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] -pub(crate) fn parse_windows_netstat_listener_pids(output: &str, port: u16) -> Vec { - let mut pids = output - .lines() - .filter_map(|line| { - let columns = line.split_whitespace().collect::>(); - if columns.len() < 5 { - return None; - } - - let local_address = columns.get(1).copied().unwrap_or_default(); - let state = columns.get(3).copied().unwrap_or_default(); - if !state.eq_ignore_ascii_case("LISTENING") - || !local_address.ends_with(&format!(":{port}")) - { - return None; - } - - columns.last().and_then(|value| value.parse::().ok()) - }) - .collect::>(); - pids.sort_unstable(); - pids.dedup(); - pids -} - -pub(crate) async fn collect_listening_port_pids(port: u16) -> Vec { - #[cfg(target_os = "windows")] - { - collect_listening_port_pids_windows(port).await - } - - #[cfg(not(target_os = "windows"))] - { - collect_listening_port_pids_unix(port).await - } -} - -#[cfg(not(target_os = "windows"))] -pub(crate) async fn collect_listening_port_pids_unix(port: u16) -> Vec { - let mut command = Command::new("lsof"); - apply_windows_no_window(&mut command); - let output = timeout( - Duration::from_secs(3), - command - .arg("-nP") - .arg(format!("-iTCP:{port}")) - .arg("-sTCP:LISTEN") - .arg("-t") - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output(), - ) - .await; - - match output { - Ok(Ok(result)) if result.status.success() => { - parse_lsof_listener_pids(&String::from_utf8_lossy(&result.stdout)) - } - _ => Vec::new(), - } -} - -#[cfg(target_os = "windows")] -pub(crate) async fn collect_listening_port_pids_windows(port: u16) -> Vec { - let mut command = Command::new("netstat"); - apply_windows_no_window(&mut command); - let output = timeout( - Duration::from_secs(3), - command - .arg("-ano") - .arg("-p") - .arg("tcp") - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output(), - ) - .await; - - match output { - Ok(Ok(result)) if result.status.success() => { - parse_windows_netstat_listener_pids(&String::from_utf8_lossy(&result.stdout), port) - } - _ => Vec::new(), - } -} diff --git a/src-tauri/src/services/openclaw_service/progress.rs b/src-tauri/src/services/openclaw_service/progress.rs deleted file mode 100644 index b6255c9ab..000000000 --- a/src-tauri/src/services/openclaw_service/progress.rs +++ /dev/null @@ -1,214 +0,0 @@ -use super::*; - -pub(crate) async fn run_shell_command_with_progress( - app: &AppHandle, - command_line: &str, -) -> Result { - let mut child = spawn_shell_command(command_line)?; - - let stdout_task = child.stdout.take().map(|stdout| { - let app = app.clone(); - tokio::spawn(async move { - stream_reader_to_progress(app, stdout, "info").await; - }) - }); - - let stderr_task = child.stderr.take().map(|stderr| { - let app = app.clone(); - tokio::spawn(async move { - stream_reader_to_progress(app, stderr, "error").await; - }) - }); - - let status = child - .wait() - .await - .map_err(|e| format!("执行命令失败: {e}"))?; - - if let Some(task) = stdout_task { - let _ = task.await; - } - if let Some(task) = stderr_task { - let _ = task.await; - } - - if status.success() { - emit_install_progress(app, "命令执行成功。", "info"); - Ok(ActionResult { - success: true, - message: "操作成功完成。".to_string(), - }) - } else { - emit_install_progress( - app, - &format!("命令执行失败,退出码: {:?}", status.code()), - "error", - ); - Ok(ActionResult { - success: false, - message: format!("命令执行失败,退出码: {:?}", status.code()), - }) - } -} - -pub(crate) fn spawn_shell_command(command_line: &str) -> Result { - let mut command = if cfg!(target_os = "windows") { - let mut cmd = Command::new("cmd"); - cmd.arg("/C").arg(command_line); - cmd - } else if cfg!(target_os = "macos") { - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); - let mut cmd = Command::new("script"); - cmd.arg("-q") - .arg("/dev/null") - .arg(shell) - .arg("-lc") - .arg(command_line); - cmd - } else { - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); - let mut cmd = Command::new(shell); - cmd.arg("-lc").arg(command_line); - cmd - }; - - apply_windows_no_window(&mut command); - - command - .env("NO_COLOR", "1") - .env("CLICOLOR", "0") - .env("FORCE_COLOR", "0") - .env("npm_config_color", "false") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - command.spawn().map_err(|e| format!("启动命令失败: {e}")) -} - -pub(crate) async fn stream_reader_to_progress( - app: AppHandle, - mut reader: R, - default_level: &'static str, -) where - R: AsyncRead + Unpin, -{ - let mut buffer = [0_u8; 2048]; - let mut pending = String::new(); - - loop { - match reader.read(&mut buffer).await { - Ok(0) => break, - Ok(size) => { - pending.push_str(&String::from_utf8_lossy(&buffer[..size])); - flush_progress_chunks(&app, &mut pending, default_level); - } - Err(error) => { - emit_install_progress(&app, &format!("读取命令输出失败: {error}"), "warn"); - break; - } - } - } - - let tail = pending.trim(); - if !tail.is_empty() { - emit_install_progress(&app, tail, classify_progress_level(tail, default_level)); - } -} - -pub(crate) fn flush_progress_chunks( - app: &AppHandle, - pending: &mut String, - default_level: &'static str, -) { - loop { - let next_break = pending.find(['\n', '\r']); - let Some(index) = next_break else { - break; - }; - - let mut line = pending[..index].trim().to_string(); - let mut consume_len = index + 1; - while pending - .get(consume_len..consume_len + 1) - .is_some_and(|ch| ch == "\n" || ch == "\r") - { - consume_len += 1; - } - - pending.drain(..consume_len); - - if line.is_empty() { - continue; - } - - line = sanitize_progress_line(&line); - if line.is_empty() { - continue; - } - - emit_install_progress(app, &line, classify_progress_level(&line, default_level)); - } - - if pending.len() > 4096 { - let line = sanitize_progress_line(pending.trim()); - if !line.is_empty() { - emit_install_progress(app, &line, classify_progress_level(&line, default_level)); - } - pending.clear(); - } -} - -pub(crate) fn sanitize_progress_line(value: &str) -> String { - value - .replace('\u{1b}', "") - .replace("[?25h", "") - .replace("[?25l", "") - .trim() - .to_string() -} - -pub(crate) fn classify_progress_level(message: &str, default_level: &'static str) -> &'static str { - let lower = message.to_ascii_lowercase(); - if lower.contains("error") || lower.contains("fatal") { - "error" - } else if lower.contains("warn") || lower.contains("warning") { - "warn" - } else { - default_level - } -} - -pub(crate) fn emit_install_progress(app: &AppHandle, message: &str, level: &str) { - if let Some(service_state) = app.try_state::() { - if let Ok(mut service) = service_state.0.try_lock() { - service.push_progress_log(message.to_string(), level.to_string()); - } - } - - let payload = InstallProgressEvent { - message: message.to_string(), - level: level.to_string(), - }; - let _ = app.emit(OPENCLAW_INSTALL_EVENT, payload); -} - -pub(crate) fn parse_semver(value: &str) -> Option<(u64, u64, u64)> { - let sanitized = value.trim().trim_start_matches('v'); - let core = sanitized.split(['-', '+']).next()?; - let mut parts = core.split('.'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next().unwrap_or("0").parse().ok()?; - let patch = parts.next().unwrap_or("0").parse().ok()?; - Some((major, minor, patch)) -} - -pub(crate) fn parse_semver_from_text(value: &str) -> Option<(u64, u64, u64)> { - parse_semver(value).or_else(|| { - value - .split(|ch: char| ch.is_whitespace() || ch == ',' || ch == '(' || ch == ')') - .find_map(parse_semver) - }) -} - -pub(crate) fn format_semver(version: (u64, u64, u64)) -> String { - format!("{}.{}.{}", version.0, version.1, version.2) -} diff --git a/src-tauri/src/services/openclaw_service/runtime.rs b/src-tauri/src/services/openclaw_service/runtime.rs deleted file mode 100644 index a1db1f210..000000000 --- a/src-tauri/src/services/openclaw_service/runtime.rs +++ /dev/null @@ -1,1096 +0,0 @@ -use super::*; - -impl OpenClawService { - pub async fn list_runtime_candidates(&self) -> Result, String> { - list_openclaw_runtime_candidates().await - } - - pub async fn set_preferred_runtime( - &self, - runtime_id: Option<&str>, - ) -> Result { - let normalized = runtime_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(PathBuf::from); - - let Some(runtime_bin_dir) = normalized else { - set_preferred_runtime_bin_dir(None); - return Ok(ActionResult { - success: true, - message: "已切换为自动选择执行环境。".to_string(), - }); - }; - - let candidates = list_openclaw_runtime_candidates().await?; - let runtime_id = runtime_bin_dir.display().to_string(); - let Some(candidate) = candidates.iter().find(|item| item.id == runtime_id) else { - return Ok(ActionResult { - success: false, - message: "未找到指定的 OpenClaw 执行环境,请重新检测后再试。".to_string(), - }); - }; - - set_preferred_runtime_bin_dir(Some(runtime_bin_dir)); - Ok(ActionResult { - success: true, - message: format!( - "已固定使用执行环境:{}{}。", - candidate.source, - candidate - .node_version - .as_deref() - .map(|version| format!(" · Node {version}")) - .unwrap_or_default() - ), - }) - } -} - -pub(crate) fn preferred_runtime_bin_dir_store() -> &'static StdMutex> { - OPENCLAW_PREFERRED_RUNTIME_BIN_DIR.get_or_init(|| StdMutex::new(None)) -} - -pub(crate) fn get_preferred_runtime_bin_dir() -> Option { - preferred_runtime_bin_dir_store() - .lock() - .unwrap_or_else(|error| error.into_inner()) - .clone() -} - -pub(crate) fn set_preferred_runtime_bin_dir(next: Option) { - let mut guard = preferred_runtime_bin_dir_store() - .lock() - .unwrap_or_else(|error| error.into_inner()); - *guard = next.filter(|path| !path.as_os_str().is_empty()); -} - -pub(crate) fn command_uses_node_runtime(command_name: &str) -> bool { - matches!(command_name, "node" | "npm" | "npx" | "openclaw") -} - -pub(crate) fn find_command_in_bin_dir(command_name: &str, bin_dir: &Path) -> Option { - select_preferred_path_candidate(find_all_commands_in_paths( - command_name, - &[bin_dir.to_path_buf()], - )) -} - -pub(crate) fn collect_existing_unique_dirs(candidates: I) -> Vec -where - I: IntoIterator, -{ - let mut dirs = Vec::new(); - let mut seen = HashSet::new(); - - for dir in candidates { - if dir.as_os_str().is_empty() || !dir.exists() { - continue; - } - if seen.insert(dir.clone()) { - dirs.push(dir); - } - } - - dirs -} - -pub(crate) async fn collect_preferred_runtime_command_dirs( - command_name: &str, - preferred_bin_dir: &Path, -) -> Result, String> { - let mut candidate_dirs = vec![preferred_bin_dir.to_path_buf()]; - - if command_name == "openclaw" { - if let Some(npm_path) = find_command_in_bin_dir("npm", preferred_bin_dir) - .and_then(|path| path.to_str().map(str::to_string)) - { - if let Some(prefix) = detect_npm_global_prefix(&npm_path).await { - for dir in npm_global_command_dirs(&prefix) { - candidate_dirs.push(dir); - } - } - } - } - - Ok(collect_existing_unique_dirs(candidate_dirs)) -} - -pub(crate) async fn collect_preferred_runtime_command_candidates( - command_name: &str, -) -> Result, String> { - if !command_uses_node_runtime(command_name) { - return Ok(Vec::new()); - } - - let Some(preferred_bin_dir) = get_preferred_runtime_bin_dir() else { - return Ok(Vec::new()); - }; - - let search_dirs = - collect_preferred_runtime_command_dirs(command_name, &preferred_bin_dir).await?; - Ok(find_all_commands_in_paths(command_name, &search_dirs)) -} - -pub(crate) async fn find_command_in_shell(command_name: &str) -> Result, String> { - let mut candidates = collect_standard_command_candidates(command_name).await?; - - if command_name == "openclaw" { - candidates.extend(find_commands_via_npm_global_prefix(command_name).await?); - } - - Ok(select_command_path(command_name, candidates) - .await? - .map(|path| path.to_string_lossy().to_string())) -} - -pub(crate) async fn find_command_in_standard_locations( - command_name: &str, -) -> Result, String> { - Ok(select_command_path( - command_name, - collect_standard_command_candidates(command_name).await?, - ) - .await? - .map(|path| path.to_string_lossy().to_string())) -} - -pub(crate) async fn collect_standard_command_candidates( - command_name: &str, -) -> Result, String> { - collect_standard_command_candidates_with_preference(command_name, true).await -} - -pub(crate) async fn collect_standard_command_candidates_without_preference( - command_name: &str, -) -> Result, String> { - collect_standard_command_candidates_with_preference(command_name, false).await -} - -pub(crate) async fn collect_standard_command_candidates_with_preference( - command_name: &str, - include_preferred_runtime: bool, -) -> Result, String> { - #[cfg(target_os = "windows")] - { - let _ = refresh_windows_path_from_registry(); - } - - let mut candidates = Vec::new(); - - if include_preferred_runtime { - candidates.extend(collect_preferred_runtime_command_candidates(command_name).await?); - } - - #[cfg(target_os = "windows")] - { - candidates.extend(find_commands_via_where(command_name).await?); - } - - candidates.extend(find_all_commands_in_known_locations(command_name)); - - Ok(candidates) -} - -pub(crate) async fn select_command_path( - command_name: &str, - candidates: Vec, -) -> Result, String> { - select_command_candidate(command_name, dedupe_paths(candidates)).await -} - -#[cfg(target_os = "windows")] -pub(crate) async fn find_commands_via_where(command_name: &str) -> Result, String> { - let mut command = Command::new("cmd"); - apply_windows_no_window(&mut command); - let output = command - .arg("/C") - .arg("where") - .arg(command_name) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .output() - .await - .map_err(|e| format!("查找命令失败: {e}"))?; - - if !output.status.success() { - return Ok(Vec::new()); - } - - Ok(String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(PathBuf::from) - .collect()) -} - -pub(crate) async fn select_command_candidate( - command_name: &str, - candidates: Vec, -) -> Result, String> { - if candidates.is_empty() { - return Ok(None); - } - - let has_preferred_runtime = - command_uses_node_runtime(command_name) && get_preferred_runtime_bin_dir().is_some(); - if let Some(candidate) = select_preferred_runtime_candidate(command_name, &candidates).await? { - return Ok(Some(candidate)); - } - if has_preferred_runtime { - return Ok(None); - } - - if command_name == "node" { - return select_best_node_candidate(candidates).await; - } - - if matches!(command_name, "npm" | "npx" | "openclaw") { - return select_node_runtime_candidate(candidates).await; - } - - if command_name == "git" { - return Ok(select_best_git_candidate(candidates)); - } - - Ok(candidates.into_iter().next()) -} - -pub(crate) fn select_best_git_candidate(candidates: Vec) -> Option { - select_preferred_path_candidate(candidates.clone()).or_else(|| candidates.into_iter().next()) -} - -pub(crate) async fn select_preferred_runtime_candidate( - command_name: &str, - candidates: &[PathBuf], -) -> Result, String> { - if !command_uses_node_runtime(command_name) { - return Ok(None); - } - - let Some(preferred_bin_dir) = get_preferred_runtime_bin_dir() else { - return Ok(None); - }; - - let preferred_dirs = - collect_preferred_runtime_command_dirs(command_name, &preferred_bin_dir).await?; - if preferred_dirs.is_empty() { - return Ok(None); - } - - Ok(select_preferred_path_candidate( - candidates - .iter() - .filter(|candidate| { - candidate - .parent() - .is_some_and(|parent| preferred_dirs.iter().any(|dir| dir.as_path() == parent)) - }) - .cloned() - .collect(), - )) -} - -pub(crate) fn find_all_commands_in_known_locations(command_name: &str) -> Vec { - let search_dirs = collect_known_command_search_dirs(command_name); - find_all_commands_in_paths(command_name, &search_dirs) -} - -pub(crate) fn collect_known_command_search_dirs(_command_name: &str) -> Vec { - let mut search_dirs = Vec::new(); - - if let Some(path_var) = std::env::var_os("PATH") { - search_dirs.extend(std::env::split_paths(&path_var)); - } - - if let Some(home) = home_dir() { - search_dirs.extend([ - home.join(".npm-global/bin"), - home.join(".local/bin"), - home.join(".bun/bin"), - home.join(".volta/bin"), - home.join(".asdf/shims"), - home.join(".local/share/mise/shims"), - home.join("Library/PhpWebStudy/env/node/bin"), - ]); - - let nvm_versions = home.join(".nvm/versions/node"); - if let Ok(entries) = std::fs::read_dir(nvm_versions) { - for entry in entries.flatten() { - search_dirs.push(entry.path().join("bin")); - } - } - - let fnm_versions = home.join(".fnm/node-versions"); - if let Ok(entries) = std::fs::read_dir(fnm_versions) { - for entry in entries.flatten() { - search_dirs.push(entry.path().join("installation/bin")); - } - } - } - - #[cfg(target_os = "windows")] - { - search_dirs.extend(windows_known_command_dirs_from_env()); - if _command_name == "git" { - search_dirs.extend(windows_known_git_command_dirs_from_env()); - } - } - - if cfg!(target_os = "macos") { - search_dirs.extend([ - PathBuf::from("/opt/homebrew/bin"), - PathBuf::from("/usr/local/bin"), - PathBuf::from("/usr/bin"), - PathBuf::from("/bin"), - ]); - } - - collect_existing_unique_dirs(search_dirs) -} - -#[cfg(target_os = "windows")] -pub(crate) fn windows_known_command_dirs_from_env() -> Vec { - let mut dirs = Vec::new(); - - if let Some(appdata) = std::env::var_os("APPDATA") { - dirs.push(PathBuf::from(appdata).join("npm")); - } - - if let Some(localappdata) = std::env::var_os("LOCALAPPDATA") { - let localappdata = PathBuf::from(localappdata); - dirs.push(localappdata.join("Programs").join("nodejs")); - dirs.push(localappdata.join("Volta").join("bin")); - } - - if let Some(program_files) = std::env::var_os("ProgramFiles") { - dirs.push(PathBuf::from(program_files).join("nodejs")); - } - - if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") { - dirs.push(PathBuf::from(program_files_x86).join("nodejs")); - } - - if let Some(home) = home_dir() { - dirs.push(home.join("AppData").join("Roaming").join("npm")); - dirs.push( - home.join("AppData") - .join("Local") - .join("Programs") - .join("nodejs"), - ); - } - - dirs -} - -#[cfg(any(target_os = "windows", test))] -pub(crate) fn windows_git_install_dir_variants(root: PathBuf) -> Vec { - vec![ - root.join("cmd"), - root.join("bin"), - root.join("mingw64").join("bin"), - ] -} - -#[cfg(target_os = "windows")] -pub(crate) fn windows_known_git_command_dirs_from_env() -> Vec { - let mut dirs = Vec::new(); - - if let Some(program_files) = std::env::var_os("ProgramFiles") { - dirs.extend(windows_git_install_dir_variants( - PathBuf::from(program_files).join("Git"), - )); - } - - if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") { - dirs.extend(windows_git_install_dir_variants( - PathBuf::from(program_files_x86).join("Git"), - )); - } - - if let Some(localappdata) = std::env::var_os("LOCALAPPDATA") { - dirs.extend(windows_git_install_dir_variants( - PathBuf::from(localappdata).join("Programs").join("Git"), - )); - } - - if let Some(home) = home_dir() { - dirs.extend(windows_git_install_dir_variants( - home.join("scoop").join("apps").join("git").join("current"), - )); - } - - dirs -} - -pub(crate) fn find_all_commands_in_paths( - command_name: &str, - search_dirs: &[PathBuf], -) -> Vec { - find_all_commands_in_paths_for(current_shell_platform(), command_name, search_dirs) -} - -pub(crate) fn find_all_commands_in_paths_for( - platform: ShellPlatform, - command_name: &str, - search_dirs: &[PathBuf], -) -> Vec { - let candidates = match platform { - ShellPlatform::Windows => vec![ - format!("{command_name}.exe"), - format!("{command_name}.cmd"), - format!("{command_name}.bat"), - command_name.to_string(), - ], - ShellPlatform::Unix => vec![command_name.to_string()], - }; - - let mut matches = Vec::new(); - let mut seen = HashSet::new(); - for dir in search_dirs { - for candidate in &candidates { - let path = dir.join(candidate); - if path.is_file() && seen.insert(path.clone()) { - matches.push(path); - } - } - } - - matches -} - -pub(crate) async fn find_commands_via_npm_global_prefix( - command_name: &str, -) -> Result, String> { - let Some(npm_path) = find_command_in_standard_locations("npm").await? else { - return Ok(Vec::new()); - }; - let Some(prefix) = detect_npm_global_prefix(&npm_path).await else { - return Ok(Vec::new()); - }; - - Ok(find_all_commands_in_paths( - command_name, - &npm_global_command_dirs(&prefix), - )) -} - -pub(crate) fn npm_global_command_dirs(prefix: &str) -> Vec { - npm_global_command_dirs_for(current_shell_platform(), prefix) -} - -pub(crate) fn npm_global_command_dirs_for(platform: ShellPlatform, prefix: &str) -> Vec { - let prefix_path = PathBuf::from(prefix); - - match platform { - ShellPlatform::Windows => vec![prefix_path], - ShellPlatform::Unix => vec![prefix_path.join("bin"), prefix_path], - } -} - -pub(crate) fn npm_global_node_modules_dirs_for( - platform: ShellPlatform, - prefix: &str, -) -> Vec { - let prefix_path = PathBuf::from(prefix); - - match platform { - ShellPlatform::Windows => vec![prefix_path.join("node_modules")], - ShellPlatform::Unix => vec![ - prefix_path.join("lib").join("node_modules"), - prefix_path.join("node_modules"), - ], - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct InstalledOpenClawPackage { - pub(crate) name: &'static str, - pub(crate) version: Option, - pub(crate) path: PathBuf, -} - -#[cfg(test)] -pub(crate) fn find_installed_openclaw_package( - prefix: &str, -) -> Option<(&'static str, Option)> { - find_installed_openclaw_package_details(prefix).map(|package| (package.name, package.version)) -} - -pub(crate) fn find_installed_openclaw_package_details( - prefix: &str, -) -> Option { - for node_modules_dir in npm_global_node_modules_dirs_for(current_shell_platform(), prefix) { - let openclaw_manifest = node_modules_dir.join("openclaw").join("package.json"); - if openclaw_manifest.is_file() { - return Some(InstalledOpenClawPackage { - name: "openclaw", - version: read_package_version(&openclaw_manifest), - path: openclaw_manifest, - }); - } - - let zh_manifest = node_modules_dir - .join("@qingchencloud") - .join("openclaw-zh") - .join("package.json"); - if zh_manifest.is_file() { - return Some(InstalledOpenClawPackage { - name: "@qingchencloud/openclaw-zh", - version: read_package_version(&zh_manifest), - path: zh_manifest, - }); - } - } - - None -} - -pub(crate) fn read_package_version(manifest_path: &Path) -> Option { - #[derive(Deserialize)] - struct PackageManifest { - version: Option, - } - - let content = std::fs::read_to_string(manifest_path).ok()?; - let manifest = serde_json::from_str::(&content).ok()?; - manifest.version.filter(|item| !item.trim().is_empty()) -} - -pub(crate) fn resolve_openclaw_cli_entry_from_package_manifest( - manifest_path: &Path, -) -> Option { - let package_root = manifest_path.parent()?; - let content = std::fs::read_to_string(manifest_path).ok()?; - let manifest = serde_json::from_str::(&content).ok()?; - - let mut candidates = Vec::new(); - - if let Some(bin_value) = manifest.get("bin") { - let bin_entry = match bin_value { - Value::String(value) => Some(value.as_str()), - Value::Object(entries) => entries - .get("openclaw") - .and_then(Value::as_str) - .or_else(|| entries.values().find_map(Value::as_str)), - _ => None, - }; - - if let Some(entry) = bin_entry { - candidates.push(package_root.join(entry)); - } - } - - candidates.push(package_root.join("dist").join("index.js")); - candidates.push(package_root.join("dist").join("index.mjs")); - candidates.push(package_root.join("dist").join("entry.js")); - candidates.push(package_root.join("dist").join("entry.mjs")); - - candidates.into_iter().find(|path| path.is_file()) -} - -pub(crate) fn prefers_node_cli_for_openclaw_path( - platform: ShellPlatform, - openclaw_path: &Path, -) -> bool { - matches!(platform, ShellPlatform::Windows) - && openclaw_path - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "cmd" | "bat")) - .unwrap_or(false) -} - -pub(crate) fn resolve_openclaw_command_from_runtime_candidate_for( - platform: ShellPlatform, - candidate: &OpenClawRuntimeCandidate, -) -> Option { - let openclaw_path = candidate - .openclaw_path - .as_deref() - .map(PathBuf::from) - .filter(|path| path.is_file()); - - if let Some(openclaw_path) = openclaw_path.as_ref() { - if !prefers_node_cli_for_openclaw_path(platform, openclaw_path) { - return Some(ResolvedOpenClawCommand::Binary { - binary_path: openclaw_path.clone(), - }); - } - } - - let node_path = PathBuf::from(candidate.node_path.as_str()); - let manifest_path = candidate - .openclaw_package_path - .as_deref() - .map(PathBuf::from); - - if node_path.is_file() { - if let Some(manifest_path) = manifest_path { - if let Some(cli_path) = resolve_openclaw_cli_entry_from_package_manifest(&manifest_path) - { - return Some(ResolvedOpenClawCommand::NodeCli { - node_path, - cli_path, - package_version: read_package_version(&manifest_path), - }); - } - } - } - - openclaw_path.map(|binary_path| ResolvedOpenClawCommand::Binary { binary_path }) -} - -pub(crate) fn resolve_openclaw_command_from_runtime_candidate( - candidate: &OpenClawRuntimeCandidate, -) -> Option { - resolve_openclaw_command_from_runtime_candidate_for(current_shell_platform(), candidate) -} - -pub(crate) fn runtime_candidate_matches_openclaw_path( - candidate: &OpenClawRuntimeCandidate, - openclaw_path: &Path, -) -> bool { - candidate - .openclaw_path - .as_deref() - .map(Path::new) - .is_some_and(|candidate_path| candidate_path == openclaw_path) - || openclaw_path - .parent() - .is_some_and(|parent| Path::new(&candidate.bin_dir) == parent) - || candidate - .npm_global_prefix - .as_deref() - .map(Path::new) - .is_some_and(|prefix| openclaw_path.starts_with(prefix)) -} - -pub(crate) fn dedupe_openclaw_commands( - commands: Vec, -) -> Vec { - let mut deduped = Vec::with_capacity(commands.len()); - let mut seen = HashSet::new(); - for command in commands { - if seen.insert(command.dedupe_key()) { - deduped.push(command); - } - } - deduped -} - -pub(crate) fn dedupe_paths(candidates: Vec) -> Vec { - let mut deduped = Vec::with_capacity(candidates.len()); - let mut seen = HashSet::new(); - for candidate in candidates { - if seen.insert(candidate.clone()) { - deduped.push(candidate); - } - } - deduped -} - -pub(crate) async fn resolve_openclaw_command() -> Result, String> { - let shell_platform = current_shell_platform(); - let shell_binary = find_command_in_shell("openclaw").await?.map(PathBuf::from); - let mut runtime_candidates = list_openclaw_runtime_candidates().await?; - runtime_candidates.sort_by(compare_openclaw_runtime_candidates); - - if let Some(shell_binary) = shell_binary.as_ref() { - if !prefers_node_cli_for_openclaw_path(shell_platform, shell_binary) { - return Ok(Some(ResolvedOpenClawCommand::Binary { - binary_path: shell_binary.clone(), - })); - } - - if let Some(command) = runtime_candidates - .iter() - .filter(|candidate| runtime_candidate_matches_openclaw_path(candidate, shell_binary)) - .find_map(|candidate| { - resolve_openclaw_command_from_runtime_candidate_for(shell_platform, candidate) - }) - { - return Ok(Some(command)); - } - } - - if let Some(command) = runtime_candidates.iter().find_map(|candidate| { - resolve_openclaw_command_from_runtime_candidate_for(shell_platform, candidate) - }) { - return Ok(Some(command)); - } - - Ok(shell_binary.map(|binary_path| ResolvedOpenClawCommand::Binary { binary_path })) -} - -pub(crate) async fn read_openclaw_version_from_command( - command_spec: &ResolvedOpenClawCommand, -) -> Result, String> { - if let Some(version) = command_spec.fallback_version() { - return Ok(Some(version)); - } - - let output = command_spec - .build_command_with_args(["--version"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| format!("读取 OpenClaw 版本失败: {e}"))?; - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if stdout.is_empty() { - Ok(None) - } else { - Ok(Some(stdout)) - } -} - -pub(crate) async fn list_openclaw_runtime_candidates( -) -> Result, String> { - let node_candidates = - dedupe_paths(collect_standard_command_candidates_without_preference("node").await?); - let preferred_bin_dir = get_preferred_runtime_bin_dir().filter(|dir| dir.is_dir()); - let auto_selected_node = select_best_node_candidate(node_candidates.clone()).await?; - let active_bin_dir = preferred_bin_dir.clone().or_else(|| { - auto_selected_node - .as_ref() - .and_then(|path| path.parent().map(Path::to_path_buf)) - }); - - let mut runtimes = Vec::new(); - let mut seen_bin_dirs = HashSet::new(); - for node_path in node_candidates { - let Some(bin_dir) = node_path.parent().map(Path::to_path_buf) else { - continue; - }; - if !seen_bin_dirs.insert(bin_dir.clone()) { - continue; - } - - runtimes.push( - inspect_openclaw_runtime_candidate( - &bin_dir, - preferred_bin_dir.as_deref(), - active_bin_dir.as_deref(), - ) - .await, - ); - } - - runtimes.sort_by(compare_openclaw_runtime_candidates); - Ok(runtimes) -} - -pub(crate) async fn inspect_openclaw_runtime_candidate( - bin_dir: &Path, - preferred_bin_dir: Option<&Path>, - active_bin_dir: Option<&Path>, -) -> OpenClawRuntimeCandidate { - let node_path = find_command_in_bin_dir("node", bin_dir).unwrap_or_else(|| { - #[cfg(target_os = "windows")] - let node_name = "node.exe"; - #[cfg(not(target_os = "windows"))] - let node_name = "node"; - - bin_dir.join(node_name) - }); - - let npm_path = find_command_in_bin_dir("npm", bin_dir); - let npm_path_string = npm_path.as_ref().map(|path| path.display().to_string()); - let npm_global_prefix = match npm_path_string.as_deref() { - Some(path) => detect_npm_global_prefix(path).await, - None => None, - }; - let openclaw_path = resolve_runtime_openclaw_path(bin_dir, npm_global_prefix.as_deref()); - let openclaw_package_path = npm_global_prefix - .as_deref() - .and_then(find_installed_openclaw_package_details) - .map(|package| package.path.display().to_string()); - - OpenClawRuntimeCandidate { - id: bin_dir.display().to_string(), - source: infer_openclaw_runtime_source(bin_dir), - bin_dir: bin_dir.display().to_string(), - node_path: node_path.display().to_string(), - node_version: read_display_version_text(&node_path).await, - npm_path: npm_path_string, - npm_global_prefix, - openclaw_path: openclaw_path - .as_ref() - .map(|path| path.display().to_string()), - openclaw_version: match openclaw_path.as_deref() { - Some(path) => read_display_version_text(path).await, - None => None, - }, - openclaw_package_path, - is_active: active_bin_dir.is_some_and(|path| path == bin_dir), - is_preferred: preferred_bin_dir.is_some_and(|path| path == bin_dir), - } -} - -pub(crate) fn resolve_runtime_openclaw_path( - bin_dir: &Path, - npm_global_prefix: Option<&str>, -) -> Option { - find_command_in_bin_dir("openclaw", bin_dir).or_else(|| { - npm_global_prefix.and_then(|prefix| { - select_preferred_path_candidate(find_all_commands_in_paths( - "openclaw", - &npm_global_command_dirs(prefix), - )) - }) - }) -} - -pub(crate) async fn read_display_version_text(binary_path: &Path) -> Option { - let path = binary_path.to_str()?; - let version_text = read_command_version_text(path, &["--version"]).await.ok()?; - let version_text = version_text.trim(); - if version_text.is_empty() { - return None; - } - - Some( - parse_semver_from_text(version_text) - .map(format_semver) - .unwrap_or_else(|| version_text.to_string()), - ) -} - -pub(crate) fn infer_openclaw_runtime_source(bin_dir: &Path) -> String { - let normalized = bin_dir - .display() - .to_string() - .replace('\\', "/") - .to_lowercase(); - - if normalized.contains("/.nvm/") { - return "nvm".to_string(); - } - if normalized.contains("/phpwebstudy/") { - return "PhpWebStudy".to_string(); - } - if normalized.contains("/.volta/") { - return "Volta".to_string(); - } - if normalized.contains("/.fnm/") { - return "fnm".to_string(); - } - if normalized.contains("/.asdf/") { - return "asdf".to_string(); - } - if normalized.contains("/mise/") { - return "mise".to_string(); - } - if normalized.contains("/opt/homebrew/") { - return "Homebrew".to_string(); - } - if normalized.ends_with("/usr/local/bin") - || normalized.ends_with("/usr/bin") - || normalized.ends_with("/bin") - || normalized.contains("/program files/nodejs") - || normalized.contains("/program files (x86)/nodejs") - { - return "系统".to_string(); - } - - "PATH".to_string() -} - -pub(crate) fn compare_openclaw_runtime_candidates( - left: &OpenClawRuntimeCandidate, - right: &OpenClawRuntimeCandidate, -) -> Ordering { - right - .is_active - .cmp(&left.is_active) - .then_with(|| right.is_preferred.cmp(&left.is_preferred)) - .then_with(|| { - right - .openclaw_path - .is_some() - .cmp(&left.openclaw_path.is_some()) - }) - .then_with(|| { - compare_optional_semver_desc( - left.node_version.as_deref(), - right.node_version.as_deref(), - ) - }) - .then_with(|| left.bin_dir.cmp(&right.bin_dir)) -} - -pub(crate) fn compare_optional_semver_desc(left: Option<&str>, right: Option<&str>) -> Ordering { - match ( - left.and_then(parse_semver_from_text), - right.and_then(parse_semver_from_text), - ) { - (Some(left), Some(right)) => right.cmp(&left), - (Some(_), None) => Ordering::Less, - (None, Some(_)) => Ordering::Greater, - (None, None) => Ordering::Equal, - } -} - -pub(crate) async fn select_best_node_candidate( - candidates: Vec, -) -> Result, String> { - let mut versioned = Vec::with_capacity(candidates.len()); - for candidate in candidates { - let openclaw_signal = match candidate.parent() { - Some(bin_dir) => inspect_node_runtime_openclaw_signal(bin_dir).await, - None => 0, - }; - let version = read_binary_semver(&candidate).await; - versioned.push((candidate, openclaw_signal, version)); - } - Ok(select_best_node_runtime_candidate(versioned)) -} - -pub(crate) async fn select_node_runtime_candidate( - candidates: Vec, -) -> Result, String> { - let mut runtime_ranked = Vec::with_capacity(candidates.len()); - for candidate in &candidates { - let openclaw_signal = match candidate.parent() { - Some(bin_dir) => inspect_node_runtime_openclaw_signal(bin_dir).await, - None => 0, - }; - let version = match sibling_node_path(candidate) { - Some(node_path) => read_binary_semver(&node_path).await, - None => None, - }; - runtime_ranked.push((candidate.clone(), openclaw_signal, version)); - } - - if let Some(candidate) = select_best_node_runtime_candidate(runtime_ranked.clone()) { - let candidate_signal = runtime_ranked - .iter() - .find(|(path, _, _)| path == &candidate) - .map(|(_, signal, _)| *signal) - .unwrap_or(0); - if candidate_signal > 0 { - return Ok(Some(candidate)); - } - } - - let preferred_node = - select_best_node_candidate(find_all_commands_in_known_locations("node")).await?; - if let Some(preferred_bin_dir) = preferred_node.as_deref().and_then(Path::parent) { - if let Some(candidate) = select_preferred_path_candidate( - candidates - .iter() - .filter(|candidate| candidate.parent() == Some(preferred_bin_dir)) - .cloned() - .collect(), - ) { - return Ok(Some(candidate)); - } - } - - Ok( - select_best_node_runtime_candidate(runtime_ranked) - .or_else(|| candidates.into_iter().next()), - ) -} - -pub(crate) async fn inspect_node_runtime_openclaw_signal(bin_dir: &Path) -> u8 { - if find_command_in_bin_dir("openclaw", bin_dir).is_some() { - return 3; - } - - let Some(npm_path) = - find_command_in_bin_dir("npm", bin_dir).and_then(|path| path.to_str().map(str::to_string)) - else { - return 0; - }; - - let Some(prefix) = detect_npm_global_prefix(&npm_path).await else { - return 0; - }; - - if select_preferred_path_candidate(find_all_commands_in_paths( - "openclaw", - &npm_global_command_dirs(&prefix), - )) - .is_some() - { - return 3; - } - - if find_installed_openclaw_package_details(&prefix).is_some() { - return 2; - } - - 0 -} - -pub(crate) fn select_best_node_runtime_candidate( - candidates: Vec<(PathBuf, u8, Option<(u64, u64, u64)>)>, -) -> Option { - candidates - .into_iter() - .max_by( - |(left_path, left_signal, left_version), (right_path, right_signal, right_version)| { - left_signal - .cmp(right_signal) - .then_with(|| left_version.cmp(right_version)) - .then_with(|| { - let preferred = select_preferred_path_candidate(vec![ - left_path.clone(), - right_path.clone(), - ]); - match preferred.as_ref() { - Some(path) if path == left_path => std::cmp::Ordering::Greater, - Some(path) if path == right_path => std::cmp::Ordering::Less, - _ => std::cmp::Ordering::Equal, - } - }) - }, - ) - .map(|(path, _, _)| path) -} - -pub(crate) fn sibling_node_path(command_path: &Path) -> Option { - #[cfg(target_os = "windows")] - let node_name = "node.exe"; - - #[cfg(not(target_os = "windows"))] - let node_name = "node"; - - let node_path = command_path.parent()?.join(node_name); - node_path.is_file().then_some(node_path) -} - -pub(crate) async fn read_binary_semver(path: &Path) -> Option<(u64, u64, u64)> { - let mut command = Command::new(path); - apply_windows_no_window(&mut command); - let output = command - .arg("--version") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .ok()?; - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - parse_semver(stdout.trim()).or_else(|| parse_semver(stderr.trim())) -} - -#[cfg_attr(not(test), allow(dead_code))] -pub(crate) fn select_best_semver_candidate( - candidates: Vec<(PathBuf, Option<(u64, u64, u64)>)>, -) -> Option { - core_select_best_semver_candidate(candidates, NODE_MIN_VERSION) -} - -pub(crate) fn select_preferred_path_candidate(candidates: Vec) -> Option { - core_select_preferred_path_candidate(candidates) -} diff --git a/src-tauri/src/services/openclaw_service/tests.rs b/src-tauri/src/services/openclaw_service/tests.rs deleted file mode 100644 index 644db8adb..000000000 --- a/src-tauri/src/services/openclaw_service/tests.rs +++ /dev/null @@ -1,1064 +0,0 @@ -use super::{ - apply_gateway_runtime_defaults, build_environment_status, build_openclaw_cleanup_command, - build_openclaw_install_command, build_openclaw_pnpm_install_command, - build_winget_install_command, command_bin_dir_for, determine_api_type, - extract_gateway_auth_token, find_installed_openclaw_package, - format_gateway_start_failure_message, format_openclaw_update_failure_message, - format_provider_base_url, gateway_start_args, has_api_version, - infer_openclaw_package_name_from_path, npm_global_command_dirs_for, - npm_global_node_modules_dirs_for, package_registry_for_package_spec, parse_semver_from_text, - resolve_openclaw_cli_entry_from_package_manifest, - resolve_openclaw_command_from_runtime_candidate, - resolve_openclaw_command_from_runtime_candidate_for, resolve_windows_dependency_install_plan, - runtime_candidate_matches_install_root, sanitize_runtime_config, select_best_git_candidate, - select_best_semver_candidate, select_gateway_start_failure_detail, - select_openclaw_update_failure_detail, select_preferred_path_candidate, - shell_command_escape_for, shell_command_invocation_prefix_for, shell_npm_prefix_assignment_for, - shell_path_assignment_for, trim_trailing_slash, windows_dependency_action_result, - windows_dependency_setup_message, windows_git_install_dir_variants, - windows_install_block_result, windows_manual_install_message, DependencyKind, DependencyStatus, - EnvironmentDiagnostics, OpenClawRuntimeCandidate, ResolvedOpenClawCommand, ShellPlatform, - WindowsDependencyInstallPlan, NPM_MIRROR_CN, OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE, -}; -use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup}; -use chrono::Utc; -use serde_json::{json, Value}; -use std::ffi::OsString; -use std::fs; -use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; - -fn build_provider(provider_type: ApiProviderType, api_host: &str) -> ApiKeyProvider { - ApiKeyProvider { - id: "provider-1".to_string(), - name: "Provider 1".to_string(), - provider_type, - api_host: api_host.to_string(), - is_system: false, - group: ProviderGroup::Custom, - enabled: true, - sort_order: 0, - api_version: None, - project: None, - location: None, - region: None, - prompt_cache_mode: None, - custom_models: Vec::new(), - created_at: Utc::now(), - updated_at: Utc::now(), - } -} - -fn build_unique_temp_dir(prefix: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("系统时间应晚于 Unix epoch") - .as_nanos(); - - std::env::temp_dir().join(format!( - "lime-openclaw-{prefix}-{}-{nanos}", - std::process::id() - )) -} - -#[test] -fn trims_trailing_slash() { - assert_eq!( - trim_trailing_slash("https://api.openai.com/"), - "https://api.openai.com" - ); -} - -#[test] -fn detects_version_segment() { - assert!(has_api_version("https://api.openai.com/v1")); - assert!(!has_api_version("https://api.openai.com")); -} - -#[test] -fn maps_api_type_correctly() { - assert_eq!( - determine_api_type(ApiProviderType::Openai).unwrap(), - "openai-completions" - ); - assert_eq!( - determine_api_type(ApiProviderType::OpenaiResponse).unwrap(), - "openai-responses" - ); - assert_eq!( - determine_api_type(ApiProviderType::Anthropic).unwrap(), - "anthropic-messages" - ); -} - -#[test] -fn formats_openai_url() { - let provider = build_provider(ApiProviderType::Openai, "https://api.openai.com"); - assert_eq!( - format_provider_base_url(&provider).unwrap(), - "https://api.openai.com/v1" - ); -} - -#[test] -fn keeps_existing_version_url() { - let provider = build_provider(ApiProviderType::Openai, "https://example.com/v2"); - assert_eq!( - format_provider_base_url(&provider).unwrap(), - "https://example.com/v2" - ); -} - -#[test] -fn formats_gemini_url() { - let provider = build_provider( - ApiProviderType::Gemini, - "https://generativelanguage.googleapis.com", - ); - assert_eq!( - format_provider_base_url(&provider).unwrap(), - "https://generativelanguage.googleapis.com/v1beta/openai" - ); -} - -#[test] -fn formats_gateway_url() { - let provider = build_provider( - ApiProviderType::Gateway, - "https://gateway.example.com/v1/ai", - ); - assert_eq!( - format_provider_base_url(&provider).unwrap(), - "https://gateway.example.com/v1" - ); -} - -#[test] -fn rejects_unsupported_provider_types() { - let provider = build_provider(ApiProviderType::AzureOpenai, "https://example.com"); - assert!(format_provider_base_url(&provider).is_err()); -} - -#[test] -fn extracts_gateway_auth_token_from_config() { - let config = json!({ - "gateway": { - "auth": { - "token": "lime-token" - } - } - }); - - assert_eq!( - extract_gateway_auth_token(&config).as_deref(), - Some("lime-token") - ); -} - -#[test] -fn ignores_empty_gateway_auth_token() { - let config = json!({ - "gateway": { - "auth": { - "token": " " - } - } - }); - - assert_eq!(extract_gateway_auth_token(&config), None); -} - -#[test] -fn applies_gateway_runtime_defaults_for_current_openclaw() { - let mut config = json!({}); - - apply_gateway_runtime_defaults(&mut config, 18790, "lime-token"); - - assert_eq!( - config.pointer("/gateway/mode").and_then(Value::as_str), - Some("local") - ); - assert_eq!( - config.pointer("/gateway/bind").and_then(Value::as_str), - Some("loopback") - ); - assert_eq!( - config.pointer("/gateway/auth/mode").and_then(Value::as_str), - Some("token") - ); - assert_eq!( - config - .pointer("/gateway/auth/token") - .and_then(Value::as_str), - Some("lime-token") - ); - assert_eq!( - config - .pointer("/gateway/remote/token") - .and_then(Value::as_str), - Some("lime-token") - ); - assert_eq!( - config.pointer("/gateway/port").and_then(Value::as_u64), - Some(18_790) - ); -} - -#[test] -fn gateway_start_args_include_new_runtime_guards() { - assert_eq!( - gateway_start_args(18790, "lime-token"), - vec![ - "gateway", - "--allow-unconfigured", - "--bind", - "loopback", - "--auth", - "token", - "--token", - "lime-token", - "--port", - "18790", - ] - ); -} - -#[test] -fn parses_lsof_listener_pid_output() { - assert_eq!( - super::parse_lsof_listener_pids("1201\n1202\nbad\n1201\n"), - vec![1201, 1202] - ); -} - -#[test] -fn parses_windows_netstat_listener_pid_output() { - let output = "\ - TCP 127.0.0.1:18790 0.0.0.0:0 LISTENING 31234\n\ - TCP [::]:18790 [::]:0 LISTENING 31235\n\ - TCP 127.0.0.1:18791 0.0.0.0:0 LISTENING 39999\n"; - - assert_eq!( - super::parse_windows_netstat_listener_pids(output, 18790), - vec![31234, 31235] - ); -} - -#[test] -fn detects_openclaw_process_from_node_command_line() { - let args = vec![ - OsString::from("/Users/demo/.nvm/versions/node/v23.4.0/bin/.openclaw-a1b2c3/openclaw"), - OsString::from("gateway"), - OsString::from("--port"), - OsString::from("18790"), - ]; - let node_path = PathBuf::from("/Users/demo/.nvm/versions/node/v23.4.0/bin/node"); - - assert!(super::process_looks_like_openclaw_process( - "node", - Some(node_path.as_path()), - &args, - )); -} - -#[test] -fn ignores_unrelated_listener_process() { - let args = vec![ - OsString::from("/usr/local/bin/python3"), - OsString::from("-m"), - OsString::from("http.server"), - OsString::from("18790"), - ]; - let python_path = PathBuf::from("/usr/local/bin/python3"); - - assert!(!super::process_looks_like_openclaw_process( - "python3", - Some(python_path.as_path()), - &args, - )); -} - -#[test] -fn formats_gateway_start_failure_for_missing_config() { - assert_eq!( - format_gateway_start_failure_message(Some( - "Missing config. Run `openclaw setup` or set gateway.mode=local." - )), - "Gateway 启动失败:OpenClaw 本地网关配置缺失,已自动补齐默认配置,请重试。" - ); -} - -#[test] -fn formats_gateway_start_failure_for_loopback_bind_error() { - assert_eq!( - format_gateway_start_failure_message(Some( - "gateway bind=loopback resolved to non-loopback host 0.0.0.0" - )), - "Gateway 启动失败:当前环境无法绑定到本地回环地址 127.0.0.1,请检查本机网络或代理配置。" - ); -} - -#[test] -fn sanitizes_null_context_window_from_runtime_config() { - let mut config = json!({ - "models": { - "providers": { - "lime-openai": { - "models": [ - { - "id": "gpt-5", - "name": "GPT-5", - "contextWindow": null - }, - { - "id": "gpt-5-mini", - "name": "GPT-5 mini", - "contextWindow": 400000 - } - ] - } - } - } - }); - - sanitize_runtime_config(&mut config); - - assert!(config - .pointer("/models/providers/lime-openai/models/0/contextWindow") - .is_none()); - assert_eq!( - config - .pointer("/models/providers/lime-openai/models/1/contextWindow") - .and_then(Value::as_u64), - Some(400_000) - ); -} - -#[test] -fn selects_specific_gateway_failure_detail_over_doctor_hint() { - let lines = vec![ - "Config invalid".to_string(), - "Run: openclaw doctor --fix".to_string(), - "Invalid config at /Users/demo/.openclaw/openclaw.lime.json:\\n- models.providers.lime-openai.models.0.contextWindow: Invalid input: expected number, received null".to_string(), - ]; - - assert_eq!( - select_gateway_start_failure_detail(&lines), - Some( - "Invalid config at /Users/demo/.openclaw/openclaw.lime.json:\\n- models.providers.lime-openai.models.0.contextWindow: Invalid input: expected number, received null" - ) - ); -} - -#[test] -fn formats_gateway_start_failure_for_invalid_context_window_config() { - assert_eq!( - format_gateway_start_failure_message(Some( - "Invalid config at /Users/demo/.openclaw/openclaw.lime.json:\\n- models.providers.lime-openai.models.0.contextWindow: Invalid input: expected number, received null" - )), - "Gateway 启动失败:当前 OpenClaw 配置包含空的 contextWindow 字段。Lime 已修正后续配置写入,请重新启动;如仍失败,请重新同步模型配置。" - ); -} - -#[test] -fn selects_update_failure_reason_from_json_payload() { - let payload = json!({ - "status": "error", - "reason": "not-openclaw-root", - "root": "/Users/demo/.nvm" - }); - - assert_eq!( - select_openclaw_update_failure_detail(Some(&payload), &[], &[]), - Some("not-openclaw-root (/Users/demo/.nvm)".to_string()) - ); -} - -#[test] -fn formats_openclaw_update_failure_for_invalid_root() { - assert_eq!( - format_openclaw_update_failure_message(Some( - "not-openclaw-root (/Users/demo/.nvm)" - )), - "OpenClaw 升级失败:未在 OpenClaw 安装根目录执行更新。Lime 会优先切换到安装目录;如仍失败,请重新检测安装状态后重试。" - ); -} - -#[test] -fn formats_openclaw_update_failure_for_node_version_requirement() { - assert_eq!( - format_openclaw_update_failure_message(Some( - "openclaw: Node.js v22.12+ is required (current: v18.20.2)." - )), - "OpenClaw 升级失败:当前用于执行 openclaw 的 Node.js 版本过低,需要 22.12.0+。请切换到满足要求的 Node.js 后重试。" - ); -} - -#[test] -fn parses_semver_from_git_version_text() { - assert_eq!( - parse_semver_from_text("git version 2.39.5 (Apple Git-154)"), - Some((2, 39, 5)) - ); -} - -#[test] -fn environment_status_prioritizes_missing_node() { - let env = build_environment_status( - DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "missing node".to_string(), - auto_install_supported: true, - }, - DependencyStatus { - status: "ok".to_string(), - version: Some("2.43.0".to_string()), - path: Some("/usr/bin/git".to_string()), - message: "git ok".to_string(), - auto_install_supported: true, - }, - DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "openclaw missing".to_string(), - auto_install_supported: false, - }, - EnvironmentDiagnostics::default(), - ); - - assert_eq!(env.recommended_action, "install_node"); - assert_eq!(env.openclaw.auto_install_supported, false); -} - -#[test] -fn environment_status_uses_reload_summary_when_openclaw_command_not_ready() { - let env = build_environment_status( - DependencyStatus { - status: "ok".to_string(), - version: Some("22.12.0".to_string()), - path: Some("/usr/local/bin/node".to_string()), - message: "node ok".to_string(), - auto_install_supported: true, - }, - DependencyStatus { - status: "ok".to_string(), - version: Some("2.44.0".to_string()), - path: Some("/usr/bin/git".to_string()), - message: "git ok".to_string(), - auto_install_supported: true, - }, - DependencyStatus { - status: "needs_reload".to_string(), - version: Some("0.3.0".to_string()), - path: Some("/mock/prefix".to_string()), - message: "reload openclaw".to_string(), - auto_install_supported: false, - }, - EnvironmentDiagnostics::default(), - ); - - assert_eq!(env.recommended_action, "refresh_openclaw_env"); - assert!(env.summary.contains("重新检测")); -} - -#[test] -fn semver_selection_prefers_windows_launcher_over_bare_file_when_versions_equal() { - let preferred = select_best_semver_candidate(vec![ - (PathBuf::from(r"C:\nvm4w\nodejs\openclaw"), Some((23, 1, 0))), - ( - PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"), - Some((23, 1, 0)), - ), - ]); - - assert_eq!( - preferred, - Some(PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd")) - ); -} - -#[test] -fn windows_command_bin_dir_supports_backslash_paths() { - assert_eq!( - command_bin_dir_for(ShellPlatform::Windows, r"C:\Program Files\nodejs\npm.cmd"), - Some(r"C:\Program Files\nodejs".to_string()) - ); -} - -#[test] -fn windows_shell_command_escape_keeps_cmd_compatible_quotes() { - assert_eq!( - shell_command_escape_for(ShellPlatform::Windows, r#"C:\Program Files\nodejs\npm.cmd"#), - r#""C:\Program Files\nodejs\npm.cmd""# - ); - assert_eq!( - shell_command_escape_for(ShellPlatform::Windows, "C:\\demo\\na\"me\\npm.cmd"), - r#""C:\demo\na""me\npm.cmd""# - ); -} - -#[test] -fn windows_shell_npm_prefix_assignment_uses_set_syntax() { - assert_eq!( - shell_npm_prefix_assignment_for( - ShellPlatform::Windows, - r"C:\Users\demo\AppData\Roaming\npm" - ), - r#"set "NPM_CONFIG_PREFIX=C:\Users\demo\AppData\Roaming\npm" && "# - ); -} - -#[test] -fn windows_shell_path_assignment_prepends_binary_directory() { - assert_eq!( - shell_path_assignment_for(ShellPlatform::Windows, r"C:\Program Files\nodejs\npm.cmd"), - r#"set "PATH=C:\Program Files\nodejs;%PATH%" && "# - ); -} - -#[test] -fn windows_cmd_scripts_use_call_invocation_prefix() { - assert_eq!( - shell_command_invocation_prefix_for( - ShellPlatform::Windows, - r"C:\Program Files\nodejs\npm.cmd" - ), - "call " - ); - assert!(shell_command_invocation_prefix_for( - ShellPlatform::Windows, - r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe" - ) - .is_empty()); -} - -#[test] -fn windows_cleanup_command_uses_cmd_compatible_syntax_without_true_fallback() { - let command = build_openclaw_cleanup_command( - ShellPlatform::Windows, - r"C:\Program Files\nodejs\npm.cmd", - Some(r"C:\Users\demo\AppData\Roaming\npm"), - ); - - assert_eq!( - command, - concat!( - "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", - "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", - "call \"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh" - ) - ); - assert!(!command.contains("|| true")); -} - -#[test] -fn windows_install_command_adds_registry_when_using_china_package() { - let command = build_openclaw_install_command( - ShellPlatform::Windows, - r"C:\Program Files\nodejs\npm.cmd", - Some(r"C:\Users\demo\AppData\Roaming\npm"), - OPENCLAW_CN_PACKAGE, - Some(NPM_MIRROR_CN), - ); - - assert_eq!( - command, - concat!( - "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", - "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", - "call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ", - "--registry=https://registry.npmmirror.com" - ) - ); -} - -#[test] -fn windows_install_command_omits_registry_for_default_package() { - let command = build_openclaw_install_command( - ShellPlatform::Windows, - r"C:\Program Files\nodejs\npm.cmd", - None, - OPENCLAW_DEFAULT_PACKAGE, - None, - ); - - assert_eq!( - command, - concat!( - "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", - "call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest" - ) - ); - assert!(!command.contains("--registry=")); -} - -#[test] -fn windows_pnpm_install_command_uses_global_add_syntax() { - let command = build_openclaw_pnpm_install_command( - ShellPlatform::Windows, - r"C:\Users\demo\AppData\Local\pnpm\pnpm.cmd", - "@qingchencloud/openclaw-zh@latest", - Some(NPM_MIRROR_CN), - ); - - assert_eq!( - command, - concat!( - "set \"PATH=C:\\Users\\demo\\AppData\\Local\\pnpm;%PATH%\" && ", - "call \"C:\\Users\\demo\\AppData\\Local\\pnpm\\pnpm.cmd\" add -g \"@qingchencloud/openclaw-zh@latest\" ", - "--registry=\"https://registry.npmmirror.com\"" - ) - ); -} - -#[test] -fn infers_openclaw_package_name_from_manifest_path() { - assert_eq!( - infer_openclaw_package_name_from_path( - PathBuf::from( - "/Users/demo/.nvm/versions/node/v23.4.0/lib/node_modules/openclaw/package.json", - ) - .as_path() - ), - Some("openclaw") - ); - assert_eq!( - infer_openclaw_package_name_from_path(PathBuf::from( - "/Users/demo/.nvm/versions/node/v23.4.0/lib/node_modules/@qingchencloud/openclaw-zh/package.json", - ) - .as_path()), - Some("@qingchencloud/openclaw-zh") - ); -} - -#[test] -fn china_package_upgrade_uses_npmmirror_registry() { - assert_eq!( - package_registry_for_package_spec("@qingchencloud/openclaw-zh@latest"), - Some(NPM_MIRROR_CN) - ); - assert_eq!(package_registry_for_package_spec("openclaw@latest"), None); -} - -#[test] -fn runtime_candidate_prefers_install_root_match_over_current_binary_hint() { - let candidate = OpenClawRuntimeCandidate { - id: "/Users/demo/.nvm/versions/node/v23.4.0/bin".to_string(), - source: "nvm".to_string(), - bin_dir: "/Users/demo/.nvm/versions/node/v23.4.0/bin".to_string(), - node_path: "/Users/demo/.nvm/versions/node/v23.4.0/bin/node".to_string(), - node_version: Some("23.4.0".to_string()), - npm_path: Some("/Users/demo/.nvm/versions/node/v23.4.0/bin/npm".to_string()), - npm_global_prefix: Some("/Users/demo/.nvm/versions/node/v23.4.0".to_string()), - openclaw_path: Some("/Users/demo/.nvm/versions/node/v23.4.0/bin/openclaw".to_string()), - openclaw_version: Some("2026.3.8".to_string()), - openclaw_package_path: Some( - "/Users/demo/.nvm/versions/node/v23.4.0/lib/node_modules/@qingchencloud/openclaw-zh/package.json" - .to_string(), - ), - is_active: false, - is_preferred: false, - }; - - assert!(runtime_candidate_matches_install_root( - &candidate, - PathBuf::from("/Users/demo/.nvm").as_path() - )); - assert!(!runtime_candidate_matches_install_root( - &candidate, - PathBuf::from("/Users/demo/Library/PhpWebStudy").as_path() - )); -} - -#[test] -fn preferred_path_candidate_prioritizes_windows_executable_extensions() { - let preferred = select_preferred_path_candidate(vec![ - PathBuf::from(r"C:\nvm4w\nodejs\openclaw"), - PathBuf::from(r"C:\nvm4w\nodejs\openclaw.bat"), - PathBuf::from(r"C:\nvm4w\nodejs\openclaw.cmd"), - PathBuf::from(r"C:\nvm4w\nodejs\openclaw.exe"), - ]); - - assert_eq!( - preferred, - Some(PathBuf::from(r"C:\nvm4w\nodejs\openclaw.exe")) - ); -} - -#[test] -fn git_candidate_selection_prefers_executable_extension() { - let preferred = select_best_git_candidate(vec![ - PathBuf::from(r"C:\Program Files\Git\cmd\git.cmd"), - PathBuf::from(r"C:\Program Files\Git\cmd\git.exe"), - ]); - - assert_eq!( - preferred, - Some(PathBuf::from(r"C:\Program Files\Git\cmd\git.exe")) - ); -} - -#[test] -fn windows_git_install_dir_variants_cover_common_layouts() { - let git_root = build_unique_temp_dir("git-layout-root"); - let cmd_dir = git_root.join("cmd"); - let bin_dir = git_root.join("bin"); - fs::create_dir_all(&cmd_dir).unwrap(); - fs::create_dir_all(&bin_dir).unwrap(); - fs::write(cmd_dir.join("git.exe"), "").unwrap(); - fs::write(bin_dir.join("git.cmd"), "").unwrap(); - - let matches = super::find_all_commands_in_paths_for( - ShellPlatform::Windows, - "git", - &windows_git_install_dir_variants(git_root.clone()), - ); - - let _ = fs::remove_dir_all(&git_root); - - assert_eq!( - matches, - vec![cmd_dir.join("git.exe"), bin_dir.join("git.cmd")] - ); -} - -#[test] -fn windows_npm_global_command_dirs_use_prefix_root() { - assert_eq!( - npm_global_command_dirs_for(ShellPlatform::Windows, r"C:\Users\demo\AppData\Roaming\npm"), - vec![PathBuf::from(r"C:\Users\demo\AppData\Roaming\npm")] - ); -} - -#[test] -fn unix_npm_global_command_dirs_include_bin_directory() { - assert_eq!( - npm_global_command_dirs_for(ShellPlatform::Unix, "/Users/demo/.npm-global"), - vec![ - PathBuf::from("/Users/demo/.npm-global/bin"), - PathBuf::from("/Users/demo/.npm-global") - ] - ); -} - -#[test] -fn windows_npm_global_node_modules_dirs_use_prefix_node_modules() { - assert_eq!( - npm_global_node_modules_dirs_for( - ShellPlatform::Windows, - r"C:\Users\demo\AppData\Roaming\npm" - ), - vec![PathBuf::from(r"C:\Users\demo\AppData\Roaming\npm").join("node_modules")] - ); -} - -#[test] -fn finds_openclaw_package_from_global_npm_prefix() { - let temp_dir = std::env::temp_dir().join(format!("lime-openclaw-test-{}", std::process::id())); - let package_dir = temp_dir.join("node_modules").join("openclaw"); - fs::create_dir_all(&package_dir).unwrap(); - fs::write( - package_dir.join("package.json"), - r#"{"name":"openclaw","version":"0.4.1"}"#, - ) - .unwrap(); - - let detected = find_installed_openclaw_package(temp_dir.to_str().unwrap()); - - fs::remove_dir_all(&temp_dir).unwrap(); - - assert_eq!(detected, Some(("openclaw", Some("0.4.1".to_string())))); -} - -#[test] -fn resolves_openclaw_cli_entry_from_dist_index_when_bin_target_missing() { - let temp_dir = build_unique_temp_dir("cli-entry"); - let package_dir = temp_dir - .join("node_modules") - .join("@qingchencloud/openclaw-zh"); - let dist_dir = package_dir.join("dist"); - fs::create_dir_all(&dist_dir).unwrap(); - fs::write( - package_dir.join("package.json"), - r#"{ - "name":"@qingchencloud/openclaw-zh", - "version":"2026.3.13-zh.1", - "bin":{"openclaw":"openclaw.mjs"} - }"#, - ) - .unwrap(); - fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap(); - - let resolved = - resolve_openclaw_cli_entry_from_package_manifest(&package_dir.join("package.json")); - - let _ = fs::remove_dir_all(&temp_dir); - - assert_eq!(resolved, Some(package_dir.join("dist").join("index.js"))); -} - -#[test] -fn resolves_openclaw_command_from_runtime_candidate_as_node_cli() { - let temp_dir = build_unique_temp_dir("runtime-candidate"); - let node_bin_dir = temp_dir.join("bin"); - let package_dir = temp_dir - .join("node_modules") - .join("@qingchencloud/openclaw-zh"); - let dist_dir = package_dir.join("dist"); - fs::create_dir_all(&node_bin_dir).unwrap(); - fs::create_dir_all(&dist_dir).unwrap(); - - let node_path = node_bin_dir.join("node"); - fs::write(&node_path, "").unwrap(); - fs::write( - package_dir.join("package.json"), - r#"{ - "name":"@qingchencloud/openclaw-zh", - "version":"2026.3.13-zh.1", - "bin":{"openclaw":"openclaw.mjs"} - }"#, - ) - .unwrap(); - fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap(); - - let candidate = OpenClawRuntimeCandidate { - id: temp_dir.display().to_string(), - source: "nvm".to_string(), - bin_dir: node_bin_dir.display().to_string(), - node_path: node_path.display().to_string(), - node_version: Some("23.4.0".to_string()), - npm_path: None, - npm_global_prefix: None, - openclaw_path: None, - openclaw_version: Some("2026.3.13-zh.1".to_string()), - openclaw_package_path: Some(package_dir.join("package.json").display().to_string()), - is_active: true, - is_preferred: true, - }; - - let resolved = resolve_openclaw_command_from_runtime_candidate(&candidate); - - let _ = fs::remove_dir_all(&temp_dir); - - assert_eq!( - resolved, - Some(ResolvedOpenClawCommand::NodeCli { - node_path, - cli_path: package_dir.join("dist").join("index.js"), - package_version: Some("2026.3.13-zh.1".to_string()), - }) - ); -} - -#[test] -fn windows_runtime_candidate_prefers_node_cli_over_cmd_shim() { - let temp_dir = build_unique_temp_dir("runtime-candidate-windows-shim"); - let node_bin_dir = temp_dir.join("nodejs"); - let package_dir = temp_dir - .join("node_modules") - .join("@qingchencloud") - .join("openclaw-zh"); - let dist_dir = package_dir.join("dist"); - fs::create_dir_all(&node_bin_dir).unwrap(); - fs::create_dir_all(&dist_dir).unwrap(); - - let node_path = node_bin_dir.join("node.exe"); - let openclaw_cmd = node_bin_dir.join("openclaw.cmd"); - fs::write(&node_path, "").unwrap(); - fs::write(&openclaw_cmd, "@echo off").unwrap(); - fs::write( - package_dir.join("package.json"), - r#"{ - "name":"@qingchencloud/openclaw-zh", - "version":"2026.3.13-zh.1", - "bin":{"openclaw":"openclaw.mjs"} - }"#, - ) - .unwrap(); - fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap(); - - let candidate = OpenClawRuntimeCandidate { - id: temp_dir.display().to_string(), - source: "system".to_string(), - bin_dir: node_bin_dir.display().to_string(), - node_path: node_path.display().to_string(), - node_version: Some("23.4.0".to_string()), - npm_path: Some(node_bin_dir.join("npm.cmd").display().to_string()), - npm_global_prefix: Some(temp_dir.display().to_string()), - openclaw_path: Some(openclaw_cmd.display().to_string()), - openclaw_version: Some("2026.3.13-zh.1".to_string()), - openclaw_package_path: Some(package_dir.join("package.json").display().to_string()), - is_active: true, - is_preferred: true, - }; - - let resolved = - resolve_openclaw_command_from_runtime_candidate_for(ShellPlatform::Windows, &candidate); - - let _ = fs::remove_dir_all(&temp_dir); - - assert_eq!( - resolved, - Some(ResolvedOpenClawCommand::NodeCli { - node_path, - cli_path: package_dir.join("dist").join("index.js"), - package_version: Some("2026.3.13-zh.1".to_string()), - }) - ); -} - -#[test] -fn windows_node_prefers_winget_when_available() { - assert_eq!( - resolve_windows_dependency_install_plan(DependencyKind::Node, true), - WindowsDependencyInstallPlan::Winget { - package_id: "OpenJS.NodeJS.LTS" - } - ); -} - -#[test] -fn windows_node_falls_back_to_official_installer_without_winget() { - assert_eq!( - resolve_windows_dependency_install_plan(DependencyKind::Node, false), - WindowsDependencyInstallPlan::OfficialInstaller - ); -} - -#[test] -fn windows_git_prefers_winget_when_available() { - assert_eq!( - resolve_windows_dependency_install_plan(DependencyKind::Git, true), - WindowsDependencyInstallPlan::Winget { - package_id: "Git.Git" - } - ); -} - -#[test] -fn windows_git_requires_manual_download_without_winget() { - assert_eq!( - resolve_windows_dependency_install_plan(DependencyKind::Git, false), - WindowsDependencyInstallPlan::ManualDownload - ); - assert_eq!( - windows_manual_install_message(DependencyKind::Git), - "当前系统缺少 winget,暂时无法一键安装 Git,请点击“手动下载 Git”完成安装后重试。" - ); -} - -#[test] -fn windows_git_setup_message_points_to_manual_download() { - let message = windows_dependency_setup_message( - DependencyKind::Git, - &DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "未检测到 Git。".to_string(), - auto_install_supported: false, - }, - ); - - assert!(message.contains("git-scm.com")); - assert!(message.contains("加入 PATH")); -} - -#[test] -fn windows_node_setup_message_points_to_nodejs_download() { - let message = windows_dependency_setup_message( - DependencyKind::Node, - &DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "未检测到 Node.js,需要安装 22.12.0+。".to_string(), - auto_install_supported: false, - }, - ); - - assert!(message.contains("nodejs.org")); - assert!(message.contains("Node.js 22+")); -} - -#[test] -fn windows_dependency_action_result_returns_failure_message() { - let result = windows_dependency_action_result( - DependencyKind::Git, - &DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "未检测到 Git。".to_string(), - auto_install_supported: false, - }, - ); - - assert!(!result.success); - assert!(result.message.contains("git-scm.com")); -} - -#[test] -fn windows_install_block_result_prioritizes_node_before_git() { - let result = windows_install_block_result( - &DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "未检测到 Node.js,需要安装 22.12.0+。".to_string(), - auto_install_supported: false, - }, - &DependencyStatus { - status: "missing".to_string(), - version: None, - path: None, - message: "未检测到 Git。".to_string(), - auto_install_supported: false, - }, - ) - .expect("应返回 Windows 阻断结果"); - - assert!(!result.success); - assert!(result.message.contains("nodejs.org")); - assert!(!result.message.contains("git-scm.com")); -} - -#[test] -fn windows_install_block_result_returns_none_when_dependencies_ready() { - let result = windows_install_block_result( - &DependencyStatus { - status: "ok".to_string(), - version: Some("22.12.0".to_string()), - path: Some("C:\\Program Files\\nodejs\\node.exe".to_string()), - message: "Node.js 已就绪:22.12.0".to_string(), - auto_install_supported: false, - }, - &DependencyStatus { - status: "ok".to_string(), - version: Some("2.44.0".to_string()), - path: Some("C:\\Program Files\\Git\\cmd\\git.exe".to_string()), - message: "Git 已就绪:2.44.0".to_string(), - auto_install_supported: false, - }, - ); - - assert!(result.is_none()); -} - -#[test] -fn winget_install_command_uses_expected_windows_flags() { - assert_eq!( - build_winget_install_command( - r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe", - "OpenJS.NodeJS.LTS" - ), - concat!( - "set \"PATH=C:\\Users\\demo\\AppData\\Local\\Microsoft\\WindowsApps;%PATH%\" && ", - "\"C:\\Users\\demo\\AppData\\Local\\Microsoft\\WindowsApps\\winget.exe\" install ", - "--id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements" - ) - ); -} diff --git a/src-tauri/src/services/openclaw_service/types.rs b/src-tauri/src/services/openclaw_service/types.rs deleted file mode 100644 index a79e70d57..000000000 --- a/src-tauri/src/services/openclaw_service/types.rs +++ /dev/null @@ -1,299 +0,0 @@ -use super::*; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BinaryInstallStatus { - pub installed: bool, - pub path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BinaryAvailabilityStatus { - pub available: bool, - pub path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NodeCheckResult { - pub status: String, - pub version: Option, - pub path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ActionResult { - pub success: bool, - pub message: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DependencyStatus { - pub status: String, - pub version: Option, - pub path: Option, - pub message: String, - pub auto_install_supported: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct EnvironmentStatus { - pub node: DependencyStatus, - pub git: DependencyStatus, - pub openclaw: DependencyStatus, - pub recommended_action: String, - pub summary: String, - #[serde(default)] - pub diagnostics: EnvironmentDiagnostics, - #[serde(default)] - pub temp_artifacts: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct EnvironmentDiagnostics { - pub npm_path: Option, - pub npm_global_prefix: Option, - pub openclaw_package_path: Option, - #[serde(default)] - pub where_candidates: Vec, - #[serde(default)] - pub supplemental_search_dirs: Vec, - #[serde(default)] - pub supplemental_command_candidates: Vec, - #[serde(default)] - pub git_where_candidates: Vec, - #[serde(default)] - pub git_supplemental_search_dirs: Vec, - #[serde(default)] - pub git_supplemental_command_candidates: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CommandPreview { - pub title: String, - pub command: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayStatusInfo { - pub status: GatewayStatus, - pub port: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum GatewayStatus { - Stopped, - Starting, - Running, - Error, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HealthInfo { - pub status: String, - pub gateway_port: u16, - pub uptime: Option, - pub version: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateInfo { - pub has_update: bool, - pub current_version: Option, - pub latest_version: Option, - pub channel: Option, - pub install_kind: Option, - pub package_manager: Option, - pub message: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OpenClawRuntimeCandidate { - pub id: String, - pub source: String, - pub bin_dir: String, - pub node_path: String, - pub node_version: Option, - pub npm_path: Option, - pub npm_global_prefix: Option, - pub openclaw_path: Option, - pub openclaw_version: Option, - pub openclaw_package_path: Option, - pub is_active: bool, - pub is_preferred: bool, -} - -#[derive(Debug, Default, Clone)] -pub(crate) struct OpenClawUpdateExecutionContext { - pub(crate) root: Option, - pub(crate) install_kind: Option, - pub(crate) package_manager: Option, -} - -#[derive(Debug, Clone)] -pub(crate) struct OpenClawDirectUpgradePlan { - pub(crate) runtime_source: String, - pub(crate) runtime_bin_dir: PathBuf, - pub(crate) package_manager: String, - pub(crate) package_spec: String, - pub(crate) command_line: String, -} - -#[derive(Debug, Clone)] -pub(crate) struct OpenClawDirectUpgradeResult { - pub(crate) runtime_source: String, - pub(crate) runtime_bin_dir: PathBuf, - pub(crate) package_manager: String, - pub(crate) package_spec: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ResolvedOpenClawCommand { - Binary { - binary_path: PathBuf, - }, - NodeCli { - node_path: PathBuf, - cli_path: PathBuf, - package_version: Option, - }, -} - -impl ResolvedOpenClawCommand { - pub(crate) fn build_command_with_args(&self, args: I) -> Command - where - I: IntoIterator, - S: AsRef, - { - let command_path = self.command_path(); - let command_path_string = command_path.to_string_lossy().to_string(); - let mut command = Command::new(command_path); - apply_binary_runtime_path(&mut command, &command_path_string); - - if let Self::NodeCli { cli_path, .. } = self { - command.arg(cli_path); - } - - command.args(args); - - command - } - - pub(crate) fn command_path(&self) -> &Path { - match self { - Self::Binary { binary_path } => binary_path.as_path(), - Self::NodeCli { node_path, .. } => node_path.as_path(), - } - } - - pub(crate) fn install_path_display(&self) -> String { - match self { - Self::Binary { binary_path } => binary_path.display().to_string(), - Self::NodeCli { cli_path, .. } => cli_path.display().to_string(), - } - } - - pub(crate) fn invocation_display(&self) -> String { - match self { - Self::Binary { binary_path } => binary_path.display().to_string(), - Self::NodeCli { - node_path, - cli_path, - .. - } => { - format!("{} {}", node_path.display(), cli_path.display()) - } - } - } - - pub(crate) fn preview_invocation(&self) -> String { - match self { - Self::Binary { binary_path } => shell_escape(binary_path.to_string_lossy().as_ref()), - Self::NodeCli { - node_path, - cli_path, - .. - } => format!( - "{} {}", - shell_escape(node_path.to_string_lossy().as_ref()), - shell_escape(cli_path.to_string_lossy().as_ref()) - ), - } - } - - pub(crate) fn fallback_version(&self) -> Option { - match self { - Self::Binary { .. } => None, - Self::NodeCli { - package_version, .. - } => package_version.clone(), - } - } - - pub(crate) fn dedupe_key(&self) -> String { - match self { - Self::Binary { binary_path } => format!("binary:{}", binary_path.display()), - Self::NodeCli { - node_path, - cli_path, - .. - } => format!("node:{}:{}", node_path.display(), cli_path.display()), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ChannelInfo { - pub id: String, - pub name: String, - pub channel_type: String, - pub status: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InstallProgressEvent { - pub message: String, - pub level: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SyncModelEntry { - pub id: String, - pub name: String, - pub context_window: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum DependencyKind { - Node, - Git, -} - -impl DependencyKind { - pub(crate) fn label(self) -> &'static str { - match self { - Self::Node => "Node.js", - Self::Git => "Git", - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct InstallerAsset { - pub(crate) filename: String, - pub(crate) download_url: String, -} diff --git a/src-tauri/src/services/openclaw_service/update.rs b/src-tauri/src/services/openclaw_service/update.rs deleted file mode 100644 index a2e2069f2..000000000 --- a/src-tauri/src/services/openclaw_service/update.rs +++ /dev/null @@ -1,747 +0,0 @@ -use super::*; - -impl OpenClawService { - pub async fn check_update(&self) -> Result { - let Some(openclaw_command) = resolve_openclaw_command().await? else { - return Ok(UpdateInfo { - has_update: false, - current_version: None, - latest_version: None, - channel: None, - install_kind: None, - package_manager: None, - message: Some("未检测到 OpenClaw 可执行文件,请先安装。".to_string()), - }); - }; - - let current_version = self - .read_openclaw_version() - .await? - .and_then(|value| parse_openclaw_release_version(&value).or(Some(value))); - - let payload = match read_openclaw_update_status_payload(&openclaw_command).await { - Ok(payload) => payload, - Err(message) => { - return Ok(UpdateInfo { - has_update: false, - current_version, - latest_version: None, - channel: None, - install_kind: None, - package_manager: None, - message: Some(message), - }); - } - }; - - Ok(UpdateInfo { - has_update: payload - .pointer("/availability/available") - .and_then(Value::as_bool) - .unwrap_or(false), - current_version, - latest_version: payload - .pointer("/availability/latestVersion") - .and_then(Value::as_str) - .map(str::to_string), - channel: payload - .pointer("/channel/label") - .or_else(|| payload.pointer("/channel/value")) - .and_then(Value::as_str) - .map(str::to_string), - install_kind: payload - .pointer("/update/installKind") - .and_then(Value::as_str) - .map(str::to_string), - package_manager: payload - .pointer("/update/packageManager") - .and_then(Value::as_str) - .map(str::to_string), - message: payload - .pointer("/update/registry/error") - .and_then(Value::as_str) - .map(str::to_string), - }) - } - - pub async fn perform_update(&mut self, app: &AppHandle) -> Result { - emit_install_progress(app, "开始执行 OpenClaw 升级。", "info"); - - let Some(openclaw_command) = resolve_openclaw_command().await? else { - return Ok(ActionResult { - success: false, - message: "未检测到 OpenClaw 可执行文件,请先安装。".to_string(), - }); - }; - let current_runtime_bin_dir = openclaw_command - .command_path() - .parent() - .map(Path::to_path_buf); - - self.refresh_process_state().await?; - let gateway_was_running = self.gateway_status == GatewayStatus::Running; - if self.gateway_status == GatewayStatus::Running { - emit_install_progress( - app, - "升级前先停止 Gateway,避免占用正在运行的 OpenClaw。", - "info", - ); - let stop_result = self.stop_gateway(Some(app)).await?; - if !stop_result.success { - return Ok(stop_result); - } - } - - if let Some(current_version) = self - .read_openclaw_version() - .await - .ok() - .flatten() - .and_then(|value| parse_openclaw_release_version(&value).or(Some(value))) - { - emit_install_progress( - app, - &format!("当前版本 {current_version},开始执行升级命令。"), - "info", - ); - } - - let update_status_payload = - match read_openclaw_update_status_payload(&openclaw_command).await { - Ok(payload) => payload, - Err(message) => { - emit_install_progress(app, &message, "warn"); - match attempt_direct_openclaw_package_upgrade( - app, - current_runtime_bin_dir.as_deref(), - None, - None, - ) - .await - { - Ok(result) => { - set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone())); - emit_install_progress( - app, - &format!( - "已自动切换后续执行环境到 {}。", - result.runtime_bin_dir.display() - ), - "info", - ); - return self - .finalize_successful_openclaw_update( - app, - gateway_was_running, - Some(format!( - "OpenClaw 已通过 {} 的 {} 全局升级完成({})。", - result.runtime_source, - result.package_manager, - result.package_spec - )), - ) - .await; - } - Err(fallback_error) => { - emit_install_progress(app, &fallback_error, "error"); - return Ok(ActionResult { - success: false, - message, - }); - } - } - } - }; - let update_context = extract_openclaw_update_execution_context(&update_status_payload); - if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) { - emit_install_progress( - app, - &format!("已切换到 OpenClaw 安装根目录执行升级:{}", root.display()), - "info", - ); - } - if let Some(install_kind) = update_context.install_kind.as_deref() { - let package_manager = update_context - .package_manager - .as_deref() - .unwrap_or("默认包管理器"); - emit_install_progress( - app, - &format!("检测到安装方式:{install_kind}(包管理器:{package_manager})。"), - "info", - ); - } - - let mut command = openclaw_command.build_command_with_args(["update", "--yes", "--json"]); - if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) { - command.current_dir(root); - } - let output = command - .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| format!("执行 OpenClaw 升级失败: {e}"))?; - - let stdout_lines = command_output_lines(&output.stdout); - for line in &stdout_lines { - emit_install_progress(app, &line, classify_progress_level(&line, "info")); - } - let stderr_lines = command_output_lines(&output.stderr); - for line in &stderr_lines { - emit_install_progress(app, line, classify_progress_level(line, "warn")); - } - let stdout_payload = serde_json::from_slice::(&output.stdout).ok(); - - if !output.status.success() { - let failure_detail = select_openclaw_update_failure_detail( - stdout_payload.as_ref(), - &stderr_lines, - &stdout_lines, - ); - let message = format_openclaw_update_failure_message(failure_detail.as_deref()); - emit_install_progress(app, &message, "warn"); - - match attempt_direct_openclaw_package_upgrade( - app, - current_runtime_bin_dir.as_deref(), - update_context.root.as_deref(), - update_context.package_manager.as_deref(), - ) - .await - { - Ok(result) => { - set_preferred_runtime_bin_dir(Some(result.runtime_bin_dir.clone())); - emit_install_progress( - app, - &format!( - "已自动切换后续执行环境到 {}。", - result.runtime_bin_dir.display() - ), - "info", - ); - return self - .finalize_successful_openclaw_update( - app, - gateway_was_running, - Some(format!( - "OpenClaw 已通过 {} 的 {} 全局升级完成({})。", - result.runtime_source, result.package_manager, result.package_spec - )), - ) - .await; - } - Err(fallback_error) => { - emit_install_progress(app, &fallback_error, "error"); - return Ok(ActionResult { - success: false, - message, - }); - } - } - } - - self.finalize_successful_openclaw_update(app, gateway_was_running, None) - .await - } - - async fn finalize_successful_openclaw_update( - &mut self, - app: &AppHandle, - gateway_was_running: bool, - success_message_override: Option, - ) -> Result { - self.refresh_process_state().await?; - let updated_version = self - .read_openclaw_version() - .await - .ok() - .flatten() - .and_then(|value| parse_openclaw_release_version(&value).or(Some(value))); - - if gateway_was_running { - emit_install_progress(app, "升级前 Gateway 处于运行态,开始自动恢复服务。", "info"); - let restart_result = self - .start_gateway(Some(app), Some(self.gateway_port)) - .await?; - if !restart_result.success { - return Ok(restart_result); - } - } - - let message = success_message_override.unwrap_or_else(|| { - updated_version - .as_ref() - .map(|version| format!("OpenClaw 已升级完成,当前版本 {version}。")) - .unwrap_or_else(|| "OpenClaw 已升级完成。".to_string()) - }); - emit_install_progress(app, &message, "info"); - Ok(ActionResult { - success: true, - message, - }) - } -} - -pub(crate) fn parse_openclaw_release_version(value: &str) -> Option { - static VERSION_RE: OnceLock = OnceLock::new(); - VERSION_RE - .get_or_init(|| Regex::new(r"(?i)openclaw\s+([0-9]+(?:\.[0-9]+)+)").expect("valid regex")) - .captures(value) - .and_then(|captures| captures.get(1).map(|value| value.as_str().to_string())) -} - -pub(crate) async fn read_openclaw_update_status_payload( - command_spec: &ResolvedOpenClawCommand, -) -> Result { - let mut command = command_spec.build_command_with_args(["update", "status", "--json"]); - let output = command - .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| format!("检查 OpenClaw 更新失败: {e}"))?; - - let stdout_lines = command_output_lines(&output.stdout); - let stderr_lines = command_output_lines(&output.stderr); - let payload = serde_json::from_slice::(&output.stdout).map_err(|error| { - let detail = select_openclaw_update_failure_detail(None, &stderr_lines, &stdout_lines) - .unwrap_or_else(|| format!("解析更新状态失败: {error}")); - format_openclaw_update_failure_message(Some(detail.as_str())) - })?; - - if !output.status.success() { - let detail = - select_openclaw_update_failure_detail(Some(&payload), &stderr_lines, &stdout_lines); - return Err(format_openclaw_update_failure_message(detail.as_deref())); - } - - Ok(payload) -} - -pub(crate) fn extract_openclaw_update_execution_context( - payload: &Value, -) -> OpenClawUpdateExecutionContext { - OpenClawUpdateExecutionContext { - root: payload - .pointer("/update/root") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(PathBuf::from), - install_kind: payload - .pointer("/update/installKind") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string), - package_manager: payload - .pointer("/update/packageManager") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string), - } -} - -pub(crate) fn select_openclaw_update_failure_detail( - payload: Option<&Value>, - stderr_lines: &[String], - stdout_lines: &[String], -) -> Option { - if let Some(payload) = payload { - if let Some(reason) = payload.get("reason").and_then(Value::as_str) { - let root_suffix = payload - .get("root") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|root| format!(" ({root})")) - .unwrap_or_default(); - return Some(format!("{reason}{root_suffix}")); - } - - if let Some(message) = payload.get("message").and_then(Value::as_str) { - let trimmed = message.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - - if let Some(message) = payload - .pointer("/update/registry/error") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return Some(message.to_string()); - } - } - - stderr_lines - .iter() - .chain(stdout_lines.iter()) - .filter_map(|line| { - let trimmed = line.trim(); - let score = openclaw_update_failure_line_score(trimmed); - (score > 0).then_some((score, trimmed)) - }) - .max_by_key(|(score, _)| *score) - .map(|(_, line)| line.to_string()) -} - -pub(crate) fn openclaw_update_failure_line_score(line: &str) -> u8 { - if line.is_empty() { - return 0; - } - - let normalized = line.to_ascii_lowercase(); - if normalized.starts_with("updating openclaw") - || normalized.starts_with("update result") - || normalized.starts_with("total time") - || normalized.starts_with("root:") - { - return 0; - } - - if normalized.contains("not-openclaw-root") { - return 100; - } - - if normalized.contains("node.js") && normalized.contains("required") { - return 95; - } - - if normalized.contains("fetch failed") || normalized.contains("network") { - return 90; - } - - if normalized.contains("pnpm") && normalized.contains("not found") - || normalized.contains("npm") && normalized.contains("not found") - { - return 85; - } - - if normalized.contains("already up to date") || normalized.contains("not modified") { - return 80; - } - - if normalized.starts_with("reason:") { - return 70; - } - - 20 -} - -pub(crate) fn format_openclaw_update_failure_message(detail: Option<&str>) -> String { - let Some(detail) = detail.map(str::trim).filter(|value| !value.is_empty()) else { - return "OpenClaw 升级失败,请查看日志输出。".to_string(); - }; - - let normalized = detail.to_ascii_lowercase(); - if normalized.contains("not-openclaw-root") { - return "OpenClaw 升级失败:未在 OpenClaw 安装根目录执行更新。Lime 会优先切换到安装目录;如仍失败,请重新检测安装状态后重试。" - .to_string(); - } - - if normalized.contains("node.js") && normalized.contains("required") { - return format!( - "OpenClaw 升级失败:当前用于执行 openclaw 的 Node.js 版本过低,需要 {}+。请切换到满足要求的 Node.js 后重试。", - format_semver(NODE_MIN_VERSION) - ); - } - - if normalized.contains("fetch failed") || normalized.contains("network") { - return "OpenClaw 升级失败:当前无法访问更新源,请检查网络或代理设置后重试。".to_string(); - } - - if normalized.contains("not modified") || normalized.contains("already up to date") { - return "OpenClaw 当前已经是最新版本,无需升级。".to_string(); - } - - if normalized.contains("pnpm") && normalized.contains("not found") { - return "OpenClaw 升级失败:当前安装方式依赖 pnpm,但系统未找到 pnpm。请先修复 Node.js / pnpm 环境后重试。" - .to_string(); - } - - if normalized.contains("npm") && normalized.contains("not found") { - return "OpenClaw 升级失败:当前安装方式依赖 npm,但系统未找到 npm。请先修复 Node.js / npm 环境后重试。" - .to_string(); - } - - format!("OpenClaw 升级失败:{detail}") -} - -pub(crate) async fn attempt_direct_openclaw_package_upgrade( - app: &AppHandle, - runtime_bin_dir_hint: Option<&Path>, - install_root_hint: Option<&Path>, - package_manager_hint: Option<&str>, -) -> Result { - let mut runtime_candidates = list_openclaw_runtime_candidates().await?; - runtime_candidates.sort_by(|left, right| { - let left_matches_root = install_root_hint - .map(|root| runtime_candidate_matches_install_root(left, root)) - .unwrap_or(false); - let right_matches_root = install_root_hint - .map(|root| runtime_candidate_matches_install_root(right, root)) - .unwrap_or(false); - let left_matches_bin = runtime_bin_dir_hint - .map(|hint| Path::new(&left.bin_dir) == hint) - .unwrap_or(false); - let right_matches_bin = runtime_bin_dir_hint - .map(|hint| Path::new(&right.bin_dir) == hint) - .unwrap_or(false); - - right_matches_root - .cmp(&left_matches_root) - .then_with(|| right_matches_bin.cmp(&left_matches_bin)) - .then_with(|| compare_openclaw_runtime_candidates(left, right)) - }); - let mut last_error = None; - let mut attempted = 0usize; - - if let Some(install_root_hint) = install_root_hint { - emit_install_progress( - app, - &format!( - "官方 updater 报告的安装根目录为 {},将优先匹配该安装来源执行兜底升级。", - install_root_hint.display() - ), - "info", - ); - } - - for candidate in runtime_candidates { - let Some(plan) = resolve_direct_openclaw_upgrade_plan( - app, - &candidate, - runtime_bin_dir_hint, - install_root_hint, - package_manager_hint, - ) - .await? - else { - continue; - }; - - attempted += 1; - emit_install_progress( - app, - &format!( - "官方自更新未能完成,开始尝试全局包升级兜底:{} · {}。", - plan.runtime_source, plan.package_manager - ), - "warn", - ); - emit_install_progress( - app, - &format!("目标执行环境:{}", plan.runtime_bin_dir.display()), - "info", - ); - emit_install_progress(app, &format!("升级包:{}", plan.package_spec), "info"); - - let result = run_shell_command_with_progress(app, &plan.command_line).await?; - if result.success { - emit_install_progress( - app, - &format!( - "已通过 {} 的 {} 全局安装方式完成兜底升级。", - plan.runtime_source, plan.package_manager - ), - "info", - ); - return Ok(OpenClawDirectUpgradeResult { - runtime_source: plan.runtime_source, - runtime_bin_dir: plan.runtime_bin_dir, - package_manager: plan.package_manager, - package_spec: plan.package_spec, - }); - } - - emit_install_progress( - app, - &format!( - "全局包升级兜底失败:{} · {}。", - plan.runtime_source, result.message - ), - "warn", - ); - last_error = Some(result.message); - } - - if attempted == 0 { - return Err("未检测到可用于全局升级的 OpenClaw 安装来源。".to_string()); - } - - Err(last_error.unwrap_or_else(|| "已自动尝试全局升级兜底,但仍未成功。".to_string())) -} - -pub(crate) async fn resolve_direct_openclaw_upgrade_plan( - app: &AppHandle, - candidate: &OpenClawRuntimeCandidate, - runtime_bin_dir_hint: Option<&Path>, - install_root_hint: Option<&Path>, - package_manager_hint: Option<&str>, -) -> Result, String> { - let runtime_bin_dir = PathBuf::from(&candidate.bin_dir); - let runtime_matches_hint = runtime_bin_dir_hint - .map(|hint| hint == runtime_bin_dir.as_path()) - .unwrap_or(false); - let runtime_matches_install_root = install_root_hint - .map(|root| runtime_candidate_matches_install_root(candidate, root)) - .unwrap_or(false); - - if runtime_bin_dir_hint.is_some() - && !runtime_matches_hint - && !runtime_matches_install_root - && candidate.openclaw_path.is_none() - && candidate.openclaw_package_path.is_none() - { - return Ok(None); - } - - if candidate.openclaw_path.is_none() && candidate.openclaw_package_path.is_none() { - return Ok(None); - } - - let package_spec = resolve_openclaw_upgrade_package_spec(app, candidate).await?; - let registry = package_registry_for_package_spec(&package_spec); - let package_manager_hint = package_manager_hint - .map(|value| value.trim().to_ascii_lowercase()) - .filter(|value| !value.is_empty()); - let npm_path = candidate - .npm_path - .as_deref() - .map(PathBuf::from) - .or_else(|| find_command_in_bin_dir("npm", &runtime_bin_dir)); - let pnpm_path = find_command_in_bin_dir("pnpm", &runtime_bin_dir); - let shell_platform = current_shell_platform(); - - let (package_manager, command_line) = match package_manager_hint.as_deref() { - Some("pnpm") => { - if let Some(pnpm_path) = pnpm_path.as_ref().and_then(|path| path.to_str()) { - ( - "pnpm".to_string(), - build_openclaw_pnpm_install_command( - shell_platform, - pnpm_path, - &package_spec, - registry, - ), - ) - } else if let Some(npm_path) = npm_path.as_ref().and_then(|path| path.to_str()) { - ( - "npm".to_string(), - build_openclaw_install_command( - shell_platform, - npm_path, - candidate.npm_global_prefix.as_deref(), - &package_spec, - registry, - ), - ) - } else { - return Ok(None); - } - } - _ => { - if let Some(npm_path) = npm_path.as_ref().and_then(|path| path.to_str()) { - ( - "npm".to_string(), - build_openclaw_install_command( - shell_platform, - npm_path, - candidate.npm_global_prefix.as_deref(), - &package_spec, - registry, - ), - ) - } else if let Some(pnpm_path) = pnpm_path.as_ref().and_then(|path| path.to_str()) { - ( - "pnpm".to_string(), - build_openclaw_pnpm_install_command( - shell_platform, - pnpm_path, - &package_spec, - registry, - ), - ) - } else { - return Ok(None); - } - } - }; - - Ok(Some(OpenClawDirectUpgradePlan { - runtime_source: candidate.source.clone(), - runtime_bin_dir, - package_manager, - package_spec, - command_line, - })) -} - -pub(crate) async fn resolve_openclaw_upgrade_package_spec( - app: &AppHandle, - candidate: &OpenClawRuntimeCandidate, -) -> Result { - if let Some(prefix) = candidate.npm_global_prefix.as_deref() { - if let Some(package) = find_installed_openclaw_package_details(prefix) { - return Ok(format!("{}@latest", package.name)); - } - } - - if let Some(package_path) = candidate.openclaw_package_path.as_deref() { - if let Some(package_name) = infer_openclaw_package_name_from_path(Path::new(package_path)) { - return Ok(format!("{package_name}@latest")); - } - } - - Ok(if should_use_china_package(app).await { - OPENCLAW_CN_PACKAGE.to_string() - } else { - OPENCLAW_DEFAULT_PACKAGE.to_string() - }) -} - -pub(crate) fn infer_openclaw_package_name_from_path(path: &Path) -> Option<&'static str> { - let normalized = path - .display() - .to_string() - .replace('\\', "/") - .to_ascii_lowercase(); - if normalized.contains("@qingchencloud/openclaw-zh") { - return Some("@qingchencloud/openclaw-zh"); - } - if normalized.contains("/openclaw/package.json") { - return Some("openclaw"); - } - None -} - -pub(crate) fn package_registry_for_package_spec(package_spec: &str) -> Option<&'static str> { - package_spec - .starts_with("@qingchencloud/openclaw-zh@") - .then_some(NPM_MIRROR_CN) -} - -pub(crate) fn runtime_candidate_matches_install_root( - candidate: &OpenClawRuntimeCandidate, - install_root_hint: &Path, -) -> bool { - [ - Some(candidate.bin_dir.as_str()), - Some(candidate.node_path.as_str()), - candidate.npm_path.as_deref(), - candidate.npm_global_prefix.as_deref(), - candidate.openclaw_path.as_deref(), - candidate.openclaw_package_path.as_deref(), - ] - .into_iter() - .flatten() - .map(PathBuf::from) - .any(|path| path.starts_with(install_root_hint)) -} diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 4d7e888ca..e3f62ab64 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.33.0", + "version": "1.34.0", "identifier": "com.limecloud.lime.headless", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a4e59e105..0030eaa54 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.33.0", + "version": "1.34.0", "identifier": "com.limecloud.lime", "build": { "beforeDevCommand": "node scripts/start-tauri-dev-server.mjs", diff --git a/src/App.tsx b/src/App.tsx index 55d2be7fe..0cdeeb05e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import React, { Suspense, lazy, useState, useCallback, useEffect } from "react"; import styled from "styled-components"; +import { useTranslation } from "react-i18next"; import { withI18nPatch } from "./i18n/withI18nPatch"; import { AppPageContent } from "./components/AppPageContent"; import { SplashScreen } from "./components/SplashScreen"; @@ -132,25 +133,10 @@ const ConnectConfirmDialog = lazy(() => default: module.ConnectConfirmDialog, })), ); -const pageLoadingFallback = ( -
- 页面加载中... -
-); - function AppContent() { startupTracker.mark("AppContent: render start"); + const { t } = useTranslation("common"); const hasTauriDesktopRuntime = hasTauriInvokeCapability(); const reserveMacWindowControls = shouldReserveMacWindowControls(); const [showSplash, setShowSplash] = useState(true); @@ -191,10 +177,12 @@ function AppContent() { [], ); const handleResourceManagerNavigationUnsupported = useCallback(() => { - toast.info("当前资源暂时不能自动回跳", { - description: "已记录来源信息,但主窗口还没有对应的业务入口。", + toast.info(t("common.app.resourceNavigation.unsupported.title"), { + description: t( + "common.app.resourceNavigation.unsupported.description", + ), }); - }, []); + }, [t]); useResourceManagerNavigationIntents({ onNavigate: handleNavigate, onHandled: handleResourceManagerNavigationHandled, @@ -219,11 +207,15 @@ function AppContent() { const _handleRequestRecommendation = useCallback( (shortLabel: string, fullPrompt: string, currentTheme: string) => { const themeLabels: Record = { - general: "对话", + general: t("common.app.project.type.general"), }; - const prefix = themeLabels[currentTheme] || "项目"; - const projectName = `${prefix}:${shortLabel}`; + const prefix = + themeLabels[currentTheme] || t("common.app.project.type.fallback"); + const projectName = t("common.app.project.recommendedName", { + prefix, + shortLabel, + }); setPendingRecommendation({ shortLabel, @@ -233,7 +225,7 @@ function AppContent() { }); setProjectDialogOpen(true); }, - [], + [t], ); const handleCreateProjectFromRecommendation = async ( @@ -271,7 +263,7 @@ function AppContent() { }); } - toast.success("项目创建成功"); + toast.success(t("common.app.project.created")); }; const handleOpenBrowserConnectorSettings = useCallback( @@ -281,12 +273,12 @@ function AppContent() { }); if (enable) { - toast.info("已打开连接器设置", { - description: "在“连接器”页中开启浏览器连接器或重新同步扩展。", + toast.info(t("common.app.browserConnector.opened.title"), { + description: t("common.app.browserConnector.opened.description"), }); } }, - [handleNavigate], + [handleNavigate, t], ); const handleOpenWebsiteDeepLink = useCallback( @@ -294,16 +286,17 @@ function AppContent() { const resolved = resolveWebsiteOpenNavigation(payload); if (!resolved) { - toast.error("无法打开这个官网入口", { - description: - "当前 slug 没有对应到桌面端可用能力,请同步官网与客户端目录。", + toast.error(t("common.app.websiteDeepLink.unsupported.title"), { + description: t( + "common.app.websiteDeepLink.unsupported.description", + ), }); return; } handleNavigate(resolved.page, resolved.params); }, - [handleNavigate], + [handleNavigate, t], ); const { @@ -332,6 +325,21 @@ function AppContent() { pageParams, agentHasMessages, }); + const pageLoadingFallback = ( +
+ {t("common.app.loadingPage")} +
+ ); const handleSplashComplete = useCallback(() => { startupTracker.mark("SplashScreen: complete"); diff --git a/src/components/AppPageContent.tsx b/src/components/AppPageContent.tsx index 8fcd8a12c..fdf837cd8 100644 --- a/src/components/AppPageContent.tsx +++ b/src/components/AppPageContent.tsx @@ -16,7 +16,6 @@ import type { BrowserRuntimePageParams, KnowledgePageParams, MemoryPageParams, - OpenClawPageParams, Page, PageParams, ResourcesPageParams, @@ -61,10 +60,6 @@ const loadPluginsPage = () => import("./plugins/PluginsPage").then((module) => ({ default: module.PluginsPage, })); -const loadOpenClawPage = () => - import("./openclaw").then((module) => ({ - default: module.OpenClawPage, - })); const loadSkillsWorkspacePage = () => import("./skills").then((module) => ({ default: module.SkillsWorkspacePage, @@ -130,7 +125,6 @@ function scheduleAgentChatPagePreload(): void { const ResourcesPage = lazy(loadResourcesPage); const MemoryPage = lazy(loadMemoryPage); const PluginsPage = lazy(loadPluginsPage); -const OpenClawPage = lazy(loadOpenClawPage); const SkillsWorkspacePage = lazy(loadSkillsWorkspacePage); const KnowledgePage = lazy(loadKnowledgePage); const BrowserRuntimeWorkspace = lazy(loadBrowserRuntimeWorkspace); @@ -403,25 +397,6 @@ export function AppPageContent({ return wrapWithSceneApps(content); } - if (activePage === "openclaw") { - const content = ( -
- -
- ); - - return wrapWithSceneApps(content); - } - if (activePage === "skills") { const content = (
diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index 4b9c8d6f6..79a94a40a 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -277,6 +277,33 @@ function buildMockReferralDashboard() { }; } +function seedCloudSessionWithReferral(options?: { + referralEnabled?: boolean; + referral?: ReturnType | null; +}) { + setStoredOemCloudSessionState({ + token: "session-token", + tenant: { id: "tenant-0001", name: "Lime Cloud" }, + user: { + id: "user-001", + displayName: "晚风", + email: "wanfeng@example.com", + }, + session: { id: "session-001", provider: "google" }, + }); + setOemCloudBootstrapSnapshot({ + session: { + tenant: { id: "tenant-0001", name: "Lime Cloud" }, + }, + features: { + referralEnabled: options?.referralEnabled ?? true, + }, + ...(options?.referral !== null + ? { referral: options?.referral ?? buildMockReferralDashboard() } + : {}), + }); +} + describe("AppSidebar", () => { beforeEach(async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); @@ -497,7 +524,6 @@ describe("AppSidebar", () => { expect(container.textContent).not.toContain("持续流程"); expect(container.textContent).not.toContain("消息渠道"); expect(container.textContent).not.toContain("插件中心"); - expect(container.textContent).not.toContain("OpenClaw"); expect(container.textContent).not.toContain("桌宠"); expect(container.textContent).not.toContain("支撑"); expect(container.textContent).not.toContain("技能"); @@ -730,25 +756,7 @@ describe("AppSidebar", () => { configurable: true, value: { writeText }, }); - setStoredOemCloudSessionState({ - token: "session-token", - tenant: { id: "tenant-0001", name: "Lime Cloud" }, - user: { - id: "user-001", - displayName: "晚风", - email: "wanfeng@example.com", - }, - session: { id: "session-001", provider: "google" }, - }); - setOemCloudBootstrapSnapshot({ - session: { - tenant: { id: "tenant-0001", name: "Lime Cloud" }, - }, - features: { - referralEnabled: true, - }, - referral: buildMockReferralDashboard(), - }); + seedCloudSessionWithReferral(); const container = mountSidebarContainer({ currentPage: "agent", @@ -798,24 +806,61 @@ describe("AppSidebar", () => { expect(mockToastSuccess).toHaveBeenCalledWith("已复制邀请文案"); }); - it("缓存的云端邀请开关关闭时不应展示头部邀请入口", async () => { - setStoredOemCloudSessionState({ - token: "session-token", - tenant: { id: "tenant-0001", name: "Lime Cloud" }, - user: { - id: "user-001", - displayName: "晚风", - email: "wanfeng@example.com", - }, - session: { id: "session-001", provider: "google" }, + it("邀请入口应使用 navigation 命名空间资源", async () => { + await changeLimeLocale("en-US"); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, }); - setOemCloudBootstrapSnapshot({ - session: { - tenant: { id: "tenant-0001", name: "Lime Cloud" }, - }, - features: { - referralEnabled: false, - }, + seedCloudSessionWithReferral(); + + const container = mountSidebarContainer({ + currentPage: "agent", + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(2); + + const inviteButton = container.querySelector( + '[data-testid="app-sidebar-invite-button"]', + ); + expect(inviteButton?.textContent).toContain("Invite friends"); + + await act(async () => { + inviteButton?.click(); + await Promise.resolve(); + }); + await flushEffects(4); + + const dialog = document.body.querySelector( + '[data-testid="app-sidebar-invite-dialog"]', + ); + expect(dialog?.textContent).toContain("Lime Invite"); + expect(dialog?.textContent).toContain("Invite code"); + expect(dialog?.textContent).toContain("480 credits"); + expect(dialog?.textContent).toContain("120 credits"); + + const copyShareButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("Copy invite message")); + + await act(async () => { + copyShareButton?.click(); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledWith( + "邀请你体验Lime,让AI做牛做马,我们来做牛人!前往 https://limeai.run 下载客户端,复制邀请码 LIME-2026 激活并注册账号参与内测", + ); + expect(mockToastSuccess).toHaveBeenCalledWith("Invite message copied"); + }); + + it("缓存的云端邀请开关关闭时不应展示头部邀请入口", async () => { + seedCloudSessionWithReferral({ + referralEnabled: false, + referral: null, }); const container = mountSidebarContainer({ @@ -2587,7 +2632,7 @@ describe("AppSidebar", () => { it("显式开启后应显示可选系统扩展入口", async () => { mockGetConfig.mockResolvedValue({ navigation: { - enabled_items: ["plugins", "openclaw", "companion"], + enabled_items: ["plugins", "companion"], }, }); @@ -2599,7 +2644,6 @@ describe("AppSidebar", () => { await flushEffects(2); expect(container.textContent).not.toContain("插件中心"); - expect(container.textContent).not.toContain("OpenClaw"); expect(container.textContent).not.toContain("桌宠"); await act(async () => { @@ -2618,7 +2662,6 @@ describe("AppSidebar", () => { expect(accountMenu?.textContent).toContain("持续流程"); expect(accountMenu?.textContent).toContain("消息渠道"); expect(accountMenu?.textContent).toContain("插件中心"); - expect(accountMenu?.textContent).toContain("OpenClaw"); expect(accountMenu?.textContent).toContain("桌宠"); }); @@ -2718,7 +2761,7 @@ describe("AppSidebar", () => { it("旧的 enabled-items 本地缓存不应再复活历史导航", async () => { localStorage.setItem( APP_SIDEBAR_ENABLED_ITEMS_STORAGE_KEY, - JSON.stringify(["plugins", "openclaw", "companion", "video"]), + JSON.stringify(["plugins", "companion", "video"]), ); mockGetConfig.mockImplementation(() => new Promise(() => undefined)); @@ -2730,7 +2773,6 @@ describe("AppSidebar", () => { await flushEffects(); expect(container.textContent).not.toContain("插件中心"); - expect(container.textContent).not.toContain("OpenClaw"); expect(container.textContent).not.toContain("桌宠"); }); @@ -2797,6 +2839,48 @@ describe("AppSidebar", () => { expect(document.documentElement.classList.contains("dark")).toBe(true); }); + it("外观快捷面板应使用 navigation 命名空间资源", async () => { + await changeLimeLocale("en-US"); + const container = mountSidebarContainer({ + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(2); + + const trigger = container.querySelector( + 'button[aria-label="Quick appearance switch"]', + ); + + expect(trigger).not.toBeNull(); + + await act(async () => { + trigger?.click(); + await Promise.resolve(); + }); + + const popover = container.querySelector( + '[data-testid="app-sidebar-appearance-popover"]', + ); + expect(popover?.textContent).toContain("Appearance"); + expect(popover?.textContent).toContain("Follow system · Ink Green"); + expect(popover?.textContent).toContain("Theme"); + expect(popover?.textContent).toContain("Color"); + expect(popover?.textContent).toContain("Light"); + expect(popover?.textContent).toContain("Dark"); + expect(popover?.textContent).toContain("Follow system"); + expect(popover?.textContent).toContain("Random"); + expect(popover?.textContent).toContain("Ocean"); + expect( + container.querySelector('button[aria-label="Switch theme to Dark"]'), + ).not.toBeNull(); + expect( + container.querySelector( + 'button[aria-label="Switch color scheme to Ocean"]', + ), + ).not.toBeNull(); + }); + it("外观弹层的随机配色应持久化到一个真实预设", async () => { const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 0ce61e14b..519f501a9 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -196,7 +196,6 @@ const SIDEBAR_NAV_LABEL_KEYS: Record = { "home-general": "navigation.sidebar.items.homeGeneral", knowledge: "navigation.sidebar.items.knowledge", memory: "navigation.sidebar.items.memory", - openclaw: "navigation.sidebar.items.openclaw", plugins: "navigation.sidebar.items.plugins", settings: "navigation.sidebar.items.settings", skills: "navigation.sidebar.items.skills", @@ -2316,14 +2315,6 @@ function resolveCloudBrandLabel( : `${appName} ${cloudSuffixLabel}`; } -function formatReferralCredits(value: number | undefined): string { - if (typeof value !== "number" || value <= 0) { - return "按当前策略发放"; - } - - return `${value.toLocaleString("zh-CN")} 积分`; -} - export function AppSidebar({ currentPage, currentPageParams, @@ -2402,6 +2393,10 @@ export function AppSidebar({ "navigation.sidebar.account.cloudSuffix", "云端", ); + const inviteLoadErrorLabel = t( + "navigation.sidebar.invite.feedback.loadFailed", + "加载邀请信息失败", + ); const activePage = requestedPage ?? currentPage; const activePageParams = requestedPageParams ?? currentPageParams; const activeNavigationTarget = { @@ -2846,7 +2841,7 @@ export function AppSidebar({ const message = error instanceof Error && error.message.trim() ? error.message.trim() - : "加载邀请信息失败"; + : inviteLoadErrorLabel; setInviteError(message); }) .finally(() => { @@ -2864,6 +2859,7 @@ export function AppSidebar({ inviteTenantId, canLoadReferralDashboard, cachedInviteDashboard, + inviteLoadErrorLabel, ]); useEffect(() => { @@ -4062,9 +4058,191 @@ export function AppSidebar({ ); const currentColorScheme = getLimeColorScheme(colorSchemeId); + const appearanceEntryLabel = t( + "navigation.sidebar.appearance.entry.label", + "快速切换外观", + ); + const appearanceTitleLabel = t( + "navigation.sidebar.appearance.dialog.title", + "外观", + ); + const appearanceThemeGroupLabel = t( + "navigation.sidebar.appearance.theme.group", + "主题", + ); + const appearanceColorSchemeGroupLabel = t( + "navigation.sidebar.appearance.colorScheme.group", + "配色", + ); + const appearanceRandomColorSchemeLabel = t( + "navigation.sidebar.appearance.colorScheme.random.label", + "随机", + ); + const appearanceRandomColorSchemeAriaLabel = t( + "navigation.sidebar.appearance.colorScheme.random.ariaLabel", + "随机切换配色", + ); + const appearanceRandomColorSchemeTitle = t( + "navigation.sidebar.appearance.colorScheme.random.title", + "随机切换一个颜色主题", + ); + const appearanceThemeCopy = { + light: { + label: t("navigation.sidebar.appearance.theme.light.label", "浅色"), + description: t( + "navigation.sidebar.appearance.theme.light.description", + "适合白天和高亮环境。", + ), + }, + dark: { + label: t("navigation.sidebar.appearance.theme.dark.label", "深色"), + description: t( + "navigation.sidebar.appearance.theme.dark.description", + "降低夜间使用时的眩光。", + ), + }, + system: { + label: t("navigation.sidebar.appearance.theme.system.label", "跟随系统"), + description: t( + "navigation.sidebar.appearance.theme.system.description", + "自动同步系统外观。", + ), + }, + } satisfies Record; + const appearanceColorSchemeCopy = { + "lime-classic": { + label: t( + "navigation.sidebar.appearance.colorScheme.limeClassic.label", + "墨绿", + ), + description: t( + "navigation.sidebar.appearance.colorScheme.limeClassic.description", + "经典深绿,温暖米色背景。", + ), + }, + "lime-forest": { + label: t( + "navigation.sidebar.appearance.colorScheme.limeForest.label", + "自然", + ), + description: t( + "navigation.sidebar.appearance.colorScheme.limeForest.description", + "舒适放松的清新自然风。", + ), + }, + "lime-ocean": { + label: t( + "navigation.sidebar.appearance.colorScheme.limeOcean.label", + "海洋", + ), + description: t( + "navigation.sidebar.appearance.colorScheme.limeOcean.description", + "沉静专业的蓝色调。", + ), + }, + "lime-sand": { + label: t( + "navigation.sidebar.appearance.colorScheme.limeSand.label", + "复古", + ), + description: t( + "navigation.sidebar.appearance.colorScheme.limeSand.description", + "温暖怀旧的琥珀色调。", + ), + }, + "lime-neon": { + label: t( + "navigation.sidebar.appearance.colorScheme.limeNeon.label", + "霓虹", + ), + description: t( + "navigation.sidebar.appearance.colorScheme.limeNeon.description", + "赛博明亮的粉紫色调。", + ), + }, + "lime-citron": { + label: t( + "navigation.sidebar.appearance.colorScheme.limeCitron.label", + "青柠", + ), + description: t( + "navigation.sidebar.appearance.colorScheme.limeCitron.description", + "活力清新的黄绿配紫。", + ), + }, + "lime-dusk": { + label: t( + "navigation.sidebar.appearance.colorScheme.limeDusk.label", + "黄昏", + ), + description: t( + "navigation.sidebar.appearance.colorScheme.limeDusk.description", + "柔和温暖的暮色调。", + ), + }, + "lime-minimal": { + label: t( + "navigation.sidebar.appearance.colorScheme.limeMinimal.label", + "极简", + ), + description: t( + "navigation.sidebar.appearance.colorScheme.limeMinimal.description", + "清晰专业的深蓝商务风。", + ), + }, + "lime-vivid": { + label: t( + "navigation.sidebar.appearance.colorScheme.limeVivid.label", + "活力", + ), + description: t( + "navigation.sidebar.appearance.colorScheme.limeVivid.description", + "时尚有冲击力的现代科技风。", + ), + }, + "lime-literary": { + label: t( + "navigation.sidebar.appearance.colorScheme.limeLiterary.label", + "文艺", + ), + description: t( + "navigation.sidebar.appearance.colorScheme.limeLiterary.description", + "宁静高雅的灰蓝文艺风。", + ), + }, + "lime-luxury": { + label: t( + "navigation.sidebar.appearance.colorScheme.limeLuxury.label", + "奢华", + ), + description: t( + "navigation.sidebar.appearance.colorScheme.limeLuxury.description", + "尊贵权威的黑金商务风。", + ), + }, + } satisfies Record; + const appearanceThemeOptions = LIME_THEME_MODE_OPTIONS.map((option) => ({ + ...option, + ...appearanceThemeCopy[option.id], + })); + const appearanceColorSchemes = LIME_COLOR_SCHEMES.map((scheme) => ({ + ...scheme, + ...appearanceColorSchemeCopy[scheme.id], + })); const currentThemeLabel = - LIME_THEME_MODE_OPTIONS.find((option) => option.id === themeState.themeMode) - ?.label ?? "跟随系统"; + appearanceThemeCopy[themeState.themeMode]?.label ?? + appearanceThemeCopy.system.label; + const currentColorSchemeLabel = + appearanceColorSchemeCopy[currentColorScheme.id]?.label ?? + currentColorScheme.label; + const appearanceSummaryLabel = t( + "navigation.sidebar.appearance.dialog.summary", + { + theme: currentThemeLabel, + colorScheme: currentColorSchemeLabel, + defaultValue: "{{theme}} · {{colorScheme}}", + }, + ); const currentLanguageLabel = resolveLocaleOptionLabel(language); const accountDisplayName = resolveAccountDisplayName( cloudSessionState, @@ -4263,10 +4441,123 @@ export function AppSidebar({ : accountLocalTooltipLabel; const inviteShare = inviteDashboard?.share; const invitePolicy = inviteDashboard?.policy; - const inviteHeadline = inviteShare?.headline?.trim() || "邀请好友加入内测"; + const inviteEntryLabel = t( + "navigation.sidebar.invite.entry.label", + "邀请好友", + ); + const inviteCloseDialogLabel = t( + "navigation.sidebar.invite.dialog.close", + "关闭邀请弹窗", + ); + const inviteBrandName = inviteShare?.brandName ?? accountTenantLabel ?? "Lime"; + const inviteEyebrowLabel = t("navigation.sidebar.invite.dialog.eyebrow", { + brand: inviteBrandName, + defaultValue: "{{brand}} 邀请", + }); + const inviteDialogTitleLabel = t( + "navigation.sidebar.invite.dialog.title", + "邀请好友", + ); + const inviteHeadline = + inviteShare?.headline?.trim() || + t("navigation.sidebar.invite.dialog.headlineFallback", "邀请好友加入内测"); const inviteRules = inviteShare?.rules?.trim() || - "通过云端邀请策略自动发放奖励,具体到账以当前品牌云端配置为准。"; + t( + "navigation.sidebar.invite.dialog.rulesFallback", + "通过云端邀请策略自动发放奖励,具体到账以当前品牌云端配置为准。", + ); + const inviteDescriptionLabel = + !inviteLoading && !inviteError + ? t("navigation.sidebar.invite.dialog.descriptionWithRules", { + headline: inviteHeadline, + rules: inviteRules, + defaultValue: "{{headline}}。{{rules}}", + }) + : inviteHeadline; + const inviteDisconnectedLabel = t( + "navigation.sidebar.invite.status.disconnected", + { + brand: cloudBrandLabel, + defaultValue: + "连接 {{brand}} 后会生成专属邀请码,并自动读取当前品牌云端的域名和奖励策略。", + }, + ); + const inviteConnectAccountLabel = t( + "navigation.sidebar.invite.actions.connectCloudAccount", + "连接云端账号", + ); + const inviteLoadingLabel = t( + "navigation.sidebar.invite.status.loading", + "正在从云端同步邀请信息...", + ); + const inviteRetryLabel = t( + "navigation.sidebar.invite.actions.retry", + "重试", + ); + const inviteCodeLabel = t("navigation.sidebar.invite.fields.code", "邀请码"); + const inviteCopyLabel = t("navigation.sidebar.invite.actions.copy", "复制"); + const inviteDownloadUrlLabel = t( + "navigation.sidebar.invite.fields.downloadUrl", + "下载地址", + ); + const inviteLandingUrlLabel = t( + "navigation.sidebar.invite.fields.landingUrl", + "邀请链接", + ); + const inviteReferrerRewardLabel = t( + "navigation.sidebar.invite.fields.referrerReward", + "邀请人奖励", + ); + const inviteInviteeRewardLabel = t( + "navigation.sidebar.invite.fields.inviteeReward", + "被邀请人奖励", + ); + const inviteCopyShareTextLabel = t( + "navigation.sidebar.invite.actions.copyShareText", + "复制邀请文案", + ); + const inviteCopyLandingUrlLabel = t( + "navigation.sidebar.invite.actions.copyLandingUrl", + "复制邀请链接", + ); + const inviteCopyCodeSuccessLabel = t( + "navigation.sidebar.invite.feedback.codeCopied", + "已复制邀请码", + ); + const inviteCopyShareTextSuccessLabel = t( + "navigation.sidebar.invite.feedback.shareTextCopied", + "已复制邀请文案", + ); + const inviteCopyLandingUrlSuccessLabel = t( + "navigation.sidebar.invite.feedback.landingUrlCopied", + "已复制邀请链接", + ); + const inviteCopyEmptyLabel = t( + "navigation.sidebar.invite.feedback.emptyCopy", + "暂无可复制内容", + ); + const inviteCopyFailedLabel = t( + "navigation.sidebar.invite.feedback.copyFailed", + "复制失败,请检查剪贴板权限", + ); + const inviteRewardCurrentPolicyLabel = t( + "navigation.sidebar.invite.reward.currentPolicy", + "按当前策略发放", + ); + const formatInviteReferralCredits = useCallback( + (value: number | undefined): string => { + if (typeof value !== "number" || value <= 0) { + return inviteRewardCurrentPolicyLabel; + } + + return t("navigation.sidebar.invite.reward.credits", { + amount: value.toLocaleString(i18n.language), + defaultValue: "{{amount}} 积分", + }); + }, + [i18n.language, inviteRewardCurrentPolicyLabel, t], + ); const handleThemeModeChange = useCallback((nextThemeMode: LimeThemeMode) => { const themeMode = persistLimeThemeMode(nextThemeMode); @@ -4378,7 +4669,7 @@ export function AppSidebar({ async (value: string | undefined, successMessage: string) => { const text = value?.trim(); if (!text) { - toast.info("暂无可复制内容"); + toast.info(inviteCopyEmptyLabel); return; } @@ -4389,10 +4680,10 @@ export function AppSidebar({ await navigator.clipboard.writeText(text); toast.success(successMessage); } catch { - toast.error("复制失败,请检查剪贴板权限"); + toast.error(inviteCopyFailedLabel); } }, - [], + [inviteCopyEmptyLabel, inviteCopyFailedLabel], ); const handleLanguageChange = useCallback( @@ -4480,14 +4771,14 @@ export function AppSidebar({ setInviteDashboard(cachedInviteDashboard); setInviteDialogOpen(true); }} - title="邀请好友" - aria-label="邀请好友" + title={inviteEntryLabel} + aria-label={inviteEntryLabel} data-testid="app-sidebar-invite-button" > - 邀请好友 + {inviteEntryLabel} , - "邀请好友", + inviteEntryLabel, ) : null} @@ -4590,8 +4881,8 @@ export function AppSidebar({ setLanguageMenuOpen(false); setAppearancePopoverOpen((current) => !current); }} - title="快速切换外观" - aria-label="快速切换外观" + title={appearanceEntryLabel} + aria-label={appearanceEntryLabel} aria-expanded={appearancePopoverOpen} aria-haspopup="dialog" > @@ -4602,7 +4893,9 @@ export function AppSidebar({ )} - 快速切换外观 + + {appearanceEntryLabel} + ) : ( !current); }} - title="快速切换外观" - aria-label="快速切换外观" + title={appearanceEntryLabel} + aria-label={appearanceEntryLabel} aria-expanded={appearancePopoverOpen} aria-haspopup="dialog" > @@ -4629,22 +4922,24 @@ export function AppSidebar({ - 外观 + {appearanceTitleLabel} - {currentThemeLabel} · {currentColorScheme.label} + {appearanceSummaryLabel} - 主题 + + {appearanceThemeGroupLabel} + - {LIME_THEME_MODE_OPTIONS.map((option) => { + {appearanceThemeOptions.map((option) => { const active = option.id === themeState.themeMode; return ( handleThemeModeChange(option.id)} > @@ -4665,18 +4966,20 @@ export function AppSidebar({ - 配色 + + {appearanceColorSchemeGroupLabel} + - 随机 + {appearanceRandomColorSchemeLabel} - {LIME_COLOR_SCHEMES.map((scheme) => { + {appearanceColorSchemes.map((scheme) => { const active = scheme.id === colorSchemeId; return ( handleColorSchemeChange(scheme.id)} > @@ -5168,26 +5477,23 @@ export function AppSidebar({ setInviteDialogOpen(false)} > - - {inviteShare?.brandName ?? accountTenantLabel ?? "Lime"} 邀请 - - 邀请好友 + {inviteEyebrowLabel} + {inviteDialogTitleLabel} - {inviteHeadline} - {!inviteLoading && !inviteError ? `。${inviteRules}` : ""} + {inviteDescriptionLabel} {!hasCloudAccount ? ( - {`连接 ${cloudBrandLabel} 后会生成专属邀请码,并自动读取当前品牌云端的域名和奖励策略。`} + {inviteDisconnectedLabel} - 连接云端账号 + {inviteConnectAccountLabel} ) : null} {hasCloudAccount && inviteLoading ? ( - 正在从云端同步邀请信息... + {inviteLoadingLabel} ) : null} {hasCloudAccount && inviteError ? ( @@ -5217,7 +5523,7 @@ export function AppSidebar({ onClick={() => setInviteReloadKey((value) => value + 1)} > - 重试 + {inviteRetryLabel} @@ -5230,7 +5536,7 @@ export function AppSidebar({ - 邀请码 + {inviteCodeLabel} {inviteShare?.code} void handleCopyInviteText( inviteShare?.code, - "已复制邀请码", + inviteCopyCodeSuccessLabel, ) } > - 复制 + {inviteCopyLabel} - 下载地址 + {inviteDownloadUrlLabel} {inviteShare?.downloadUrl} - 邀请链接 + {inviteLandingUrlLabel} {inviteShare?.landingUrl} - 邀请人奖励 + {inviteReferrerRewardLabel} - {formatReferralCredits( + {formatInviteReferralCredits( invitePolicy?.referrerRewardCredits, )} - 被邀请人奖励 + {inviteInviteeRewardLabel} - {formatReferralCredits( + {formatInviteReferralCredits( invitePolicy?.inviteeRewardCredits, )} @@ -5281,24 +5587,24 @@ export function AppSidebar({ onClick={() => void handleCopyInviteText( inviteShare?.shareText, - "已复制邀请文案", + inviteCopyShareTextSuccessLabel, ) } > - 复制邀请文案 + {inviteCopyShareTextLabel} void handleCopyInviteText( inviteShare?.landingUrl, - "已复制邀请链接", + inviteCopyLandingUrlSuccessLabel, ) } > - 复制邀请链接 + {inviteCopyLandingUrlLabel} diff --git a/src/components/ConfirmDialog.test.tsx b/src/components/ConfirmDialog.test.tsx new file mode 100644 index 000000000..01cb7e2e5 --- /dev/null +++ b/src/components/ConfirmDialog.test.tsx @@ -0,0 +1,78 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ConfirmDialog } from "./ConfirmDialog"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: { defaultValue?: string }) => + ( + { + "common.cancel": "Cancel", + "common.confirm": "Confirm", + "common.confirmDialog.title": "Confirm action", + } as Record + )[key] ?? + options?.defaultValue ?? + key, + }), +})); + +interface MountedDialog { + container: HTMLDivElement; + root: Root; +} + +const mountedDialogs: MountedDialog[] = []; + +function renderConfirmDialog() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + {}} + onConfirm={() => {}} + />, + ); + }); + + mountedDialogs.push({ container, root }); +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedDialogs.length > 0) { + const mounted = mountedDialogs.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + document.body.replaceChildren(); +}); + +describe("ConfirmDialog", () => { + it("未传入显式文案时使用 common namespace 默认 chrome", () => { + renderConfirmDialog(); + + expect(document.body.textContent).toContain("Confirm action"); + expect(document.body.textContent).toContain("Cancel"); + expect(document.body.textContent).toContain("Confirm"); + expect(document.body.textContent).toContain("This cannot be undone."); + }); +}); diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 621dc9396..80f580730 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -1,4 +1,5 @@ import { AlertTriangle } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Modal } from "./Modal"; interface ConfirmDialogProps { @@ -14,14 +15,15 @@ interface ConfirmDialogProps { export function ConfirmDialog({ isOpen, - title = "确认操作", + title, message, - confirmText = "确定", - cancelText = "取消", + confirmText, + cancelText, variant = "danger", onConfirm, onCancel, }: ConfirmDialogProps) { + const { t } = useTranslation("common"); const variantStyles = { danger: { icon: "text-red-500", @@ -38,6 +40,21 @@ export function ConfirmDialog({ }; const styles = variantStyles[variant]; + const resolvedTitle = + title ?? + t("common.confirmDialog.title", { + defaultValue: "确认操作", + }); + const resolvedConfirmText = + confirmText ?? + t("common.confirm", { + defaultValue: "确定", + }); + const resolvedCancelText = + cancelText ?? + t("common.cancel", { + defaultValue: "取消", + }); return (
-

{title}

+

{resolvedTitle}

{message}

@@ -61,13 +78,13 @@ export function ConfirmDialog({ onClick={onCancel} className="rounded-lg border px-4 py-2 text-sm hover:bg-muted" > - {cancelText} + {resolvedCancelText} diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx index 22ef373a4..c9e900df1 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx @@ -411,6 +411,60 @@ describe("AgentThreadReliabilityPanel", () => { ); }); + it("后端 pending 为 0 且存在 runtime_error 时应显示故障而不是本地旧待补", () => { + const container = renderPanel({ + threadRead: { + thread_id: "thread-1", + status: "running", + active_turn_id: "turn-2", + pending_requests: [], + incidents: [ + { + id: "incident-runtime-error", + thread_id: "thread-1", + turn_id: "turn-1", + incident_type: "runtime_error", + severity: "high", + status: "active", + title: "时间线记录到异常项", + details: + "Agent provider execution failed: Request failed with status 402 Payment Required", + }, + ], + }, + turns: [ + { + id: "turn-2", + thread_id: "thread-1", + prompt_text: "继续", + status: "running", + started_at: "2026-05-11T00:26:18Z", + created_at: "2026-05-11T00:26:18Z", + updated_at: "2026-05-11T00:26:24Z", + }, + ], + currentTurnId: "turn-2", + pendingActions: [ + { + requestId: "ask-turn-1", + actionType: "ask_user", + prompt: "请提供继续执行所需信息", + status: "pending", + scope: { + sessionId: "session-1", + threadId: "thread-1", + turnId: "turn-1", + }, + }, + ], + }); + + expect(container.textContent).toContain("时间线记录到异常项"); + expect(container.textContent).toContain("402 Payment Required"); + expect(container.textContent).not.toContain("当前线程正在等待人工处理"); + expect(container.textContent).not.toContain("优先响应当前待处理请求"); + }); + it("缺少 thread_read 时,应从当前 turn 与 pendingActions 推导并支持中断", async () => { const onInterruptCurrentTurn = vi.fn().mockResolvedValue(undefined); const container = renderPanel({ diff --git a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx index 76ccb3ffe..68d81c95c 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx @@ -1149,6 +1149,34 @@ describe("AgentThreadTimeline", () => { ); }); + it("运行中的单条 reasoning 应直接显示为一个思考块,避免摘要壳嵌套详情壳", () => { + const reasoningText = "正在梳理 PPT 大纲所需的关键输入。"; + const items: AgentThreadItem[] = [ + { + ...createBaseItem("reasoning-active-1", 1), + type: "reasoning", + status: "in_progress", + completed_at: undefined, + text: reasoningText, + }, + ]; + + const container = renderTimeline(items, { + isCurrentTurn: true, + turn: { + status: "running", + }, + }); + + expect( + container.querySelector( + '[data-testid="agent-thread-block:1:process:details"]', + ), + ).toBeNull(); + expect((container.textContent?.split("思考中").length ?? 1) - 1).toBe(1); + expect(container.textContent).toContain(reasoningText); + }); + it("已完成的单条思考应默认只保留摘要,展开后再显示完整正文", () => { const reasoningText = "先核对执行链路,再立即恢复当前运行。\n随后补齐自动续提。"; diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index 8b755be66..cbcf9990c 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -1552,10 +1552,13 @@ function TimelineBlockCard({ block.items.length === 1 && isThinkingTimelineItem(block.items[0]!) ? block.items[0]! : null; + const shouldRenderActiveSingleThinkingInline = + Boolean(singleThinkingItem) && block.status === "in_progress"; const shouldSummarizeSingleThinkingInline = Boolean(singleThinkingItem) && (singleThinkingItem?.type === "reasoning" || singleThinkingItem?.type === "turn_summary") && + !shouldRenderActiveSingleThinkingInline && !hasStructuredThinkingInlinePreview(singleThinkingItem); const shouldRenderSingleItemInline = block.items.length === 1 && diff --git a/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.test.tsx b/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.test.tsx index c9efb376a..7749b98ed 100644 --- a/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.test.tsx @@ -233,8 +233,10 @@ describe("AgentThreadTimelineArtifactCard", () => { expect(badge).not.toBeNull(); expect(badge?.textContent).toContain("AgentUI Artifact 预览"); expect(badge?.getAttribute("title")).toContain( - "conversationProjectionStore.agentUi", + "Agent UI 标准投影", ); + expect(badge?.getAttribute("title")).toContain("产物快照"); + expect(badge?.getAttribute("title")).not.toContain("artifact_snapshot"); }); it("Markdown 文件产物应明确标识为可沉淀的 Document 产物", () => { diff --git a/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.tsx b/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.tsx index 3cfaf1ed1..b22187d8e 100644 --- a/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.tsx +++ b/src/components/agent/chat/components/AgentThreadTimelineArtifactCard.tsx @@ -20,6 +20,7 @@ import { useLatestAgentUiProjectionEventForArtifact } from "../projection/useCon import { formatAgentUiProjectionEventType, formatAgentUiProjectionPhase, + formatAgentUiProjectionSourceType, } from "../projection/agentUiProjectionSummary"; interface AgentThreadTimelineArtifactCardProps { @@ -451,8 +452,10 @@ export function AgentThreadTimelineArtifactCard({ className="border-sky-200 bg-white text-sky-700" data-testid="timeline-file-artifact-agentui" title={[ - "conversationProjectionStore.agentUi", - latestArtifactProjection.sourceType, + "Agent UI 标准投影", + formatAgentUiProjectionSourceType( + latestArtifactProjection.sourceType, + ), formatAgentUiProjectionPhase( latestArtifactProjection.phase, ), diff --git a/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.test.tsx b/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.test.tsx index 1acf634b3..7d7ee332e 100644 --- a/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.test.tsx +++ b/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.test.tsx @@ -121,7 +121,7 @@ describe("AgentUiTeamWorkbenchSurfaceView", () => { const container = renderSurfaceView({ events: buildEvents() }); expect(container.textContent).toContain("工作台操作视图"); - expect(container.textContent).toContain("3 items"); + expect(container.textContent).toContain("3 项"); expect(container.textContent).toContain("注意 2"); expect(container.textContent).toContain("Board"); expect(container.textContent).toContain("Review"); diff --git a/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.tsx b/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.tsx index a6c312c79..2a7104316 100644 --- a/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.tsx +++ b/src/components/agent/chat/components/AgentUiTeamWorkbenchSurfaceView.tsx @@ -73,13 +73,12 @@ export function AgentUiTeamWorkbenchSurfaceView({ 工作台操作视图
- 按 Agent UI v0.6 surface - 展示可操作目标,避免在组件内重新推断队友状态。 + 按 Agent UI v0.6 工作区展示可操作目标,避免在组件内重新推断队友状态。
- {model.total} items + {model.total} 项 {model.attentionCount > 0 ? ( diff --git a/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx b/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx index f8ec3e35a..bad4ff4ad 100644 --- a/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx +++ b/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx @@ -15,6 +15,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { summarizeCuratedTaskFollowUpActions, + buildCuratedTaskTemplateCopy, summarizeCuratedTaskOutputContract, summarizeCuratedTaskRequiredInputs, findCuratedTaskTemplateById, @@ -88,6 +89,13 @@ export function CuratedTaskLauncherDialog({ }: CuratedTaskLauncherDialogProps) { const { i18n, t } = useTranslation("agent"); const locale = i18n.language; + const curatedTaskTemplateCopy = useMemo( + () => + buildCuratedTaskTemplateCopy((key, defaultValue, values) => + t(key, { defaultValue, ...values }), + ), + [t], + ); const [inputValues, setInputValues] = useState({}); const [referenceEntries, setReferenceEntries] = useState< CuratedTaskReferenceEntry[] @@ -375,8 +383,8 @@ export function CuratedTaskLauncherDialog({ return null; } - return findCuratedTaskTemplateById(suggestedTaskId); - }, [reviewFeedbackProjection]); + return findCuratedTaskTemplateById(suggestedTaskId, curatedTaskTemplateCopy); + }, [curatedTaskTemplateCopy, reviewFeedbackProjection]); const activeReviewBaselineSnapshot = useMemo(() => { if (!task) { diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index 9309e57f1..198992be3 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -1221,7 +1221,6 @@ describe("EmptyState", () => { const onOpenMemoryWorkbench = vi.fn(); const onOpenChannels = vi.fn(); const onOpenChromeRelay = vi.fn(); - const onOpenOpenClaw = vi.fn(); const container = renderEmptyState({ activeTheme: "general", runtimeToolAvailability: { @@ -1256,7 +1255,6 @@ describe("EmptyState", () => { onOpenMemoryWorkbench, onOpenChannels, onOpenChromeRelay, - onOpenOpenClaw, }); await act(async () => { @@ -1276,12 +1274,10 @@ describe("EmptyState", () => { ); expect(container.textContent).not.toContain("频道入口"); expect(container.textContent).not.toContain("浏览器连接器"); - expect(container.textContent).not.toContain("OpenClaw 兼容入口"); expect(container.textContent).not.toContain("打开记忆工作台"); expect(onOpenChannels).not.toHaveBeenCalled(); expect(onOpenChromeRelay).not.toHaveBeenCalled(); - expect(onOpenOpenClaw).not.toHaveBeenCalled(); expect(onOpenMemoryWorkbench).not.toHaveBeenCalled(); }); diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index eb6b53255..1b91da819 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -6,6 +6,7 @@ import React, { useState, } from "react"; import styled, { keyframes } from "styled-components"; +import { useTranslation } from "react-i18next"; import { getConfig } from "@/lib/api/appConfig"; import { getSkillCatalog, @@ -24,6 +25,7 @@ import { } from "../utils/contextualRecommendations"; import { buildCuratedTaskLaunchPrompt, + buildCuratedTaskTemplateCopy, findCuratedTaskTemplateById, listCuratedTaskTemplates, recordCuratedTaskTemplateUsage, @@ -394,8 +396,6 @@ interface EmptyStateProps extends SkillSelectionSourceProps { onOpenChannels?: () => void; /** 打开浏览器连接器 */ onOpenChromeRelay?: () => void; - /** 打开 OpenClaw 兼容入口 */ - onOpenOpenClaw?: () => void; /** 当前带入的 creation replay 前台投影 */ creationReplaySurface?: CreationReplaySurfaceModel | null; /** 当前结果模板默认带入的 memory 引用 id */ @@ -529,6 +529,14 @@ export const EmptyState: React.FC = ({ fileManagerOpen = false, onToggleFileManager, }) => { + const { t } = useTranslation("agent"); + const curatedTaskTemplateCopy = useMemo( + () => + buildCuratedTaskTemplateCopy((key, defaultValue, values) => + t(key, { defaultValue, ...values }), + ), + [t], + ); const pageContainerRef = useRef(null); const [activeCapability, setActiveCapability] = useState(null); @@ -759,8 +767,12 @@ export const EmptyState: React.FC = ({ const curatedTaskTemplates = useMemo(() => { void curatedTaskTemplatesVersion; void curatedTaskRecommendationSignalsVersion; - return listCuratedTaskTemplates(); - }, [curatedTaskRecommendationSignalsVersion, curatedTaskTemplatesVersion]); + return listCuratedTaskTemplates(curatedTaskTemplateCopy); + }, [ + curatedTaskRecommendationSignalsVersion, + curatedTaskTemplateCopy, + curatedTaskTemplatesVersion, + ]); const selectedTextPreview = useMemo(() => { const normalized = (recommendationSelectedText || "") @@ -1073,7 +1085,8 @@ export const EmptyState: React.FC = ({ } const resolvedTemplate = - findCuratedTaskTemplateById(template.id) ?? template; + findCuratedTaskTemplateById(template.id, curatedTaskTemplateCopy) ?? + template; const launchPrompt = buildCuratedTaskLaunchPrompt({ task: resolvedTemplate, inputValues, @@ -1107,6 +1120,7 @@ export const EmptyState: React.FC = ({ [ activeCuratedTask, appendSelectedTextToRecommendation, + curatedTaskTemplateCopy, handleThemeChange, input, onLaunchBrowserAssist, @@ -1294,7 +1308,10 @@ export const EmptyState: React.FC = ({ const handleSelectHomeSkillItem = useCallback( (item: HomeSkillSurfaceItem) => { if (item.launchKind === "curated_task_launcher") { - const template = findCuratedTaskTemplateById(item.id); + const template = findCuratedTaskTemplateById( + item.id, + curatedTaskTemplateCopy, + ); if (!template) { return; } @@ -1358,6 +1375,7 @@ export const EmptyState: React.FC = ({ } }, [ + curatedTaskTemplateCopy, effectiveDefaultCuratedTaskReferenceEntries, effectiveDefaultCuratedTaskReferenceMemoryIds, handleCuratedTaskLauncherRequest, @@ -1399,7 +1417,10 @@ export const EmptyState: React.FC = ({ : null; if (targetItem) { if (targetItem.launchKind === "curated_task_launcher") { - const template = findCuratedTaskTemplateById(targetItem.id); + const template = findCuratedTaskTemplateById( + targetItem.id, + curatedTaskTemplateCopy, + ); if (!template) { return; } @@ -1426,6 +1447,7 @@ export const EmptyState: React.FC = ({ } }, [ + curatedTaskTemplateCopy, effectiveDefaultCuratedTaskReferenceEntries, effectiveDefaultCuratedTaskReferenceMemoryIds, handleSelectHomeSkillItem, diff --git a/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx b/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx index c9b8c1028..ba93e21f4 100644 --- a/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx +++ b/src/components/agent/chat/components/GeneralWorkbenchSidebar.test.tsx @@ -44,6 +44,31 @@ vi.mock("sonner", () => ({ }, })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + i18n: { language: "zh-CN" }, + t: ( + key: string, + defaultValueOrOptions?: + | string + | ({ defaultValue?: string } & Record), + ) => { + if (typeof defaultValueOrOptions === "string") { + return defaultValueOrOptions; + } + + if ( + defaultValueOrOptions && + typeof defaultValueOrOptions.defaultValue === "string" + ) { + return defaultValueOrOptions.defaultValue; + } + + return key; + }, + }), +})); + beforeEach(() => { ( globalThis as typeof globalThis & { diff --git a/src/components/agent/chat/components/GeneralWorkbenchWorkflowPanel.tsx b/src/components/agent/chat/components/GeneralWorkbenchWorkflowPanel.tsx index bd7bdaeb0..cd792b209 100644 --- a/src/components/agent/chat/components/GeneralWorkbenchWorkflowPanel.tsx +++ b/src/components/agent/chat/components/GeneralWorkbenchWorkflowPanel.tsx @@ -1,4 +1,5 @@ import React, { memo, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { AlertCircle, ChevronDown, @@ -38,7 +39,9 @@ import { } from "../utils/curatedTaskRecommendationSignals"; import { buildCuratedTaskLaunchInputPrefillFromReferenceEntries } from "../utils/curatedTaskReferenceSelection"; import { + buildReviewFeedbackProjectionCopy, buildReviewFeedbackProjection, + formatReviewFeedbackTemplate, type ReviewFeedbackProjection, } from "../utils/reviewFeedbackProjection"; import { @@ -591,6 +594,7 @@ function ReviewFeedbackProjectionCard({ className?: string; onApplyAction?: () => void; }) { + const { t } = useTranslation("agent"); const primarySuggestedTask = projection.suggestedTasks[0] ?? null; return ( @@ -603,7 +607,7 @@ function ReviewFeedbackProjectionCard({ >
- 围绕最近判断 + {t("reviewFeedback.badge", "围绕最近判断")} {!projection.matchedCurrentTask && projection.suggestedTaskTitles.length > 0 ? ( @@ -613,7 +617,13 @@ function ReviewFeedbackProjectionCard({ ) : null}
- 最近判断已更新:{projection.signal.title} + {formatReviewFeedbackTemplate( + t("reviewFeedback.title", { + defaultValue: "最近判断已更新:{{title}}", + title: projection.signal.title, + }), + { title: projection.signal.title }, + )}
{projection.signal.summary} @@ -629,10 +639,19 @@ function ReviewFeedbackProjectionCard({ data-testid={dataTestId ? `${dataTestId}-action` : undefined} onClick={onApplyAction} > - 继续去「{primarySuggestedTask.title}」 + {formatReviewFeedbackTemplate( + t("reviewFeedback.action", { + defaultValue: "继续去「{{title}}」", + title: primarySuggestedTask.title, + }), + { title: primarySuggestedTask.title }, + )} - 会继续带着这轮结果与参考对象,不用重新整理一遍。 + {t( + "reviewFeedback.helper.generalWorkbench", + "会继续带着这轮结果与参考对象,不用重新整理一遍。", + )}
) : null} @@ -1283,6 +1302,7 @@ function GeneralWorkbenchWorkflowPanelComponent({ onRevealArtifactInFinder, onOpenArtifactWithDefaultApp, }: GeneralWorkbenchWorkflowPanelProps) { + const { t } = useTranslation("agent"); const [recommendationSignalsVersion, setRecommendationSignalsVersion] = useState(0); @@ -1377,11 +1397,12 @@ function GeneralWorkbenchWorkflowPanelComponent({ const reviewFeedbackProjection = useMemo( () => buildReviewFeedbackProjection({ + copy: buildReviewFeedbackProjectionCopy(t), signal: latestReviewSignal, currentTaskId: runMetadataSummary.curatedTask?.taskId, currentTaskTitle: runMetadataSummary.curatedTask?.taskTitle, }), - [latestReviewSignal, runMetadataSummary.curatedTask], + [latestReviewSignal, runMetadataSummary.curatedTask, t], ); const reviewFeedbackFollowUpActionPayload = useMemo(() => { if (!reviewFeedbackProjection) { diff --git a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx index ccf0c8e14..4159f7020 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx @@ -847,8 +847,14 @@ describe("HarnessStatusPanel", () => { expect(document.body.textContent).toContain("Artifact"); expect(document.body.textContent).toContain("Evidence"); expect(document.body.textContent).toContain("Diagnostics"); + expect(document.body.textContent).toContain("队列更新"); + expect(document.body.textContent).toContain("产物快照"); + expect(document.body.textContent).toContain("运行状态"); expect(document.body.textContent).toContain("steer_intent"); expect(document.body.textContent).toContain("gaps_present"); + expect(document.body.textContent).not.toContain("queue_added"); + expect(document.body.textContent).not.toContain("artifact_snapshot"); + expect(document.body.textContent).not.toContain("runtime_status"); expect(document.body.textContent).not.toContain("other session marker"); }); diff --git a/src/components/agent/chat/components/HarnessStatusPanel.tsx b/src/components/agent/chat/components/HarnessStatusPanel.tsx index 9b950f26a..3a4e0876d 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.tsx @@ -92,6 +92,7 @@ import { formatAgentUiProjectionEventDetail, formatAgentUiProjectionEventType, formatAgentUiProjectionPhase, + formatAgentUiProjectionSourceType, summarizeAgentUiProjectionEvents, } from "../projection/agentUiProjectionSummary"; import { @@ -5855,8 +5856,10 @@ export function HarnessStatusPanel({ ) : null}
- - {event.sourceType} + + {formatAgentUiProjectionSourceType( + event.sourceType, + )} · {formatAgentUiProjectionEventDetail(event)} diff --git a/src/components/agent/chat/components/MessageList.test.tsx b/src/components/agent/chat/components/MessageList.test.tsx index 39f3866ef..b8ca7ef23 100644 --- a/src/components/agent/chat/components/MessageList.test.tsx +++ b/src/components/agent/chat/components/MessageList.test.tsx @@ -151,6 +151,7 @@ const mockAgentThreadTimeline = vi.fn( actionRequests, onOpenSavedSiteContent, placement, + turn, }: { actionRequests?: Array>; onOpenSavedSiteContent?: (target: { @@ -160,10 +161,12 @@ const mockAgentThreadTimeline = vi.fn( }) => void; deferCompletedSingleDetails?: boolean; placement?: "leading" | "trailing" | "default"; + turn?: { id?: string } | null; }) => (
执行轨迹{actionRequests?.length ? `:${actionRequests.length}` : ""}
@@ -3731,6 +3734,66 @@ describe("MessageList", () => { ); }); + it("流式正文已出现但过程由 timeline 承载时,应把思考区放在正文气泡外", () => { + const now = new Date("2026-05-11T09:40:00.000Z"); + const messages: Message[] = [ + { + id: "msg-user-streaming-timeline-process", + role: "user", + content: "帮我做 PPT 大纲,先确认关键信息", + timestamp: now, + }, + { + id: "msg-assistant-streaming-timeline-process", + role: "assistant", + content: "好的,要帮您做 PPT 大纲,我先确认几个关键点。", + timestamp: now, + isThinking: true, + }, + ]; + + const container = render(messages, { + currentTurnId: "turn-streaming-timeline-process", + turns: [ + { + id: "turn-streaming-timeline-process", + thread_id: "thread-streaming-timeline-process", + prompt_text: "帮我做 PPT 大纲,先确认关键信息", + status: "running", + started_at: "2026-05-11T09:40:00.000Z", + created_at: "2026-05-11T09:40:00.000Z", + updated_at: "2026-05-11T09:40:02.000Z", + }, + ], + threadItems: [ + { + id: "reasoning-streaming-timeline-process", + thread_id: "thread-streaming-timeline-process", + turn_id: "turn-streaming-timeline-process", + sequence: 1, + status: "in_progress", + started_at: "2026-05-11T09:40:01.000Z", + updated_at: "2026-05-11T09:40:02.000Z", + type: "reasoning", + text: "正在判断需要补充哪些 PPT 输入。", + }, + ], + }); + + expect( + container.querySelector('[data-testid="assistant-primary-timeline-shell"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-timeline:leading"]'), + ).not.toBeNull(); + expect(mockStreamingRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + content: "好的,要帮您做 PPT 大纲,我先确认几个关键点。", + thinkingContent: undefined, + }), + ); + }); + it("已完成旧消息残留 runtimeStatus 时不应把思考过程重复塞回正文", () => { const now = new Date(); const messages: Message[] = [ @@ -5549,4 +5612,110 @@ describe("MessageList", () => { ).toBeNull(); expect(timelineNodes).toHaveLength(1); }); + + it("继续回合的执行过程应挂在第二次对话组而不是第一次失败组", () => { + const messages: Message[] = [ + { + id: "msg-user-first", + role: "user", + content: "帮我做一份 PPT 大纲", + timestamp: new Date("2026-05-11T00:20:46Z"), + }, + { + id: "msg-assistant-first", + role: "assistant", + content: "执行失败:402 Payment Required", + timestamp: new Date("2026-05-11T00:20:47Z"), + runtimeTurnId: "turn-first", + }, + { + id: "msg-user-continue", + role: "user", + content: "继续", + timestamp: new Date("2026-05-11T00:26:18Z"), + }, + { + id: "msg-assistant-continue", + role: "assistant", + content: "好的", + timestamp: new Date("2026-05-11T00:26:19Z"), + runtimeTurnId: "turn-continue", + }, + ]; + + const container = render(messages, { + currentTurnId: "turn-continue", + turns: [ + { + id: "turn-first", + thread_id: "thread-1", + prompt_text: "帮我做一份 PPT 大纲", + status: "failed", + started_at: "2026-05-11T00:20:46Z", + completed_at: "2026-05-11T00:20:47Z", + created_at: "2026-05-11T00:20:46Z", + updated_at: "2026-05-11T00:20:47Z", + }, + { + id: "turn-continue", + thread_id: "thread-1", + prompt_text: "继续", + status: "completed", + started_at: "2026-05-11T00:26:18Z", + completed_at: "2026-05-11T00:26:24Z", + created_at: "2026-05-11T00:26:18Z", + updated_at: "2026-05-11T00:26:24Z", + }, + ], + threadItems: [ + { + id: "error-first", + thread_id: "thread-1", + turn_id: "turn-first", + sequence: 1, + status: "failed", + started_at: "2026-05-11T00:20:47Z", + updated_at: "2026-05-11T00:20:47Z", + type: "error", + message: "Agent provider execution failed: 402 Payment Required", + }, + { + id: "process-continue", + thread_id: "thread-1", + turn_id: "turn-continue", + sequence: 1, + status: "completed", + started_at: "2026-05-11T00:26:19Z", + completed_at: "2026-05-11T00:26:24Z", + updated_at: "2026-05-11T00:26:24Z", + type: "plan", + text: "等待用户补充 PPT 信息", + }, + ], + }); + + const firstAssistant = Array.from( + container.querySelectorAll('[data-testid="streaming-renderer"]'), + ).find((node) => node.textContent?.includes("402 Payment Required")); + const continueAssistant = Array.from( + container.querySelectorAll('[data-testid="streaming-renderer"]'), + ).find((node) => node.textContent?.includes("好的")); + const continueTimeline = container.querySelector( + '[data-testid^="agent-thread-timeline:"][data-turn-id="turn-continue"]', + ); + + expect(firstAssistant).toBeTruthy(); + expect(continueAssistant).toBeTruthy(); + expect(continueTimeline).toBeTruthy(); + expect( + (continueTimeline as Node).compareDocumentPosition( + continueAssistant as Node, + ) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + (continueTimeline as Node).compareDocumentPosition( + firstAssistant as Node, + ) & Node.DOCUMENT_POSITION_PRECEDING, + ).toBeTruthy(); + }); }); diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index ccfd24b37..e0f1def53 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -1429,11 +1429,13 @@ const MessageListInner: React.FC = ({ lastAssistantMessageId, timelineByMessageId, renderedThreadItems, + renderedMessages, }); }, [ activeCurrentTurn, activeCurrentTurnId, lastAssistantMessageId, + renderedMessages, renderedThreadItems, timelineByMessageId, ]); @@ -2404,8 +2406,7 @@ const MessageListInner: React.FC = ({ const shouldRenderPrimaryTimelineOutsideBubble = msg.role === "assistant" && Boolean(primaryTimelineNode) && - hasVisibleAssistantText && - !msg.isThinking; + hasVisibleAssistantText; return ( { }); expect(container.textContent).toContain("Agent UI v0.6"); - expect(container.textContent).toContain("10 events"); + expect(container.textContent).toContain("10 条事件"); expect(container.textContent).toContain("Team 拓扑"); expect(container.textContent).toContain("Worker 流"); expect(container.textContent).toContain("Review / Handoff"); expect(container.textContent).toContain("工作台操作视图"); - expect(container.textContent).toContain("10 items"); + expect(container.textContent).toContain("10 项"); expect(container.textContent).toContain("注意 1"); - expect(container.textContent).toContain("Surface 专门视图"); + expect(container.textContent).toContain("工作区专门视图"); expect(container.textContent).toContain("Roster"); expect(container.textContent).toContain("成员、角色、来源与当前状态"); - expect(container.textContent).toContain("delegation_graph"); - expect(container.textContent).toContain("work_board"); - expect(container.textContent).toContain("worker_notifications"); - expect(container.textContent).toContain("teammate_transcript"); - expect(container.textContent).toContain("background_teammate"); - expect(container.textContent).toContain("remote_teammate"); - expect(container.textContent).toContain("handoff_lane"); - expect(container.textContent).toContain("review_lane"); + expect(container.textContent).toContain("分派关系"); + expect(container.textContent).toContain("任务板"); + expect(container.textContent).toContain("执行通知"); + expect(container.textContent).toContain("队友记录"); + expect(container.textContent).toContain("后台队友"); + expect(container.textContent).toContain("远程队友"); + expect(container.textContent).toContain("交接"); + expect(container.textContent).toContain("评审"); expect(container.textContent).toContain("决策:pending_review"); expect(container.textContent).toContain("风险:high"); expect(container.textContent).toContain("清单 2"); expect(container.textContent).toContain("修复:复核权限确认"); expect(container.textContent).toContain("回归项:npm run test:contracts"); - expect(container.textContent).toContain("team_policy"); + expect(container.textContent).toContain("团队策略"); expect(container.textContent).toContain("Review 请求"); expect(container.textContent).toContain("Agent 交接"); @@ -446,10 +446,10 @@ describe("TeamWorkbenchSummaryPanel", () => { }); expect(container.textContent).toContain("已定位工作台目标"); expect(container.textContent).toContain("请求审核 · review-1"); - expect(container.textContent).toContain("review_lane"); + expect(container.textContent).toContain("评审"); expect(container.textContent).toContain("Review:review-1"); expect(container.textContent).toContain( - "不从文本推断状态,也不伪造 remote / review / handoff 运行时调用", + "不从文本推断状态,也不伪造远端、评审或交接运行时调用", ); const transcriptAction = container.querySelector( @@ -475,9 +475,9 @@ describe("TeamWorkbenchSummaryPanel", () => { }, }); expect(container.textContent).toContain("打开详情 · child-1:turn-child-1"); - expect(container.textContent).toContain("Transcript Zoom"); + expect(container.textContent).toContain("队友记录详情"); expect(container.textContent).toContain( - "Transcript Ref:child-1:turn-child-1", + "记录引用:child-1:turn-child-1", ); expect(container.textContent).toContain("子会话:child-1"); expect(container.textContent).toContain("最新回合:turn-child-1"); @@ -491,15 +491,15 @@ describe("TeamWorkbenchSummaryPanel", () => { expect(container.textContent).toContain("1 条"); expect(container.textContent).toContain("结构化 Drilldown"); expect(container.textContent).toContain("工具活动 1 条"); - expect(container.textContent).toContain("tool_call"); + expect(container.textContent).toContain("工具调用"); expect(container.textContent).toContain("读取文件"); expect(container.textContent).toContain("已读取 Agent UI 标准文档"); expect(container.textContent).toContain("相关队友链路"); expect(container.textContent).toContain("不合成新运行时事实"); - expect(container.textContent).toContain("worker_notifications"); + expect(container.textContent).toContain("执行通知"); expect(container.textContent).toContain("child-1:completed"); expect(container.textContent).toContain("不把队友输出混进主"); - expect(container.textContent).toContain("final answer"); + expect(container.textContent).toContain("主回复"); }); it("应把工作台 action route 结果显示为可见状态", () => { @@ -543,7 +543,7 @@ describe("TeamWorkbenchSummaryPanel", () => { expect(container.textContent).toContain("请求审核 · review-route-1"); expect(container.textContent).toContain("审核未接入"); expect(container.textContent).toContain( - "review lane 已定位;真实审核 callback / reassignment 写回仍需后续接入。", + "评审记录已定位;真实审核回调与重指派写回仍需后续接入。", ); }); @@ -595,7 +595,7 @@ describe("TeamWorkbenchSummaryPanel", () => { expect(onWorkbenchAction).toHaveBeenCalledTimes(1); expect(container.textContent).toContain("远端任务已定位"); expect(container.textContent).toContain( - "已定位结构化 remote_task source;当前只展示来源、状态与结果引用,不伪造远端运行时控制。", + "已定位结构化远端任务记录;当前只展示来源、状态与结果引用,不伪造远端运行时控制。", ); expect(container.textContent).not.toContain("远端未接入"); }); @@ -649,7 +649,7 @@ describe("TeamWorkbenchSummaryPanel", () => { expect(onWorkbenchAction).toHaveBeenCalledTimes(1); expect(container.textContent).toContain("交接已定位"); expect(container.textContent).toContain( - "已定位结构化 handoff_lane source;当前只展示 handoff 生命周期,不从文本伪造跨 Agent 控制。", + "已定位结构化交接记录;当前只展示交接生命周期,不从文本伪造跨 Agent 控制。", ); expect(container.textContent).not.toContain("交接未接入"); }); @@ -887,23 +887,23 @@ describe("TeamWorkbenchSummaryPanel", () => { expect(container.textContent).toContain("工具活动 1 条"); expect(container.textContent).toContain("近期消息 1 条"); expect(container.textContent).toContain( - "这里只消费子会话详情里的 queued_turns 与 thread item 类型", + "这里只消费子会话详情里的输入队列与历史正文类型", ); expect(container.textContent).toContain( "继续处理 Agent UI transcript drilldown", ); expect(container.textContent).toContain("图片 1"); expect(container.textContent).toContain("请选择修复策略"); - expect(container.textContent).toContain("request_user_input"); + expect(container.textContent).toContain("等待补充"); expect(container.textContent).toContain("工具活动"); - expect(container.textContent).toContain("tool_call"); + expect(container.textContent).toContain("工具调用"); expect(container.textContent).toContain("子会话进展"); expect(container.textContent).toContain("历史正文 3 条"); expect(container.textContent).toContain("回复"); expect(container.textContent).toContain( "历史正文:已完成差异梳理,并整理出两个未对齐项。", ); - expect(container.textContent).toContain("不把队友输出混进主 final answer"); + expect(container.textContent).toContain("不把队友输出混进主回复"); }); it("应在 Team Workbench 中展示 reassignment live board update", () => { @@ -975,10 +975,10 @@ describe("TeamWorkbenchSummaryPanel", () => { expect(container.textContent).toContain("已定位工作台目标"); expect(container.textContent).toContain("工作项已定位"); expect(container.textContent).toContain( - "已定位结构化 work_board source;可通过负责人选择器回填 TaskUpdate 指令,等待 owner_change source 确认。", + "已定位结构化任务板记录;可通过负责人选择器回填负责人更新指令,等待运行时确认负责人变化。", ); expect(container.textContent).toContain("重新指派 · work-item-2"); - expect(container.textContent).toContain("work_board"); + expect(container.textContent).toContain("任务板"); }); it("应为 work_board source fact 提供重指派 selector 并回填 TaskUpdate 指令", async () => { @@ -1055,9 +1055,9 @@ describe("TeamWorkbenchSummaryPanel", () => { ); expect(selector).not.toBeNull(); expect(container.textContent).toContain("负责人重指派"); - expect(container.textContent).toContain("TaskUpdate owner_change"); + expect(container.textContent).toContain("负责人更新"); expect(container.textContent).toContain( - "仍以 TaskUpdate 返回的 owner_change / ownerChange metadata 作为唯一负责人变化 source", + "仍以运行时返回的负责人变化作为唯一事实", ); await act(async () => { @@ -1089,7 +1089,7 @@ describe("TeamWorkbenchSummaryPanel", () => { expect(onWorkbenchReassign.mock.calls[0]?.[1]).toBe("复核员"); expect(container.textContent).toContain("重指派已回填"); expect(container.textContent).toContain( - "TaskUpdate owner 更新指令已回填;发送后由真实 owner_change source 确认负责人变化。", + "负责人更新指令已回填;发送并执行后,以运行时返回的负责人变化为准。", ); }); @@ -1171,13 +1171,13 @@ describe("TeamWorkbenchSummaryPanel", () => { expect(container.textContent).toContain("已定位工作台目标"); expect(container.textContent).toContain("工作项未接入"); expect(container.textContent).toContain( - "真实 board/team API 写回或执行发起接入前不伪造工作项状态。", + "真实任务板或团队 API 写回接入前不伪造工作项状态。", ); expect(container.textContent).toContain(`结果引用:${resultRef}`); expect(container.textContent).toContain(`Artifact paths:${artifactPath}`); expect(container.textContent).toContain("相关队友链路"); - expect(container.textContent).toContain("artifact ref"); - expect(container.textContent).toContain("artifact_workspace"); + expect(container.textContent).toContain("产物引用"); + expect(container.textContent).toContain("产物工作区"); expect(container.textContent).toContain("runtime-json"); }); }); diff --git a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx index 87b1a88b1..19e6f1802 100644 --- a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx +++ b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx @@ -147,7 +147,7 @@ function buildWorkbenchActionRouteStatus( return { label: "只定位", detail: - "该目标已在 Agent UI 标准 surface 中定位,当前没有可执行的本地 child session handler。", + "该目标已在 Agent UI 标准工作区中定位,当前没有可执行的本地子任务处理器。", className: "border-sky-200 bg-sky-50 text-sky-700", }; case "seeded_work_item": @@ -168,62 +168,62 @@ function buildWorkbenchActionRouteStatus( return { label: "重指派已回填", detail: - "TaskUpdate owner 更新指令已回填;发送后由真实 owner_change source 确认负责人变化。", + "负责人更新指令已回填;发送并执行后,以运行时返回的负责人变化为准。", className: "border-emerald-200 bg-emerald-50 text-emerald-700", }; case "work_item_source_located": return { label: "工作项已定位", detail: - "已定位结构化 work_board source;可通过负责人选择器回填 TaskUpdate 指令,等待 owner_change source 确认。", + "已定位结构化任务板记录;可通过负责人选择器回填负责人更新指令,等待运行时确认负责人变化。", className: "border-sky-200 bg-sky-50 text-sky-700", }; case "remote_task_source_located": return { label: "远端任务已定位", detail: - "已定位结构化 remote_task source;当前只展示来源、状态与结果引用,不伪造远端运行时控制。", + "已定位结构化远端任务记录;当前只展示来源、状态与结果引用,不伪造远端运行时控制。", className: "border-sky-200 bg-sky-50 text-sky-700", }; case "handoff_source_located": return { label: "交接已定位", detail: - "已定位结构化 handoff_lane source;当前只展示 handoff 生命周期,不从文本伪造跨 Agent 控制。", + "已定位结构化交接记录;当前只展示交接生命周期,不从文本伪造跨 Agent 控制。", className: "border-sky-200 bg-sky-50 text-sky-700", }; case "unsupported_remote": return { label: "远端未接入", detail: - "remote teammate 需要真实 remote ingress / A2A task source 后才能运行时控制。", + "远端队友需要接入真实远端入口或 A2A 任务记录后才能运行时控制。", className: "border-amber-200 bg-amber-50 text-amber-700", }; case "unsupported_review": return { label: "审核未接入", detail: - "review lane 已定位;真实审核 callback / reassignment 写回仍需后续接入。", + "评审记录已定位;真实审核回调与重指派写回仍需后续接入。", className: "border-amber-200 bg-amber-50 text-amber-700", }; case "unsupported_handoff": return { label: "交接未接入", detail: - "handoff lane 已定位;accepted / returned / resumed 真实 source 接入前不伪造状态。", + "交接记录已定位;真实接收、退回、恢复记录接入前不伪造状态。", className: "border-amber-200 bg-amber-50 text-amber-700", }; case "unsupported_work_item": return { label: "工作项未接入", detail: - "work_board 目标已定位;真实 board/team API 写回或执行发起接入前不伪造工作项状态。", + "任务板目标已定位;真实任务板或团队 API 写回接入前不伪造工作项状态。", className: "border-amber-200 bg-amber-50 text-amber-700", }; case "unsupported": return { label: "暂不支持", - detail: "该 Agent UI target 暂无可执行的本地运行时 handler。", + detail: "该 Agent UI 目标暂无可执行的本地运行时处理器。", className: "border-slate-200 bg-slate-50 text-slate-600", }; default: @@ -702,6 +702,59 @@ const RECENT_MESSAGE_SOURCE_TYPES = new Set([ "user_message", ]); +const TRANSCRIPT_ENTRY_SOURCE_LABELS: Record = { + agent_message: "Agent 回复", + approval_request: "等待确认", + command_execution: "命令执行", + message_fallback: "消息", + plan: "计划", + reasoning: "推理", + request_user_input: "等待补充", + tool_call: "工具调用", + turn_summary: "回合摘要", + user_message: "用户消息", + web_search: "联网搜索", +}; + +const AGENT_UI_SURFACE_LABELS: Record = { + artifact_workspace: "产物工作区", + background_teammate: "后台队友", + conversation: "对话", + delegation_graph: "分派关系", + diagnostics: "诊断", + handoff_lane: "交接", + hitl: "人工确认", + inline_process: "过程", + remote_teammate: "远程队友", + review_lane: "评审", + runtime_status: "运行状态", + session_tabs: "会话", + task_capsule: "任务", + team_policy: "团队策略", + team_roster: "成员", + teammate_transcript: "队友记录", + timeline_evidence: "证据", + tool_ui: "工具界面", + work_board: "任务板", + worker_notifications: "执行通知", +}; + +function formatTranscriptEntrySourceLabel(sourceType?: string | null): string { + const normalized = sourceType?.trim(); + if (!normalized) { + return "活动"; + } + return TRANSCRIPT_ENTRY_SOURCE_LABELS[normalized] ?? "活动"; +} + +function formatAgentUiSurfaceLabel(surface?: string | null): string { + const normalized = surface?.trim(); + if (!normalized) { + return "工作区"; + } + return AGENT_UI_SURFACE_LABELS[normalized] ?? "工作区"; +} + function matchesTranscriptEntrySource( entry: TeamWorkspaceActivityEntry, sourceTypes: Set, @@ -716,7 +769,7 @@ function buildTranscriptEntryGroups( { kind: "pending_input", label: "待处理输入", - description: "来自 request_user_input / approval_request 结构化 item。", + description: "等待你确认或补充的结构化请求。", entries: entries.filter((entry) => matchesTranscriptEntrySource(entry, PENDING_INPUT_SOURCE_TYPES), ), @@ -724,7 +777,7 @@ function buildTranscriptEntryGroups( { kind: "tool_activity", label: "工具活动", - description: "来自 tool_call / command_execution / web_search。", + description: "工具、命令和联网搜索的最新动作。", entries: entries.filter((entry) => matchesTranscriptEntrySource(entry, TOOL_ACTIVITY_SOURCE_TYPES), ), @@ -732,7 +785,7 @@ function buildTranscriptEntryGroups( { kind: "recent_messages", label: "近期消息", - description: "来自 user / agent / reasoning / plan 的有界预览。", + description: "用户消息、Agent 回复、推理和计划的有界预览。", entries: entries.filter((entry) => matchesTranscriptEntrySource(entry, RECENT_MESSAGE_SOURCE_TYPES), ), @@ -1273,7 +1326,7 @@ export function TeamWorkbenchSummaryPanel({ Agent UI v0.6 - {teamWorkbenchProjectionSummary.total} events + {teamWorkbenchProjectionSummary.total} 条事件
@@ -1288,7 +1341,9 @@ export function TeamWorkbenchSummaryPanel({
{card.value}
-
{card.hint}
+
+ {formatAgentUiSurfaceLabel(card.hint)} +
))} @@ -1322,7 +1377,11 @@ export function TeamWorkbenchSummaryPanel({ {formatAgentUiProjectionEventType(event.type)} - {event.surface ? {event.surface} : null} + {event.surface ? ( + + {formatAgentUiSurfaceLabel(event.surface)} + + ) : null}
{formatAgentUiProjectionEventDetail(event)} @@ -1356,14 +1415,14 @@ export function TeamWorkbenchSummaryPanel({ {selectedWorkbenchItem.event.surface ? ( - {selectedWorkbenchItem.event.surface} + {formatAgentUiSurfaceLabel( + selectedWorkbenchItem.event.surface, + )} ) : null}

- 这里只定位 Agent UI 标准 surface - 中的目标,不从文本推断状态,也不伪造 remote / review / handoff - 运行时调用。 + 这里只定位 Agent UI 标准工作区中的目标,不从文本推断状态,也不伪造远端、评审或交接运行时调用。

{selectedWorkbenchActionRouteStatus ? (
- TaskUpdate owner_change + 负责人更新

- 选择目标负责人后只回填 TaskUpdate 指令;发送并执行后,Agent - UI 仍以 TaskUpdate 返回的 owner_change / ownerChange - metadata 作为唯一负责人变化 source。 + 选择目标负责人后只回填负责人更新指令;发送并执行后,Agent + UI 仍以运行时返回的负责人变化作为唯一事实。

= ({ name: event.target.value, })) } - placeholder="例如:My API Provider" + placeholder={t( + "settings.providers.modelAdd.form.providerName.placeholder", + "例如:My API Provider", + )} className="h-12 rounded-[18px] border-slate-200 bg-white px-4" disabled={submitting} data-testid="model-provider-name-input" @@ -1425,13 +1768,27 @@ export const ModelAddPanel: React.FC = ({
- +
{[ - { type: "openai" as ProviderType, label: "OpenAI 格式" }, + { + type: "openai" as ProviderType, + label: t( + "settings.providers.modelAdd.form.apiFormat.openai", + "OpenAI 格式", + ), + }, { type: "anthropic-compatible" as ProviderType, - label: "Anthropic 格式", + label: t( + "settings.providers.modelAdd.form.apiFormat.anthropic", + "Anthropic 格式", + ), }, ].map((option) => (
@@ -1516,7 +1876,15 @@ export const ModelAddPanel: React.FC = ({
= ({ })) } placeholder={ - apiKeyRequired ? "输入 API 密钥" : "本地模型可留空" + apiKeyRequired + ? t( + "settings.providers.modelAdd.form.apiKey.placeholder", + "输入 API 密钥", + ) + : t( + "settings.providers.modelAdd.form.apiKey.optionalPlaceholder", + "本地模型可留空", + ) } className="h-12 rounded-[18px] border-slate-200 bg-white px-4 pr-11" disabled={submitting} @@ -1540,7 +1916,10 @@ export const ModelAddPanel: React.FC = ({ type="button" onClick={() => setShowApiKey((previous) => !previous)} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 transition hover:text-slate-700" - aria-label="显示或隐藏 API 密钥" + aria-label={t( + "settings.providers.modelAdd.form.apiKey.toggleVisibility", + "显示或隐藏 API 密钥", + )} > @@ -1551,10 +1930,16 @@ export const ModelAddPanel: React.FC = ({

- 只使用接口返回或你手动添加的模型,不再显示本地兜底模型。 + {t( + "settings.providers.modelAdd.models.description", + "只使用接口返回或你手动添加的模型,不再显示本地兜底模型。", + )}

@@ -1604,14 +1992,24 @@ export const ModelAddPanel: React.FC = ({ >
- 接口模型(显示 {suggestedApiModels.length} /{" "} - {availableApiModels.length} 个,点击添加) + {t( + "settings.providers.modelAdd.models.apiSuggestions", + { + visible: suggestedApiModels.length, + total: availableApiModels.length, + defaultValue: + "接口模型(显示 {{visible}} / {{total}} 个,点击添加)", + }, + )}
setApiModelQuery(event.target.value)} - placeholder="筛选接口模型" + placeholder={t( + "settings.providers.modelAdd.models.filterPlaceholder", + "筛选接口模型", + )} className="h-8 rounded-full border-slate-200 bg-white px-3 text-xs normal-case sm:w-[220px]" autoCapitalize="none" autoCorrect="off" @@ -1627,7 +2025,10 @@ export const ModelAddPanel: React.FC = ({ disabled={suggestedApiModels.length === 0} data-testid="api-model-add-all-button" > - 添加全部 + {t( + "settings.providers.modelAdd.models.action.addAll", + "添加全部", + )}
@@ -1653,7 +2054,10 @@ export const ModelAddPanel: React.FC = ({
) : (
- 没有匹配的接口模型。 + {t( + "settings.providers.modelAdd.models.noApiMatches", + "没有匹配的接口模型。", + )}
)}
@@ -1674,7 +2078,10 @@ export const ModelAddPanel: React.FC = ({ :: {index === 0 ? ( - 主模型 + {t( + "settings.providers.modelAdd.models.primaryBadge", + "主模型", + )} ) : null} @@ -1686,7 +2093,10 @@ export const ModelAddPanel: React.FC = ({ onClick={() => setMainModel(model)} className="text-xs font-medium text-slate-500 hover:text-slate-900" > - 设为主模型 + {t( + "settings.providers.modelAdd.models.action.setPrimary", + "设为主模型", + )} ) : null}
))} @@ -1711,7 +2124,10 @@ export const ModelAddPanel: React.FC = ({ addModelDraft(); } }} - placeholder="输入模型 ID,按 Enter 添加" + placeholder={t( + "settings.providers.modelAdd.models.draftPlaceholder", + "输入模型 ID,按 Enter 添加", + )} className="h-11 rounded-[16px] border-slate-200 bg-white px-4 normal-case" disabled={submitting} data-testid="model-draft-input" @@ -1725,7 +2141,10 @@ export const ModelAddPanel: React.FC = ({ data-testid="model-draft-add-button" > - 添加模型 + {t( + "settings.providers.modelAdd.models.action.add", + "添加模型", + )} @@ -1750,11 +2169,17 @@ export const ModelAddPanel: React.FC = ({ data-testid="model-activate-button" > {submitting ? ( - "正在测试连接..." + t( + "settings.providers.modelAdd.action.testingConnection", + "正在测试连接...", + ) ) : ( <> - 保存并测试 + {t( + "settings.providers.modelAdd.action.saveAndTest", + "保存并测试", + )} )} @@ -1768,7 +2193,7 @@ export const ModelAddPanel: React.FC = ({ data-testid="model-add-cancel-button" > - 完成添加 + {t("settings.providers.modelAdd.action.finish", "完成添加")} ); diff --git a/src/components/api-key-provider/ProviderSetting.tsx b/src/components/api-key-provider/ProviderSetting.tsx index 5a1461f6d..e1aac08ba 100644 --- a/src/components/api-key-provider/ProviderSetting.tsx +++ b/src/components/api-key-provider/ProviderSetting.tsx @@ -46,6 +46,7 @@ import { isFalProviderLike, isLikelyFalImageModel, isProviderApiKeyRequired, + type ProviderModelFetchStatusCopy, } from "./providerModelFetchHelpers"; // ============================================================================ @@ -357,6 +358,58 @@ const ProviderSettingBody: React.FC = ({ !testingConnection && modelList.length > 0 && (!providerApiKeyRequired || hasApiKey || canUseDraftApiKey); + const modelFetchUnsupportedMessage = useMemo(() => { + switch (provider.type) { + case "azure-openai": + return t( + "settings.providers.setting.feedback.modelFetch.unsupported.azureOpenai", + "Azure OpenAI 的模型枚举仍需单独适配资源端点与 API Version,当前不展示自动获取入口。", + ); + case "vertexai": + return t( + "settings.providers.setting.feedback.modelFetch.unsupported.vertexAi", + "Vertex AI 需要单独的云端认证与项目上下文,当前不展示自动获取入口。", + ); + case "aws-bedrock": + return t( + "settings.providers.setting.feedback.modelFetch.unsupported.awsBedrock", + "AWS Bedrock 需要专门的云凭证签名流程,当前不展示自动获取入口。", + ); + default: + return t( + "settings.providers.setting.feedback.modelFetch.unsupported.default", + "当前协议暂不支持自动获取最新模型,请手动添加模型 ID。", + ); + } + }, [provider.type, t]); + const modelFetchStatusCopy = useMemo( + () => ({ + responsesConfirmedImage: (imageModel) => + t( + "settings.providers.setting.feedback.modelFetch.responses.confirmedImage", + { + imageModel, + defaultValue: + "已确认 Responses 图片模型 {{imageModel}},该入口无需标准 /models 枚举,图片生成会走 Responses image_generation。", + }, + ), + responsesManualImage: t( + "settings.providers.setting.feedback.modelFetch.responses.manualImage", + "该 Responses 图片入口不提供标准 /models 枚举;请手动添加 gpt-images-2 或 gpt-image-2,图片生成会走 Responses image_generation。", + ), + falConfirmedModel: (modelId) => + t("settings.providers.setting.feedback.modelFetch.fal.confirmedModel", { + modelId, + defaultValue: + "已确认 Fal 模型 {{modelId}},Fal 不提供标准 /models 枚举,后续会使用手动声明的模型 ID。", + }), + falManualModel: t( + "settings.providers.setting.feedback.modelFetch.fal.manualModel", + "Fal 不提供标准 /models 枚举;当前模型优先级没有可用 Fal 图片模型,请手动添加 fal-ai/nano-banana-pro、fal-ai/flux-pro 或其他 fal-ai/... 模型 ID。", + ), + }), + [t], + ); const persistDraftApiKey = useCallback(async () => { const nextApiKey = apiKeyDraft.trim(); @@ -365,13 +418,18 @@ const ProviderSettingBody: React.FC = ({ } if (!onAddApiKey) { - throw new Error("当前页面缺少添加 API Key 的能力。"); + throw new Error( + t( + "settings.providers.setting.feedback.apiKey.addMissingCapability", + "当前页面缺少添加 API Key 的能力。", + ), + ); } await onAddApiKey(provider.id, nextApiKey); setApiKeyDraft(""); setApiKeyDirty(false); - }, [apiKeyDirty, apiKeyDraft, onAddApiKey, provider.id]); + }, [apiKeyDirty, apiKeyDraft, onAddApiKey, provider.id, t]); const applyModels = useCallback( async (nextModels: string[]) => { @@ -433,9 +491,7 @@ const ProviderSettingBody: React.FC = ({ if (!modelAutoFetchCapability.supported) { setModelFetchStatus({ tone: "info", - message: - modelAutoFetchCapability.unsupportedReason ?? - "当前协议不支持接口获取模型,请手动添加模型 ID。", + message: modelFetchUnsupportedMessage, }); return; } @@ -443,7 +499,10 @@ const ProviderSettingBody: React.FC = ({ if (!canReadModelsFromApi) { setModelFetchStatus({ tone: "error", - message: "请先填写并保存 API 密钥,再从接口获取模型。", + message: t( + "settings.providers.setting.feedback.modelFetch.needApiKey", + "请先填写并保存 API 密钥,再从接口获取模型。", + ), }); return; } @@ -462,8 +521,14 @@ const ProviderSettingBody: React.FC = ({ const responsesStatus = buildResponsesModelFetchStatus( result, modelList, + modelFetchStatusCopy, + ); + const falStatus = buildFalModelFetchStatus( + provider, + result, + modelList, + modelFetchStatusCopy, ); - const falStatus = buildFalModelFetchStatus(provider, result, modelList); setModelFetchStatus({ tone: responsesStatus?.tone ?? @@ -473,8 +538,15 @@ const ProviderSettingBody: React.FC = ({ responsesStatus?.message ?? falStatus?.message ?? (source === "Error" - ? (result.error ?? "接口获取模型失败。请手动添加模型 ID。") - : "接口没有返回实时模型列表。请手动添加模型 ID。"), + ? (result.error ?? + t( + "settings.providers.setting.feedback.modelFetch.errorWithManualFallback", + "接口获取模型失败。请手动添加模型 ID。", + )) + : t( + "settings.providers.setting.feedback.modelFetch.emptyRealtime", + "接口没有返回实时模型列表。请手动添加模型 ID。", + )), }); return; } @@ -488,8 +560,14 @@ const ProviderSettingBody: React.FC = ({ setModelFetchStatus({ tone: "info", message: isFalProviderLike(provider) - ? "Fal 不提供标准 /models 枚举;当前没有可用 Fal 图片模型,请手动添加 fal-ai/nano-banana-pro、fal-ai/flux-pro 或其他 fal-ai/... 模型 ID。" - : "接口已响应,但没有返回可添加的模型 ID。请手动添加模型。", + ? t( + "settings.providers.setting.feedback.modelFetch.emptyFal", + "Fal 不提供标准 /models 枚举;当前没有可用 Fal 图片模型,请手动添加 fal-ai/nano-banana-pro、fal-ai/flux-pro 或其他 fal-ai/... 模型 ID。", + ) + : t( + "settings.providers.setting.feedback.modelFetch.empty", + "接口已响应,但没有返回可添加的模型 ID。请手动添加模型。", + ), }); return; } @@ -502,31 +580,50 @@ const ProviderSettingBody: React.FC = ({ if (existingFetchedModelCount === effectiveFetchedModelIds.length) { setModelFetchStatus({ tone: "success", - message: `已确认 ${effectiveFetchedModelIds.length} 个模型,当前模型优先级已包含全部结果。`, + message: t( + "settings.providers.setting.feedback.modelFetch.confirmedAll", + { + count: effectiveFetchedModelIds.length, + defaultValue: + "已确认 {{count}} 个模型,当前模型优先级已包含全部结果。", + }, + ), }); return; } setModelFetchStatus({ tone: "success", - message: `接口返回 ${effectiveFetchedModelIds.length} 个模型,点击下方模型即可加入优先级。`, + message: t("settings.providers.setting.feedback.modelFetch.fetched", { + count: effectiveFetchedModelIds.length, + defaultValue: + "接口返回 {{count}} 个模型,点击下方模型即可加入优先级。", + }), }); } catch (error) { setModelFetchStatus({ tone: "error", - message: error instanceof Error ? error.message : "接口获取模型失败", + message: + error instanceof Error + ? error.message + : t( + "settings.providers.setting.feedback.modelFetch.errorDefault", + "接口获取模型失败", + ), }); } finally { setFetchingModels(false); } }, [ canReadModelsFromApi, + modelFetchStatusCopy, + modelFetchUnsupportedMessage, modelList, modelAutoFetchCapability.supported, - modelAutoFetchCapability.unsupportedReason, normalizedModelSet, persistDraftApiKey, provider, + t, ]); const handleDeleteProvider = useCallback(async () => { @@ -534,11 +631,18 @@ const ProviderSettingBody: React.FC = ({ return; } - const deleteDescription = provider.is_system - ? "此操作会移除该服务商的已启用模型和本地 API 密钥,系统服务商入口仍可重新添加。" - : "此操作会移除该服务商和关联密钥。"; const confirmed = window.confirm( - `确认删除「${provider.name}」配置?${deleteDescription}`, + provider.is_system + ? t("settings.providers.setting.feedback.delete.confirm.system", { + providerName: provider.name, + defaultValue: + "确认删除「{{providerName}}」配置?此操作会移除该服务商的已启用模型和本地 API 密钥,系统服务商入口仍可重新添加。", + }) + : t("settings.providers.setting.feedback.delete.confirm.custom", { + providerName: provider.name, + defaultValue: + "确认删除「{{providerName}}」配置?此操作会移除该服务商和关联密钥。", + }), ); if (!confirmed) { return; @@ -551,23 +655,35 @@ const ProviderSettingBody: React.FC = ({ await onDeleteProvider(provider.id); setProviderActionStatus({ tone: "success", - message: "配置已删除。", + message: t( + "settings.providers.setting.feedback.delete.success", + "配置已删除。", + ), }); } catch (error) { setProviderActionStatus({ tone: "error", - message: error instanceof Error ? error.message : "删除配置失败", + message: + error instanceof Error + ? error.message + : t( + "settings.providers.setting.feedback.delete.errorDefault", + "删除配置失败", + ), }); } finally { setDeletingProvider(false); } - }, [onDeleteProvider, provider.id, provider.is_system, provider.name]); + }, [onDeleteProvider, provider.id, provider.is_system, provider.name, t]); const handleTestConnection = useCallback(async () => { if (modelList.length === 0) { setConnectionStatus({ tone: "error", - message: "请先添加至少一个模型,再测试连接。", + message: t( + "settings.providers.setting.feedback.connection.needModel", + "请先添加至少一个模型,再测试连接。", + ), }); return; } @@ -575,7 +691,10 @@ const ProviderSettingBody: React.FC = ({ if (providerApiKeyRequired && !hasApiKey && !canUseDraftApiKey) { setConnectionStatus({ tone: "error", - message: "请先填写 API 密钥,再测试连接。", + message: t( + "settings.providers.setting.feedback.connection.needApiKey", + "请先填写 API 密钥,再测试连接。", + ), }); return; } @@ -601,19 +720,39 @@ const ProviderSettingBody: React.FC = ({ tone: "success", message: result.latencyMs !== undefined - ? `连接成功 · ${result.latencyMs}ms` - : "连接成功", + ? t( + "settings.providers.setting.feedback.connection.successWithLatency", + { + latencyMs: result.latencyMs, + defaultValue: "连接成功 · {{latencyMs}}ms", + }, + ) + : t( + "settings.providers.setting.feedback.connection.success", + "连接成功", + ), }); } else { setConnectionStatus({ tone: "error", - message: result.error || "连接测试未通过,请检查密钥或模型 ID。", + message: + result.error || + t( + "settings.providers.setting.feedback.connection.failureDefault", + "连接测试未通过,请检查密钥或模型 ID。", + ), }); } } catch (error) { setConnectionStatus({ tone: "error", - message: error instanceof Error ? error.message : "连接测试失败", + message: + error instanceof Error + ? error.message + : t( + "settings.providers.setting.feedback.connection.errorDefault", + "连接测试失败", + ), }); } finally { setTestingConnection(false); @@ -627,6 +766,7 @@ const ProviderSettingBody: React.FC = ({ primaryModel, providerApiKeyRequired, provider.id, + t, ]); return ( diff --git a/src/components/api-key-provider/providerModelFetchHelpers.ts b/src/components/api-key-provider/providerModelFetchHelpers.ts index 5fa9e4e52..0ef1a887e 100644 --- a/src/components/api-key-provider/providerModelFetchHelpers.ts +++ b/src/components/api-key-provider/providerModelFetchHelpers.ts @@ -8,6 +8,13 @@ export interface ProviderModelFetchStatus { message: string; } +export interface ProviderModelFetchStatusCopy { + responsesConfirmedImage?: (imageModel: string) => string; + responsesManualImage?: string; + falConfirmedModel?: (modelId: string) => string; + falManualModel?: string; +} + export interface ProviderModelFetchProfile { id: string; type: string; @@ -96,6 +103,7 @@ export function buildResponsesModelFetchStatus( diagnostic_hint?: string | null; }, models: string[], + copy?: ProviderModelFetchStatusCopy, ): ProviderModelFetchStatus | null { if (!isResponsesModelFetchUnsupported(result)) { return null; @@ -105,13 +113,16 @@ export function buildResponsesModelFetchStatus( if (imageModel) { return { tone: "success", - message: `已确认 Responses 图片模型 ${imageModel},该入口无需标准 /models 枚举,图片生成会走 Responses image_generation。`, + message: + copy?.responsesConfirmedImage?.(imageModel) ?? + `已确认 Responses 图片模型 ${imageModel},该入口无需标准 /models 枚举,图片生成会走 Responses image_generation。`, }; } return { tone: "info", message: + copy?.responsesManualImage ?? "该 Responses 图片入口不提供标准 /models 枚举;请手动添加 gpt-images-2 或 gpt-image-2,图片生成会走 Responses image_generation。", }; } @@ -123,6 +134,7 @@ export function buildFalModelFetchStatus( diagnostic_hint?: string | null; }, models: string[], + copy?: ProviderModelFetchStatusCopy, ): ProviderModelFetchStatus | null { if (!isFalProviderLike(provider) || !isFalModelFetchUnsupported(result)) { return null; @@ -132,13 +144,16 @@ export function buildFalModelFetchStatus( if (firstModel) { return { tone: "success", - message: `已确认 Fal 模型 ${firstModel},Fal 不提供标准 /models 枚举,后续会使用手动声明的模型 ID。`, + message: + copy?.falConfirmedModel?.(firstModel) ?? + `已确认 Fal 模型 ${firstModel},Fal 不提供标准 /models 枚举,后续会使用手动声明的模型 ID。`, }; } return { tone: "info", message: + copy?.falManualModel ?? "Fal 不提供标准 /models 枚举;当前模型优先级没有可用 Fal 图片模型,请手动添加 fal-ai/nano-banana-pro、fal-ai/flux-pro 或其他 fal-ai/... 模型 ID。", }; } diff --git a/src/components/artifact/ErrorFallbackRenderer.tsx b/src/components/artifact/ErrorFallbackRenderer.tsx index 37c3854a7..a9bd77dd6 100644 --- a/src/components/artifact/ErrorFallbackRenderer.tsx +++ b/src/components/artifact/ErrorFallbackRenderer.tsx @@ -6,6 +6,7 @@ */ import React, { memo, useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { AlertTriangle, RefreshCw, @@ -46,6 +47,20 @@ interface ErrorDetailsState { showContent: boolean; } +interface ErrorReportLabels { + heading: string; + time: string; + artifactId: string; + artifactType: string; + artifactTitle: string; + artifactStatus: string; + errorInfo: string; + errorType: string; + errorMessage: string; + errorStack: string; + artifactContent: string; +} + // ============================================================================ // 辅助组件 // ============================================================================ @@ -55,9 +70,10 @@ interface ErrorDetailsState { */ const CopyButton: React.FC<{ text: string; - label?: string; + label: string; + copiedLabel: string; className?: string; -}> = memo(({ text, label = "复制", className }) => { +}> = memo(({ text, label, copiedLabel, className }) => { const [copied, setCopied] = useState(false); const handleCopy = useCallback(async () => { @@ -72,6 +88,7 @@ const CopyButton: React.FC<{ return ( )} {onShowSource && ( )} @@ -258,17 +343,35 @@ export const ErrorFallbackRenderer: React.FC = memo( {/* Artifact 信息 */}
- 类型:{" "} + + {t("errors.artifactFallback.field.type", { + defaultValue: "类型", + })} + : + {" "} {artifact.type}
- 标题:{" "} + + {t("errors.artifactFallback.field.title", { + defaultValue: "标题", + })} + : + {" "} - {artifact.title || "(无标题)"} + {artifact.title || + t("errors.artifactFallback.value.untitled", { + defaultValue: "(无标题)", + })}
- 状态:{" "} + + {t("errors.artifactFallback.field.status", { + defaultValue: "状态", + })} + : + {" "} {artifact.status}
@@ -276,7 +379,9 @@ export const ErrorFallbackRenderer: React.FC = memo( {/* 错误堆栈(可折叠) */} {error?.stack && ( @@ -285,7 +390,11 @@ export const ErrorFallbackRenderer: React.FC = memo( {error.stack}
- +
@@ -293,17 +402,26 @@ export const ErrorFallbackRenderer: React.FC = memo( {/* 源码内容(可折叠) */}
-                {artifact.content || "(空内容)"}
+                {artifact.content ||
+                  t("errors.artifactFallback.value.emptyContent", {
+                    defaultValue: "(空内容)",
+                  })}
               
{artifact.content && (
- +
)}
diff --git a/src/components/execution/LatestRunStatusBadge.test.tsx b/src/components/execution/LatestRunStatusBadge.test.tsx new file mode 100644 index 000000000..8adf45e85 --- /dev/null +++ b/src/components/execution/LatestRunStatusBadge.test.tsx @@ -0,0 +1,121 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { changeLimeLocale } from "@/i18n/createI18n"; +import type { AgentRun } from "@/lib/api/executionRun"; + +const { mockExecutionRunList } = vi.hoisted(() => ({ + mockExecutionRunList: vi.fn(), +})); + +vi.mock("@/lib/api/executionRun", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + executionRunList: mockExecutionRunList, + }; +}); + +import { LatestRunStatusBadge } from "./LatestRunStatusBadge"; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function buildRun(overrides: Partial): AgentRun { + return { + id: "run-1", + source: "automation", + source_ref: null, + session_id: null, + status: "success", + started_at: "2026-05-11T08:09:10.000Z", + finished_at: null, + duration_ms: null, + error_code: null, + error_message: null, + metadata: null, + created_at: "2026-05-11T08:09:10.000Z", + updated_at: "2026-05-11T08:09:10.000Z", + ...overrides, + }; +} + +function renderBadge() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + mountedRoots.push({ root, container }); + return container; +} + +async function flushEffects(times = 4) { + await act(async () => { + for (let index = 0; index < times; index += 1) { + await Promise.resolve(); + } + }); +} + +beforeEach(async () => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + vi.clearAllMocks(); + await changeLimeLocale("en-US"); +}); + +afterEach(async () => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + + await changeLimeLocale("zh-CN"); + vi.clearAllMocks(); +}); + +describe("LatestRunStatusBadge", () => { + it("应通过 common namespace 渲染英文执行状态与时间标签", async () => { + mockExecutionRunList.mockResolvedValue([ + buildRun({ status: "success" }), + buildRun({ id: "run-2", source: "chat", status: "running" }), + ]); + + const container = renderBadge(); + await flushEffects(); + + const text = container.textContent ?? ""; + expect(text).toContain("Latest run"); + expect(text).toContain("Success"); + expect(text).toContain("Time:"); + expect(text).not.toContain("最近执行"); + expect(text).not.toContain("成功"); + expect(text).not.toContain("时间"); + }); + + it("无执行记录时应通过 common namespace 渲染英文空状态", async () => { + mockExecutionRunList.mockResolvedValue([]); + + const container = renderBadge(); + await flushEffects(); + + const text = container.textContent ?? ""; + expect(text).toContain("Latest run: No records yet"); + expect(text).not.toContain("暂无记录"); + }); +}); diff --git a/src/components/execution/LatestRunStatusBadge.tsx b/src/components/execution/LatestRunStatusBadge.tsx index 505121817..38c89e477 100644 --- a/src/components/execution/LatestRunStatusBadge.tsx +++ b/src/components/execution/LatestRunStatusBadge.tsx @@ -1,10 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; -import { +import { formatDate } from "@/i18n/format"; +import { executionRunList } from "@/lib/api/executionRun"; +import type { AgentRun, AgentRunSource, AgentRunStatus, - executionRunList, } from "@/lib/api/executionRun"; interface LatestRunStatusBadgeProps { @@ -14,25 +16,6 @@ interface LatestRunStatusBadgeProps { pollMs?: number; } -function statusLabel(status: AgentRunStatus): string { - switch (status) { - case "queued": - return "排队中"; - case "running": - return "运行中"; - case "success": - return "成功"; - case "error": - return "失败"; - case "canceled": - return "已取消"; - case "timeout": - return "超时"; - default: - return status; - } -} - function statusVariant(status: AgentRunStatus) { if (status === "success") return "default" as const; if (status === "running" || status === "queued") return "secondary" as const; @@ -42,18 +25,58 @@ function statusVariant(status: AgentRunStatus) { function formatTime(value: string | null | undefined): string { if (!value) return "-"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString("zh-CN", { hour12: false }); + return ( + formatDate(value, { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }) || value + ); } export function LatestRunStatusBadge({ source, - label = "最近执行", + label, className, pollMs = 15_000, }: LatestRunStatusBadgeProps) { + const { t } = useTranslation("common"); const [latestRun, setLatestRun] = useState(null); + const resolvedLabel = + label ?? + t("common.execution.latestRunStatus.defaultLabel", { + defaultValue: "最近执行", + }); + const noRecordLabel = t("common.execution.latestRunStatus.noRecord", { + defaultValue: "暂无记录", + }); + const statusLabels: Record = useMemo( + () => ({ + queued: t("common.execution.latestRunStatus.status.queued", { + defaultValue: "排队中", + }), + running: t("common.execution.latestRunStatus.status.running", { + defaultValue: "运行中", + }), + success: t("common.execution.latestRunStatus.status.success", { + defaultValue: "成功", + }), + error: t("common.execution.latestRunStatus.status.error", { + defaultValue: "失败", + }), + canceled: t("common.execution.latestRunStatus.status.canceled", { + defaultValue: "已取消", + }), + timeout: t("common.execution.latestRunStatus.status.timeout", { + defaultValue: "超时", + }), + }), + [t], + ); const refresh = useCallback(async () => { try { @@ -76,14 +99,16 @@ export function LatestRunStatusBadge({ }, [pollMs, refresh]); const statusText = useMemo(() => { - if (!latestRun) return "暂无记录"; - return statusLabel(latestRun.status); - }, [latestRun]); + if (!latestRun) return noRecordLabel; + return statusLabels[latestRun.status] ?? latestRun.status; + }, [latestRun, noRecordLabel, statusLabels]); if (!latestRun) { return (
- {label}: 暂无记录 + + {resolvedLabel}: {noRecordLabel} +
); } @@ -91,10 +116,14 @@ export function LatestRunStatusBadge({ return (
- {label} + {resolvedLabel} {statusText} - 时间: {formatTime(latestRun.started_at)} + {t("common.execution.latestRunStatus.timeLabel", { + defaultValue: "时间", + })} + :{" "} + {formatTime(latestRun.started_at)}
diff --git a/src/components/memory/MemoryPage.test.tsx b/src/components/memory/MemoryPage.test.tsx index ac12be0fd..754474394 100644 --- a/src/components/memory/MemoryPage.test.tsx +++ b/src/components/memory/MemoryPage.test.tsx @@ -30,23 +30,57 @@ const { mockGetStoredResourceProjectId, mockOnResourceProjectChange, mockBuildHomeAgentParams, -} = vi.hoisted(() => ({ - mockGetConfig: vi.fn(), - mockSaveConfig: vi.fn(), - mockCleanupContextMemdir: vi.fn(), - mockGetContextMemoryEffectiveSources: vi.fn(), - mockGetContextMemoryAutoIndex: vi.fn(), - mockGetContextWorkingMemory: vi.fn(), - mockGetContextMemoryExtractionStatus: vi.fn(), - mockPrefetchContextMemoryForTurn: vi.fn(), - mockScaffoldContextMemdir: vi.fn(), - mockGetUnifiedMemoryStats: vi.fn(), - mockListUnifiedMemories: vi.fn(), - mockGetProjectMemory: vi.fn(), - mockGetStoredResourceProjectId: vi.fn(), - mockOnResourceProjectChange: vi.fn(), - mockBuildHomeAgentParams: vi.fn(), -})); + mockUseTranslation, +} = vi.hoisted(() => { + const mockTranslate = vi.fn( + ( + key: string, + fallbackOrOptions?: string | { defaultValue?: string }, + options?: Record, + ) => { + const values: Record = + typeof fallbackOrOptions === "object" && fallbackOrOptions !== null + ? (fallbackOrOptions as Record) + : (options ?? {}); + const template = + typeof fallbackOrOptions === "string" + ? fallbackOrOptions + : typeof values.defaultValue === "string" + ? values.defaultValue + : key; + + return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, name) => { + const value = values[name]; + return value == null ? match : String(value); + }); + }, + ); + + return { + mockGetConfig: vi.fn(), + mockSaveConfig: vi.fn(), + mockCleanupContextMemdir: vi.fn(), + mockGetContextMemoryEffectiveSources: vi.fn(), + mockGetContextMemoryAutoIndex: vi.fn(), + mockGetContextWorkingMemory: vi.fn(), + mockGetContextMemoryExtractionStatus: vi.fn(), + mockPrefetchContextMemoryForTurn: vi.fn(), + mockScaffoldContextMemdir: vi.fn(), + mockGetUnifiedMemoryStats: vi.fn(), + mockListUnifiedMemories: vi.fn(), + mockGetProjectMemory: vi.fn(), + mockGetStoredResourceProjectId: vi.fn(), + mockOnResourceProjectChange: vi.fn(), + mockBuildHomeAgentParams: vi.fn(), + mockUseTranslation: vi.fn((_namespace?: string) => ({ + i18n: { + language: "zh-CN", + resolvedLanguage: "zh-CN", + }, + t: mockTranslate, + })), + }; +}); vi.mock("@/lib/api/appConfig", () => ({ getConfig: mockGetConfig, @@ -81,6 +115,10 @@ vi.mock("@/lib/workspace/navigation", () => ({ buildHomeAgentParams: mockBuildHomeAgentParams, })); +vi.mock("react-i18next", () => ({ + useTranslation: mockUseTranslation, +})); + const mountedRoots: MountedRoot[] = []; function renderPage(options?: { diff --git a/src/components/openclaw/OpenClawConfigurePage.tsx b/src/components/openclaw/OpenClawConfigurePage.tsx deleted file mode 100644 index 7094aedb6..000000000 --- a/src/components/openclaw/OpenClawConfigurePage.tsx +++ /dev/null @@ -1,411 +0,0 @@ -import { - AlertTriangle, - Copy, - ExternalLink, - Loader2, - Play, - RefreshCw, - ShieldCheck, - Trash2, -} from "lucide-react"; -import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry"; -import type { ConfiguredProvider } from "@/hooks/useConfiguredProviders"; -import type { - OpenClawGatewayStatus, - OpenClawHealthInfo, -} from "@/lib/api/openclaw"; -import { cn } from "@/lib/utils"; -import type { OpenClawLastSynced } from "./types"; -import { compactPathLabel } from "./pathDisplay"; -import { - openClawDangerButtonClassName, - openClawInputClassName, - openClawPanelClassName, - openClawPrimaryButtonClassName, - openClawSecondaryButtonClassName, - openClawSubPanelClassName, -} from "./openclawStyles"; - -interface OpenClawConfigurePageProps { - installPath?: string | null; - uninstalling: boolean; - syncing: boolean; - starting: boolean; - canSync: boolean; - canStart: boolean; - providersLoading: boolean; - modelsLoading: boolean; - modelsError: string | null; - selectedProviderKey: string; - selectedModelId: string; - compatibleProviders: ConfiguredProvider[]; - providerModels: EnhancedModelMetadata[]; - lastSynced: OpenClawLastSynced | null; - gatewayStatus: OpenClawGatewayStatus; - gatewayPort: number; - healthInfo: OpenClawHealthInfo | null; - gatewayRunning: boolean; - onCopyPath: () => void; - onUninstall: () => void; - onOpenDocs: () => void; - onSelectProvider: (providerId: string) => void; - onSelectModel: (modelId: string) => void; - onInputModel: (modelId: string) => void; - onRefreshProviders: () => void; - onSync: () => void; - onStart: () => void; - onOpenRuntime: () => void; - onGoProviderSettings: () => void; -} - -export function OpenClawConfigurePage({ - installPath, - uninstalling, - syncing, - starting, - canSync, - canStart, - providersLoading, - modelsLoading, - modelsError, - selectedProviderKey, - selectedModelId, - compatibleProviders, - providerModels, - lastSynced, - gatewayStatus, - gatewayPort, - healthInfo, - gatewayRunning, - onCopyPath, - onUninstall, - onOpenDocs, - onSelectProvider, - onSelectModel, - onInputModel, - onRefreshProviders, - onSync, - onStart, - onOpenRuntime, - onGoProviderSettings, -}: OpenClawConfigurePageProps) { - const hasProviders = compatibleProviders.length > 0; - const gatewayStatusLabel = gatewayRunning ? "运行中" : gatewayStatus; - const healthText = healthInfo - ? `${healthInfo.status}${healthInfo.version ? ` · ${healthInfo.version}` : ""}` - : "尚未执行健康检查"; - const installPathLabel = installPath - ? compactPathLabel(installPath, 76) - : "未检测到安装路径"; - - return ( -
-
-
-
-
- MODEL SYNC -
-

- 配置模型并准备启动 -

-

- 安装已经完成。现在只需要选择服务商和模型,同步独立副本配置后就可以启动 - Gateway。 -

- -
- - {hasProviders - ? `${compatibleProviders.length} 个兼容 Provider` - : "尚无兼容 Provider"} - - - Gateway {gatewayStatusLabel} - - - {lastSynced - ? `最近同步 ${lastSynced.modelId}` - : "尚未同步模型配置"} - -
-
- -
- - - -
-
-
- -
-
-
-
-
-
- OpenClaw 安装路径 -
-
- - {installPathLabel} - - -
-
- - -
-
- -
-
-

- 模型配置 -

-

- 选择服务商和主模型,启动时会自动确保配置同步到独立副本。 -

-
- - {!hasProviders ? ( -
- 当前没有可用于 OpenClaw 的兼容 Provider。请先添加 API Key 类型的 - Provider。 -
- -
-
- ) : ( -
- - - - - - - {modelsError ? ( -
- 模型列表加载失败:{modelsError}。你仍可直接手动输入模型 ID。 -
- ) : null} - - {lastSynced ? ( -
- 最近一次已同步:{lastSynced.providerId} /{" "} - {lastSynced.modelId} -
- ) : null} -
- )} -
-
- -
-
-
-
-

- 当前状态 -

-

- 安装完成后建议先查看 Gateway - 状态,再决定是否同步配置或直接启动。 -

-
- -
- -
-
-
- Gateway 状态 -
-
- {gatewayStatusLabel} · {gatewayPort} -
-
-
-
- 健康检查 -
-
- {healthText} -
-
-
-
- -
-
- -
-
温馨提示
-

- OpenClaw - 具备较高系统权限,建议仅在可信环境中使用。点击启动后会优先同步当前 - Provider 与模型配置。 -

-
-
-
- -
-
辅助动作
-
- - {gatewayRunning ? ( - - ) : null} -
-
-
-
-
- ); -} diff --git a/src/components/openclaw/OpenClawDashboardPage.tsx b/src/components/openclaw/OpenClawDashboardPage.tsx deleted file mode 100644 index 89a18dd32..000000000 --- a/src/components/openclaw/OpenClawDashboardPage.tsx +++ /dev/null @@ -1,294 +0,0 @@ -import { - ArrowLeft, - ArrowUpCircle, - Copy, - ExternalLink, - Loader2, - MonitorSmartphone, - RefreshCw, -} from "lucide-react"; -import { useMemo } from "react"; -import { toast } from "sonner"; -import { copyTextToClipboard } from "@/lib/crashDiagnostic"; -import { cn } from "@/lib/utils"; -import { - openClawPanelClassName, - openClawPrimaryButtonClassName, - openClawSecondaryButtonClassName, - openClawSubPanelClassName, -} from "./openclawStyles"; - -interface OpenClawDashboardPageProps { - dashboardUrl: string | null; - loading: boolean; - running: boolean; - windowBusy: boolean; - windowOpen: boolean; - hasUpdate?: boolean; - latestVersion?: string | null; - updating?: boolean; - onBack: () => void; - onOpenExternal: () => void; - onOpenWindow: () => void; - onRefresh: () => void; - onUpdate?: () => void; -} - -export function OpenClawDashboardPage({ - dashboardUrl, - loading, - running, - windowBusy, - windowOpen, - hasUpdate = false, - latestVersion = null, - updating = false, - onBack, - onOpenExternal, - onOpenWindow, - onRefresh, - onUpdate, -}: OpenClawDashboardPageProps) { - const actionDisabled = !running || !dashboardUrl || loading || windowBusy; - const hasDashboardToken = useMemo(() => { - if (!dashboardUrl) { - return false; - } - - try { - const url = new URL(dashboardUrl); - return ( - url.searchParams.has("token") || - new URLSearchParams(url.hash.replace(/^#/, "")).has("token") - ); - } catch { - return /[#?&]token=/.test(dashboardUrl); - } - }, [dashboardUrl]); - - const maskedDashboardUrl = useMemo(() => { - if (!dashboardUrl) { - return "正在读取 Dashboard 地址..."; - } - - try { - const url = new URL(dashboardUrl); - const queryToken = url.searchParams.get("token"); - if (queryToken) { - const visiblePrefix = queryToken.slice(0, 6); - const visibleSuffix = queryToken.slice(-4); - url.searchParams.set("token", `${visiblePrefix}***${visibleSuffix}`); - } - - const hashParams = new URLSearchParams(url.hash.replace(/^#/, "")); - const hashToken = hashParams.get("token"); - if (hashToken) { - const visiblePrefix = hashToken.slice(0, 6); - const visibleSuffix = hashToken.slice(-4); - hashParams.set("token", `${visiblePrefix}***${visibleSuffix}`); - url.hash = hashParams.toString(); - } - return url.toString(); - } catch { - return dashboardUrl.replace( - /(token=)([^&#]+)/, - (_match, prefix, value: string) => { - const visiblePrefix = value.slice(0, 6); - const visibleSuffix = value.slice(-4); - return `${prefix}${visiblePrefix}***${visibleSuffix}`; - }, - ); - } - }, [dashboardUrl]); - - const handleCopyDashboardUrl = async () => { - if (!dashboardUrl) { - toast.error("当前没有可复制的 Dashboard 地址。"); - return; - } - - try { - await copyTextToClipboard(dashboardUrl, { - fallbackErrorMessage: "复制 Dashboard 地址失败,请重试。", - permissionDeniedMessage: - "剪贴板权限被系统拒绝,请先点击 Lime 窗口后重试复制 Dashboard 地址。", - inactiveWindowMessage: - "当前窗口未激活,先点击 Lime 窗口后再复制 Dashboard 地址。", - }); - toast.success("完整 Dashboard 地址已复制。"); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "复制 Dashboard 地址失败。", - ); - } - }; - - return ( -
-
-
-
-
- DASHBOARD ACCESS -
-

- Dashboard 访问方式 -

-

- 已移除内嵌模式。Dashboard 请通过桌面面板或系统浏览器访问,避免 - iframe 鉴权与兼容性问题。 -

- -
- - {windowOpen ? "桌面面板已打开" : "桌面面板未打开"} - - - {hasDashboardToken ? "已携带 token" : "未检测到 token"} - -
-
- -
- {hasUpdate && onUpdate ? ( - - ) : null} - - -
-
-
- -
-
-
访问方式
-

- 这里保留两种稳定方式:桌面面板和系统浏览器。桌面面板适合日常使用,浏览器更适合诊断 - token 与页面状态。 -

- -
- - - -
-
- -
-
-
-

- 当前地址 -

-

- 页面中只显示脱敏地址;复制按钮会复制完整带 token 的访问链接。 -

-
- - -
- -
-
-
- token 状态 -
-
- {hasDashboardToken ? "已携带 token" : "未检测到 token"} -
-
- -
-
脱敏地址
-
- {maskedDashboardUrl} -
-
-
- -

- Gateway 重启后 token 可能变化;如遇 401 - 或空白页,先点“刷新状态”再重新打开。 -

-
-
-
- ); -} diff --git a/src/components/openclaw/OpenClawExecutionEnvironmentCard.tsx b/src/components/openclaw/OpenClawExecutionEnvironmentCard.tsx deleted file mode 100644 index b4d3b8db9..000000000 --- a/src/components/openclaw/OpenClawExecutionEnvironmentCard.tsx +++ /dev/null @@ -1,328 +0,0 @@ -import { useMemo, useState } from "react"; -import { ChevronDown, Cpu, Package, TerminalSquare } from "lucide-react"; -import type { OpenClawRuntimeCandidate } from "@/lib/api/openclaw"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, -} from "@/components/ui/select"; -import { cn } from "@/lib/utils"; -import { openClawSubPanelClassName } from "./openclawStyles"; -import { compactPathLabel } from "./pathDisplay"; - -const AUTO_RUNTIME_VALUE = "__auto_runtime__"; - -interface OpenClawExecutionEnvironmentCardProps { - candidates: OpenClawRuntimeCandidate[]; - preferredRuntimeId: string | null; - busy?: boolean; - description: string; - className?: string; - onChange: (runtimeId: string | null) => void; -} - -function formatRuntimeHeading( - candidate: OpenClawRuntimeCandidate | null, -): string { - if (!candidate) { - return "自动选择"; - } - - return `${candidate.source} · Node ${candidate.nodeVersion || "未识别"}`; -} - -function formatRuntimeSelectionLabel( - candidate: OpenClawRuntimeCandidate | null, - preferredRuntimeId: string | null, -): string { - if (!candidate) { - return "自动选择"; - } - - return preferredRuntimeId - ? `固定:${formatRuntimeHeading(candidate)}` - : `自动:${formatRuntimeHeading(candidate)}`; -} - -function formatOpenClawStatus( - candidate: OpenClawRuntimeCandidate | null, -): string { - if (!candidate) { - return "尚未检测到可用运行时"; - } - - if (candidate.openclawVersion) { - return `OpenClaw ${candidate.openclawVersion}`; - } - - if (candidate.openclawPath) { - return "已检测到 OpenClaw 命令"; - } - - if (candidate.openclawPackagePath) { - return "已检测到 OpenClaw 包"; - } - - return "当前运行时尚未安装 OpenClaw"; -} - -function buildEnvironmentDetails( - candidate: OpenClawRuntimeCandidate | null, -): Array<{ label: string; value: string }> { - if (!candidate) { - return []; - } - - return [ - { label: "Node.js 可执行文件", value: candidate.nodePath }, - { label: "运行时 bin 目录", value: candidate.binDir }, - { label: "npm 命令", value: candidate.npmPath || "" }, - { label: "npm 全局前缀", value: candidate.npmGlobalPrefix || "" }, - { label: "OpenClaw 命令", value: candidate.openclawPath || "" }, - { label: "OpenClaw 包路径", value: candidate.openclawPackagePath || "" }, - ].filter((item) => item.value.trim().length > 0); -} - -export function OpenClawExecutionEnvironmentCard({ - candidates, - preferredRuntimeId, - busy = false, - description, - className, - onChange, -}: OpenClawExecutionEnvironmentCardProps) { - const [detailsOpen, setDetailsOpen] = useState(false); - const currentCandidate = - (preferredRuntimeId - ? candidates.find((candidate) => candidate.id === preferredRuntimeId) - : null) || - candidates.find((candidate) => candidate.isPreferred) || - candidates.find((candidate) => candidate.isActive) || - candidates[0] || - null; - const selectValue = preferredRuntimeId || AUTO_RUNTIME_VALUE; - const showSelector = candidates.length > 0; - const environmentDetails = useMemo( - () => buildEnvironmentDetails(currentCandidate), - [currentCandidate], - ); - - return ( -
-
-
-
执行环境
-
- {formatRuntimeSelectionLabel(currentCandidate, preferredRuntimeId)} -
-
- - {preferredRuntimeId ? "已固定" : "自动选择"} - -
- -

{description}

- - {showSelector ? ( -
- -
- ) : null} - -
-
-
- - Node.js -
-
- {currentCandidate?.nodeVersion || "未识别"} -
-
- {currentCandidate?.nodePath - ? compactPathLabel(currentCandidate.nodePath, 46) - : "未检测到"} -
-
- -
-
- - OpenClaw -
-
- {formatOpenClawStatus(currentCandidate)} -
-
- {currentCandidate?.openclawPath - ? compactPathLabel(currentCandidate.openclawPath, 46) - : currentCandidate?.openclawPackagePath - ? compactPathLabel(currentCandidate.openclawPackagePath, 46) - : "切换到该运行时后可直接安装 OpenClaw。"} -
-
- -
-
- - npm 前缀 -
-
- {currentCandidate?.source || "未识别"} -
-
- {currentCandidate?.npmGlobalPrefix - ? compactPathLabel(currentCandidate.npmGlobalPrefix, 46) - : currentCandidate?.npmPath - ? compactPathLabel(currentCandidate.npmPath, 46) - : "当前未检测到 npm 全局前缀"} -
-
-
- - {environmentDetails.length > 0 ? ( - -
-
-
-
- 环境详情 -
-

- 默认只保留运行时摘要,排查路径与前缀冲突时再展开完整信息。 -

-
- - - -
- - -
-
- {environmentDetails.map((item) => ( -
-
- {item.label} -
-
- {item.value} -
-
- ))} -
-
-
-
-
- ) : null} -
- ); -} diff --git a/src/components/openclaw/OpenClawInstallPage.test.tsx b/src/components/openclaw/OpenClawInstallPage.test.tsx deleted file mode 100644 index d8442031b..000000000 --- a/src/components/openclaw/OpenClawInstallPage.test.tsx +++ /dev/null @@ -1,363 +0,0 @@ -import React from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { - OpenClawDependencyStatus, - OpenClawEnvironmentStatus, -} from "@/lib/api/openclaw"; -import { OpenClawInstallPage } from "./OpenClawInstallPage"; - -vi.mock("./OpenClawMark", () => ({ - OpenClawMark: () =>
, -})); - -type OpenClawInstallPageProps = Parameters[0]; - -interface MountedHarness { - container: HTMLDivElement; - root: Root; -} - -const mountedRoots: MountedHarness[] = []; - -function buildDependencyStatus( - overrides: Partial, -): OpenClawDependencyStatus { - return { - status: "ok", - version: "22.12.0", - path: "/usr/local/bin/tool", - message: "已就绪", - autoInstallSupported: true, - ...overrides, - }; -} - -function buildEnvironmentStatus(options?: { - node?: Partial; - git?: Partial; - openclaw?: Partial; - summary?: string; - diagnostics?: OpenClawEnvironmentStatus["diagnostics"]; -}): OpenClawEnvironmentStatus { - return { - node: buildDependencyStatus(options?.node ?? {}), - git: buildDependencyStatus({ - version: "2.44.0", - path: "/usr/bin/git", - ...options?.git, - }), - openclaw: buildDependencyStatus({ - path: "/usr/local/bin/openclaw", - autoInstallSupported: false, - ...options?.openclaw, - }), - recommendedAction: "ready", - summary: options?.summary ?? "环境已就绪。", - diagnostics: options?.diagnostics, - tempArtifacts: [], - }; -} - -function renderPage( - overrides?: Partial, -): HTMLDivElement { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - const defaultProps: OpenClawInstallPageProps = { - environmentStatus: buildEnvironmentStatus(), - runtimeCandidates: [], - preferredRuntimeId: null, - desktopPlatform: "macos", - busy: false, - switchingRuntime: false, - installing: false, - installingNode: false, - installingGit: false, - cleaningTemp: false, - onInstall: () => {}, - onInstallNode: () => {}, - onInstallGit: () => {}, - onRefresh: () => {}, - onCleanupTemp: () => {}, - onOpenDocs: () => {}, - onDownloadNode: () => {}, - onDownloadGit: () => {}, - onSelectPreferredRuntime: () => {}, - }; - - act(() => { - root.render(); - }); - - mountedRoots.push({ container, root }); - return container; -} - -function findButton(container: HTMLElement, text: string): HTMLButtonElement { - const button = Array.from(container.querySelectorAll("button")).find((item) => - item.textContent?.includes(text), - ); - - if (!button) { - throw new Error(`未找到按钮:${text}`); - } - - return button as HTMLButtonElement; -} - -beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; -}); - -afterEach(() => { - while (mountedRoots.length > 0) { - const mounted = mountedRoots.pop(); - if (!mounted) { - break; - } - - act(() => { - mounted.root.unmount(); - }); - mounted.container.remove(); - } - - vi.clearAllMocks(); -}); - -describe("OpenClawInstallPage", () => { - it("Windows 缺依赖时应阻止直接安装 OpenClaw", () => { - const container = renderPage({ - desktopPlatform: "windows", - environmentStatus: buildEnvironmentStatus({ - node: { - status: "missing", - version: null, - path: null, - message: "未检测到 Node.js,需要安装 22.12.0+。", - autoInstallSupported: false, - }, - git: { - status: "missing", - version: null, - path: null, - message: "未检测到 Git。", - autoInstallSupported: false, - }, - openclaw: { - status: "missing", - version: null, - path: null, - message: "未检测到 OpenClaw。", - autoInstallSupported: false, - }, - summary: "Windows 下请先手动安装依赖。", - }), - }); - - const blockedButtons = Array.from( - container.querySelectorAll("button"), - ).filter((button) => - button.textContent?.includes("请先安装 Node.js / Git"), - ); - - expect(blockedButtons.length).toBeGreaterThan(0); - expect( - blockedButtons.every((button) => button.hasAttribute("disabled")), - ).toBe(true); - expect(container.textContent).toContain( - "Windows 下请先手动安装 Node.js / Git,完成后点击“重新检测”,再安装 OpenClaw。", - ); - }); - - it("macOS 缺依赖时仍允许一键修复并继续安装", () => { - const container = renderPage({ - desktopPlatform: "macos", - environmentStatus: buildEnvironmentStatus({ - node: { - status: "missing", - version: null, - path: null, - message: "未检测到 Node.js,需要安装 22.12.0+。", - autoInstallSupported: true, - }, - git: { - status: "missing", - version: null, - path: null, - message: "未检测到 Git。", - autoInstallSupported: true, - }, - openclaw: { - status: "missing", - version: null, - path: null, - message: "未检测到 OpenClaw。", - autoInstallSupported: false, - }, - }), - }); - - const repairButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("一键修复环境并安装 OpenClaw"), - ); - - expect(repairButton).toBeTruthy(); - expect(repairButton?.hasAttribute("disabled")).toBe(false); - expect(container.textContent).not.toContain("Windows 下请先手动安装"); - }); - - it("检测到 OpenClaw 包但命令待生效时应提示先重新检测", () => { - const container = renderPage({ - desktopPlatform: "windows", - environmentStatus: buildEnvironmentStatus({ - node: { - status: "ok", - version: "22.12.0", - path: "C:/Program Files/nodejs/node.exe", - message: "Node.js 已就绪:22.12.0", - autoInstallSupported: false, - }, - git: { - status: "ok", - version: "2.44.0", - path: "C:/Program Files/Git/cmd/git.exe", - message: "Git 已就绪:2.44.0", - autoInstallSupported: false, - }, - openclaw: { - status: "needs_reload", - version: "0.4.1", - path: "C:/Users/demo/AppData/Roaming/npm", - message: - "已在 npm 全局目录检测到 openclaw(0.4.1),但当前进程尚未解析到 openclaw 命令。", - autoInstallSupported: false, - }, - summary: "已检测到 OpenClaw 包,但命令尚未生效;请点击“重新检测”。", - diagnostics: { - npmPath: "C:/Program Files/nodejs/npm.cmd", - npmGlobalPrefix: "C:/Users/demo/AppData/Roaming/npm", - openclawPackagePath: - "C:/Users/demo/AppData/Roaming/npm/node_modules/openclaw/package.json", - whereCandidates: [], - supplementalSearchDirs: ["C:/Users/demo/AppData/Roaming/npm"], - supplementalCommandCandidates: [ - "C:/Users/demo/AppData/Roaming/npm/openclaw.cmd", - ], - gitWhereCandidates: ["C:/Program Files/Git/cmd/git.exe"], - gitSupplementalSearchDirs: [ - "C:/Program Files/Git/cmd", - "C:/Program Files/Git/bin", - ], - gitSupplementalCommandCandidates: [ - "C:/Program Files/Git/cmd/git.exe", - ], - }, - }), - }); - - const blockedButtons = Array.from( - container.querySelectorAll("button"), - ).filter((button) => button.textContent?.includes("请先重新检测 OpenClaw")); - - expect(blockedButtons.length).toBeGreaterThan(0); - expect( - blockedButtons.every((button) => button.hasAttribute("disabled")), - ).toBe(true); - expect(container.textContent).toContain("待刷新"); - expect(container.textContent).toContain( - "已检测到 OpenClaw 包,但命令尚未生效。请先点击“重新检测”;若仍失败,请重启 Lime 后再试。", - ); - expect(container.textContent).toContain("检测诊断"); - expect(container.textContent).toContain("C:/Program Files/nodejs/npm.cmd"); - expect(container.textContent).toContain("~/AppData/Roaming/npm"); - - act(() => { - findButton(container, "查看详细诊断").click(); - }); - - expect(container.textContent).toContain("收起详细诊断"); - expect(container.textContent).toContain( - "C:/Users/demo/AppData/Roaming/npm/openclaw.cmd", - ); - expect(container.textContent).toContain("`where git` 命中"); - expect(container.textContent).toContain("C:/Program Files/Git/cmd/git.exe"); - }); - - it("长路径应在执行环境与诊断摘要中压缩显示,并通过折叠入口展开详情", () => { - const runtimeNodePath = - "/Users/demo/Library/Application Support/lime/runtime/node/versions/very-long-version/bin/node"; - const diagnosticsNpmPath = - "/Users/demo/Library/Application Support/lime/runtime/npm/prefix/very-long-segment/bin/npm"; - - const container = renderPage({ - environmentStatus: buildEnvironmentStatus({ - diagnostics: { - npmPath: diagnosticsNpmPath, - npmGlobalPrefix: - "/Users/demo/Library/Application Support/lime/runtime/npm/prefix/very-long-segment", - openclawPackagePath: - "/Users/demo/Library/Application Support/lime/runtime/openclaw/node_modules/@qingchencloud/openclaw-zh/package.json", - whereCandidates: [], - supplementalSearchDirs: [], - supplementalCommandCandidates: [], - gitWhereCandidates: [], - gitSupplementalSearchDirs: [], - gitSupplementalCommandCandidates: [], - }, - }), - runtimeCandidates: [ - { - id: "runtime-demo", - source: "nvm", - binDir: - "/Users/demo/Library/Application Support/lime/runtime/node/versions/very-long-version/bin", - nodePath: runtimeNodePath, - nodeVersion: "22.12.0", - npmPath: - "/Users/demo/Library/Application Support/lime/runtime/node/versions/very-long-version/bin/npm", - npmGlobalPrefix: - "/Users/demo/Library/Application Support/lime/runtime/npm/prefix/very-long-segment", - openclawPath: - "/Users/demo/Library/Application Support/lime/runtime/node/versions/very-long-version/bin/openclaw", - openclawVersion: "2026.3.18", - openclawPackagePath: - "/Users/demo/Library/Application Support/lime/runtime/openclaw/node_modules/@qingchencloud/openclaw-zh/package.json", - isActive: true, - isPreferred: false, - }, - ], - }); - - const titledElements = Array.from(container.querySelectorAll("[title]")); - const runtimeSummary = titledElements.find( - (item) => item.getAttribute("title") === runtimeNodePath, - ); - const diagnosticsSummary = titledElements.find( - (item) => item.getAttribute("title") === diagnosticsNpmPath, - ); - - expect(runtimeSummary?.textContent).toContain("..."); - expect(runtimeSummary?.textContent).not.toBe(runtimeNodePath); - expect(diagnosticsSummary?.textContent).toContain("..."); - expect(diagnosticsSummary?.textContent).not.toBe(diagnosticsNpmPath); - - act(() => { - findButton(container, "查看详情").click(); - }); - expect(container.textContent).toContain("收起详情"); - - act(() => { - findButton(container, "查看详细诊断").click(); - }); - expect(container.textContent).toContain("收起详细诊断"); - }); -}); diff --git a/src/components/openclaw/OpenClawInstallPage.tsx b/src/components/openclaw/OpenClawInstallPage.tsx deleted file mode 100644 index e45ecb038..000000000 --- a/src/components/openclaw/OpenClawInstallPage.tsx +++ /dev/null @@ -1,609 +0,0 @@ -import { useState, type ReactNode } from "react"; -import { - ChevronDown, - Download, - ExternalLink, - GitBranch, - Loader2, - Package, - RefreshCw, - TerminalSquare, - Trash2, -} from "lucide-react"; -import type { - OpenClawDependencyStatus, - OpenClawEnvironmentStatus, - OpenClawRuntimeCandidate, -} from "@/lib/api/openclaw"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import type { DesktopPlatform } from "@/lib/crashDiagnostic"; -import { cn } from "@/lib/utils"; -import { OpenClawExecutionEnvironmentCard } from "./OpenClawExecutionEnvironmentCard"; -import { compactPathLabel } from "./pathDisplay"; -import { - openClawPanelClassName, - openClawPrimaryButtonClassName, - openClawSecondaryButtonClassName, - openClawSubPanelClassName, -} from "./openclawStyles"; - -interface OpenClawInstallPageProps { - environmentStatus: OpenClawEnvironmentStatus | null; - runtimeCandidates: OpenClawRuntimeCandidate[]; - preferredRuntimeId: string | null; - desktopPlatform: DesktopPlatform; - busy: boolean; - switchingRuntime: boolean; - installing: boolean; - installingNode: boolean; - installingGit: boolean; - cleaningTemp: boolean; - onInstall: () => void; - onInstallNode: () => void; - onInstallGit: () => void; - onRefresh: () => void; - onCleanupTemp: () => void; - onOpenDocs: () => void; - onDownloadNode: () => void; - onDownloadGit: () => void; - onSelectPreferredRuntime: (runtimeId: string | null) => void; -} - -function resolveStatusTone(status: OpenClawDependencyStatus["status"]): string { - switch (status) { - case "ok": - return "text-emerald-600"; - case "needs_reload": - return "text-amber-600"; - case "version_low": - return "text-amber-600"; - case "missing": - return "text-rose-600"; - default: - return "text-slate-500"; - } -} - -function resolveStatusLabel( - status: OpenClawDependencyStatus["status"], -): string { - switch (status) { - case "ok": - return "已就绪"; - case "needs_reload": - return "待刷新"; - case "version_low": - return "版本过低"; - case "missing": - return "未检测到"; - default: - return status; - } -} - -function DependencyCard({ - title, - icon, - status, - busy, - primaryLabel, - onPrimaryAction, - disablePrimaryAction, - secondaryLabel, - onSecondaryAction, -}: { - title: string; - icon: ReactNode; - status: OpenClawDependencyStatus | null; - busy: boolean; - primaryLabel?: string; - onPrimaryAction?: () => void; - disablePrimaryAction?: boolean; - secondaryLabel?: string; - onSecondaryAction?: () => void; -}) { - const resolvedStatus = status || { - status: "unknown", - version: null, - path: null, - message: "尚未检测", - autoInstallSupported: false, - }; - const pathText = resolvedStatus.path || "当前未检测到路径"; - - return ( -
-
-
- {icon} - {title} -
- - {resolveStatusLabel(resolvedStatus.status)} - -
- -

- {resolvedStatus.message} -

- -
-
版本:{resolvedStatus.version || "未检测到"}
-
- 路径: - - - {resolvedStatus.path - ? compactPathLabel(resolvedStatus.path, 54) - : pathText} - - -
-
- - {(primaryLabel || secondaryLabel) && ( -
- {primaryLabel && onPrimaryAction ? ( - - ) : null} - {secondaryLabel && onSecondaryAction ? ( - - ) : null} -
- )} -
- ); -} - -export function OpenClawInstallPage({ - environmentStatus, - runtimeCandidates, - preferredRuntimeId, - desktopPlatform, - busy, - switchingRuntime, - installing, - installingNode, - installingGit, - cleaningTemp, - onInstall, - onInstallNode, - onInstallGit, - onRefresh, - onCleanupTemp, - onOpenDocs, - onDownloadNode, - onDownloadGit, - onSelectPreferredRuntime, -}: OpenClawInstallPageProps) { - const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); - const nodeReady = environmentStatus?.node.status === "ok"; - const gitReady = environmentStatus?.git.status === "ok"; - const openclawReady = environmentStatus?.openclaw.status === "ok"; - const openclawNeedsReload = - environmentStatus?.openclaw.status === "needs_reload"; - const missingDependencies = environmentStatus - ? [!nodeReady ? "Node.js" : null, !gitReady ? "Git" : null].filter(Boolean) - : []; - const installBlockedByPlatform = - desktopPlatform === "windows" && missingDependencies.length > 0; - const installBlocked = installBlockedByPlatform || openclawNeedsReload; - const installLabel = openclawReady - ? "重新安装 OpenClaw" - : openclawNeedsReload - ? "请先重新检测 OpenClaw" - : installBlockedByPlatform - ? `请先安装 ${missingDependencies.join(" / ")}` - : nodeReady && gitReady - ? "安装 OpenClaw" - : "一键修复环境并安装 OpenClaw"; - const openclawInstallLabel = - !openclawReady && !openclawNeedsReload - ? installBlockedByPlatform - ? `请先安装 ${missingDependencies.join(" / ")}` - : "安装 OpenClaw" - : undefined; - const diagnostics = environmentStatus?.diagnostics; - const hasDiagnostics = Boolean( - diagnostics?.npmPath || - diagnostics?.npmGlobalPrefix || - diagnostics?.openclawPackagePath || - diagnostics?.whereCandidates?.length || - diagnostics?.supplementalSearchDirs?.length || - diagnostics?.supplementalCommandCandidates?.length || - diagnostics?.gitWhereCandidates?.length || - diagnostics?.gitSupplementalSearchDirs?.length || - diagnostics?.gitSupplementalCommandCandidates?.length, - ); - - const nodeStatusLabel = environmentStatus - ? resolveStatusLabel(environmentStatus.node.status) - : "未检测"; - const gitStatusLabel = environmentStatus - ? resolveStatusLabel(environmentStatus.git.status) - : "未检测"; - const openclawStatusLabel = environmentStatus - ? resolveStatusLabel(environmentStatus.openclaw.status) - : "未检测"; - const diagnosticHighlights = [ - { - key: "npm-command", - title: "npm 命令", - value: diagnostics?.npmPath || null, - fallback: "未检测到", - }, - { - key: "npm-prefix", - title: "npm 全局前缀", - value: diagnostics?.npmGlobalPrefix || null, - fallback: "未检测到", - }, - { - key: "openclaw-package", - title: "OpenClaw 包路径", - value: diagnostics?.openclawPackagePath || null, - fallback: "未检测到", - }, - ]; - const diagnosticLists = [ - { - key: "where-candidates", - title: "`where openclaw` 命中", - values: diagnostics?.whereCandidates || [], - emptyText: "未命中", - }, - { - key: "supplemental-search-dirs", - title: "补充搜索目录", - values: diagnostics?.supplementalSearchDirs || [], - emptyText: "无", - }, - { - key: "supplemental-command-candidates", - title: "补充目录中的 OpenClaw 命中", - values: diagnostics?.supplementalCommandCandidates || [], - emptyText: "未命中", - }, - { - key: "git-where-candidates", - title: "`where git` 命中", - values: diagnostics?.gitWhereCandidates || [], - emptyText: "未命中", - }, - { - key: "git-supplemental-search-dirs", - title: "Git 补充搜索目录", - values: diagnostics?.gitSupplementalSearchDirs || [], - emptyText: "无", - }, - { - key: "git-supplemental-command-candidates", - title: "补充目录中的 Git 命中", - values: diagnostics?.gitSupplementalCommandCandidates || [], - emptyText: "未命中", - }, - ]; - - return ( -
-
-
-
-
- SETUP -
-

- OpenClaw 环境安装 -

-

- {environmentStatus?.summary || - "正在检查 Node.js、Git 与 OpenClaw 环境,稍后会给出一键修复建议。"} -

- -
- - Node.js {nodeStatusLabel} - - - Git {gitStatusLabel} - - - OpenClaw {openclawStatusLabel} - -
-
- -
- - - - -
-
- - {installBlockedByPlatform ? ( -
- Windows 下请先手动安装 {missingDependencies.join(" / ")} - ,完成后点击“重新检测”,再安装 OpenClaw。 -
- ) : openclawNeedsReload ? ( -
- 已检测到 OpenClaw - 包,但命令尚未生效。请先点击“重新检测”;若仍失败,请重启 Lime - 后再试。 -
- ) : null} -
- -
- -
- -
- } - status={environmentStatus?.node ?? null} - busy={busy && installingNode} - primaryLabel={ - environmentStatus?.node.autoInstallSupported && - environmentStatus?.node.status !== "ok" - ? "一键安装 Node.js" - : undefined - } - onPrimaryAction={onInstallNode} - secondaryLabel="手动下载 Node.js" - onSecondaryAction={onDownloadNode} - /> - - } - status={environmentStatus?.git ?? null} - busy={busy && installingGit} - primaryLabel={ - environmentStatus?.git.autoInstallSupported && - environmentStatus?.git.status !== "ok" - ? "一键安装 Git" - : undefined - } - onPrimaryAction={onInstallGit} - secondaryLabel="手动下载 Git" - onSecondaryAction={onDownloadGit} - /> - - } - status={environmentStatus?.openclaw ?? null} - busy={busy && installing} - primaryLabel={openclawInstallLabel} - onPrimaryAction={!openclawReady ? onInstall : undefined} - disablePrimaryAction={installBlockedByPlatform} - /> -
- -
-
当前安装策略
-
-

- 优先复用系统里已满足要求的 Node.js / Git,避免重复安装。

-

- - - 缺失依赖时,优先尝试应用内一键安装;若当前平台不支持,则自动降级到手动下载引导。 -

- {desktopPlatform === "windows" ? ( -

- - Windows 下会优先引导手动安装 Node.js / Git,环境就绪后再继续安装 - OpenClaw,避免混合安装流程中途退出。 -

- ) : null} -

- 安装完成后会自动重新检测环境,并继续执行 OpenClaw 安装。

-
- - {environmentStatus?.tempArtifacts?.length ? ( -
-
可清理的临时文件
-
- {environmentStatus.tempArtifacts.map((item) => ( -
- {item} -
- ))} -
-
- ) : null} -
- - {hasDiagnostics ? ( -
- -
-
-
- 检测诊断 -
-

- 主页面先展示关键探测结果,完整命中路径与搜索目录按需展开,避免把安装页变成长日志面板。 -

-
- - - -
- -
- {diagnosticHighlights.map((item) => ( -
-
- {item.title} -
-
- {item.value - ? compactPathLabel(item.value, 60) - : item.fallback} -
-
- ))} -
- - -
-
- {diagnosticHighlights.map((item) => ( -
-
- {item.title} -
-
- {item.value || item.fallback} -
-
- ))} - - {diagnosticLists.map((item) => ( -
-
- {item.title} -
-
- {item.values.length > 0 ? ( - item.values.map((entry) => ( -
- {entry} -
- )) - ) : ( -
{item.emptyText}
- )} -
-
- ))} -
-
-
-
-
- ) : null} -
- ); -} diff --git a/src/components/openclaw/OpenClawMark.tsx b/src/components/openclaw/OpenClawMark.tsx deleted file mode 100644 index b057241ca..000000000 --- a/src/components/openclaw/OpenClawMark.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Bot } from "lucide-react"; -import { cn } from "@/lib/utils"; - -interface OpenClawMarkProps { - size?: "sm" | "md" | "lg"; - className?: string; -} - -const sizeMap = { - sm: "h-12 w-12", - md: "h-16 w-16", - lg: "h-20 w-20", -}; - -const iconSizeMap = { - sm: "h-6 w-6", - md: "h-8 w-8", - lg: "h-10 w-10", -}; - -export function OpenClawMark({ size = "md", className }: OpenClawMarkProps) { - return ( -
- -
- ); -} diff --git a/src/components/openclaw/OpenClawPage.test.tsx b/src/components/openclaw/OpenClawPage.test.tsx deleted file mode 100644 index 0c80e35f0..000000000 --- a/src/components/openclaw/OpenClawPage.test.tsx +++ /dev/null @@ -1,899 +0,0 @@ -import React from "react"; -import { act } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - cleanupMountedRoots, - flushEffects, - renderIntoDom, - setReactActEnvironment, - waitForCondition, - type MountedRoot, -} from "../image-gen/test-utils"; -import { OpenClawPage } from "./OpenClawPage"; - -const { - mockToastError, - mockToastInfo, - mockToastSuccess, - mockToastWarning, - mockDetectDesktopPlatform, - mockOpenUrl, - mockInstall, - mockInstallDependency, - mockGetEnvironmentStatus, - mockGetStatus, - mockCheckUpdate, - mockPerformUpdate, - mockListRuntimeCandidates, - mockSetPreferredRuntime, - mockGetNodeDownloadUrl, - mockGetGitDownloadUrl, - mockListenInstallProgress, - mockGetProgressLogs, - mockInstallPageRender, - mockRuntimePageRender, - mockRefreshDashboardUrl, - mockRefreshDashboardWindowState, - mockHandleOpenDashboardWindow, - mockCheckHealth, - mockGetChannels, -} = vi.hoisted(() => ({ - mockToastError: vi.fn(), - mockToastInfo: vi.fn(), - mockToastSuccess: vi.fn(), - mockToastWarning: vi.fn(), - mockDetectDesktopPlatform: vi.fn(), - mockOpenUrl: vi.fn(), - mockInstall: vi.fn(), - mockInstallDependency: vi.fn(), - mockGetEnvironmentStatus: vi.fn(), - mockGetStatus: vi.fn(), - mockCheckUpdate: vi.fn(), - mockPerformUpdate: vi.fn(), - mockListRuntimeCandidates: vi.fn(), - mockSetPreferredRuntime: vi.fn(), - mockGetNodeDownloadUrl: vi.fn(), - mockGetGitDownloadUrl: vi.fn(), - mockListenInstallProgress: vi.fn(), - mockGetProgressLogs: vi.fn(), - mockInstallPageRender: vi.fn(), - mockRuntimePageRender: vi.fn(), - mockRefreshDashboardUrl: vi.fn(), - mockRefreshDashboardWindowState: vi.fn(), - mockHandleOpenDashboardWindow: vi.fn(), - mockCheckHealth: vi.fn(), - mockGetChannels: vi.fn(), -})); - -vi.mock("sonner", () => ({ - toast: { - error: mockToastError, - info: mockToastInfo, - success: mockToastSuccess, - warning: mockToastWarning, - }, -})); - -vi.mock("@/hooks/useApiKeyProvider", () => ({ - useApiKeyProvider: () => ({ - providers: [], - loading: false, - refresh: vi.fn(), - }), -})); - -vi.mock("@/hooks/useProviderModels", () => ({ - useProviderModels: () => ({ - models: [], - loading: false, - error: null, - }), -})); - -vi.mock("@/lib/crashDiagnostic", () => ({ - detectDesktopPlatform: mockDetectDesktopPlatform, -})); - -vi.mock("@/lib/api/openclaw", () => ({ - openclawApi: { - getEnvironmentStatus: mockGetEnvironmentStatus, - getStatus: mockGetStatus, - checkUpdate: mockCheckUpdate, - performUpdate: mockPerformUpdate, - listRuntimeCandidates: mockListRuntimeCandidates, - setPreferredRuntime: mockSetPreferredRuntime, - getNodeDownloadUrl: mockGetNodeDownloadUrl, - getGitDownloadUrl: mockGetGitDownloadUrl, - listenInstallProgress: mockListenInstallProgress, - getProgressLogs: mockGetProgressLogs, - install: mockInstall, - installDependency: mockInstallDependency, - cleanupTempArtifacts: vi.fn(), - getCommandPreview: vi.fn(), - uninstall: vi.fn(), - restartGateway: vi.fn(), - startGateway: vi.fn(), - stopGateway: vi.fn(), - checkHealth: mockCheckHealth, - getChannels: mockGetChannels, - getDashboardUrl: vi.fn(), - syncProviderConfig: vi.fn(), - }, -})); - -vi.mock("./openUrl", () => ({ - openUrl: mockOpenUrl, -})); - -vi.mock("./useOpenClawDashboardWindow", () => ({ - useOpenClawDashboardWindow: () => ({ - dashboardLoading: false, - dashboardUrl: null, - dashboardWindowBusy: false, - dashboardWindowOpen: false, - refreshDashboardUrl: mockRefreshDashboardUrl, - refreshDashboardWindowState: mockRefreshDashboardWindowState, - handleOpenDashboardWindow: mockHandleOpenDashboardWindow, - handleOpenDashboardExternal: vi.fn(), - closeDashboardWindowSilently: vi.fn(), - }), -})); - -vi.mock("./useOpenClawStore", () => { - const store = { - selectedProviderId: null as string | null, - selectedModelId: "", - gatewayPort: 18790, - preferredRuntimeId: null as string | null, - lastSynced: null, - recentOperation: null, - setSelectedProviderId: vi.fn(), - setSelectedModelId: vi.fn(), - setGatewayPort: vi.fn(), - setPreferredRuntimeId: vi.fn(), - setLastSynced: vi.fn(), - setRecentOperation: vi.fn(), - clearLastSynced: vi.fn(), - clearRecentOperation: vi.fn(), - }; - - return { - useOpenClawStore: (selector: (state: typeof store) => unknown) => - selector(store), - }; -}); - -vi.mock("./OpenClawInstallPage", () => ({ - OpenClawInstallPage: (props: { - desktopPlatform: string; - environmentStatus: { summary?: string } | null; - onInstall: () => void; - onInstallNode: () => void; - onInstallGit: () => void; - onSelectPreferredRuntime: (runtimeId: string | null) => void; - }) => { - mockInstallPageRender(props); - return ( -
-
{props.desktopPlatform}
-
- {props.environmentStatus?.summary ?? ""} -
- - - - -
- ); - }, -})); - -vi.mock("./OpenClawConfigurePage", () => ({ - OpenClawConfigurePage: () =>
, -})); - -vi.mock("./OpenClawDashboardPage", () => ({ - OpenClawDashboardPage: () =>
, -})); - -vi.mock("./OpenClawProgressPage", () => ({ - OpenClawProgressPage: () =>
, -})); - -vi.mock("./OpenClawRuntimePage", () => ({ - OpenClawRuntimePage: (props: { - onUpdate: () => void; - onUseRecommendedUpdateRuntime: () => void; - }) => { - mockRuntimePageRender(props); - return ( -
- - -
- ); - }, -})); - -const mountedRoots: MountedRoot[] = []; - -function buildEnvironmentStatus(options?: { - nodeStatus?: "ok" | "missing" | "version_low"; - gitStatus?: "ok" | "missing"; - openclawStatus?: "ok" | "missing" | "needs_reload"; - openclawMessage?: string; - openclawPath?: string | null; - openclawVersion?: string | null; - summary?: string; -}) { - const nodeStatus = options?.nodeStatus ?? "missing"; - const gitStatus = options?.gitStatus ?? "missing"; - const openclawStatus = options?.openclawStatus ?? "missing"; - - return { - node: { - status: nodeStatus, - version: nodeStatus === "ok" ? "22.12.0" : null, - path: nodeStatus === "ok" ? "C:/Program Files/nodejs/node.exe" : null, - message: - nodeStatus === "ok" - ? "Node.js 已就绪:22.12.0" - : "未检测到 Node.js,需要安装 22.12.0+。", - autoInstallSupported: false, - }, - git: { - status: gitStatus, - version: gitStatus === "ok" ? "2.44.0" : null, - path: gitStatus === "ok" ? "C:/Program Files/Git/cmd/git.exe" : null, - message: gitStatus === "ok" ? "Git 已就绪:2.44.0" : "未检测到 Git。", - autoInstallSupported: false, - }, - openclaw: { - status: openclawStatus, - version: options?.openclawVersion ?? null, - path: options?.openclawPath ?? null, - message: - options?.openclawMessage ?? - (openclawStatus === "needs_reload" - ? "已检测到 OpenClaw 包,但当前进程尚未解析到 openclaw 命令。" - : openclawStatus === "ok" - ? "已检测到 OpenClaw。" - : "未检测到 OpenClaw。"), - autoInstallSupported: false, - }, - recommendedAction: "install_node", - summary: options?.summary ?? "Windows 下请先手动安装依赖。", - tempArtifacts: [], - }; -} - -function renderPage( - props?: Partial>, -) { - return renderIntoDom(, mountedRoots); -} - -function findButton(container: HTMLElement, text: string): HTMLButtonElement { - const button = Array.from(container.querySelectorAll("button")).find((item) => - item.textContent?.includes(text), - ); - - if (!button) { - throw new Error(`未找到按钮:${text}`); - } - - return button as HTMLButtonElement; -} - -async function waitForInstallPage(container: HTMLElement) { - await waitForCondition( - () => !!container.querySelector('[data-testid="openclaw-install-page"]'), - 40, - "OpenClaw 安装页未在预期时间内渲染", - ); -} - -async function flushInstallTimer() { - await act(async () => { - await new Promise((resolve) => window.setTimeout(resolve, 0)); - }); -} - -beforeEach(() => { - setReactActEnvironment(); - vi.clearAllMocks(); - - mockDetectDesktopPlatform.mockReturnValue("windows"); - mockGetEnvironmentStatus.mockResolvedValue(buildEnvironmentStatus()); - mockGetStatus.mockResolvedValue({ status: "stopped", port: 18790 }); - mockCheckUpdate.mockResolvedValue({ - hasUpdate: false, - currentVersion: "2026.3.8", - latestVersion: "2026.3.8", - channel: "stable", - installKind: "package", - packageManager: "pnpm", - message: null, - }); - mockListRuntimeCandidates.mockResolvedValue([]); - mockSetPreferredRuntime.mockResolvedValue({ - success: true, - message: "已切换为自动选择执行环境。", - }); - mockGetNodeDownloadUrl.mockResolvedValue("https://nodejs.org/en/download"); - mockGetGitDownloadUrl.mockResolvedValue("https://git-scm.com/download/win"); - mockListenInstallProgress.mockResolvedValue(() => {}); - mockGetProgressLogs.mockResolvedValue([]); - mockInstall.mockResolvedValue({ success: true, message: "ok" }); - mockInstallDependency.mockResolvedValue({ success: true, message: "ok" }); - mockPerformUpdate.mockResolvedValue({ success: true, message: "ok" }); - mockCheckHealth.mockResolvedValue({ - status: "healthy", - gatewayPort: 18790, - uptime: 10, - version: "2026.3.8", - }); - mockGetChannels.mockResolvedValue([]); - mockRefreshDashboardUrl.mockResolvedValue(null); - mockRefreshDashboardWindowState.mockResolvedValue(false); -}); - -afterEach(() => { - cleanupMountedRoots(mountedRoots); - vi.clearAllMocks(); -}); - -describe("OpenClawPage", () => { - it("应在头部明确 remote 主入口与 OpenClaw compat 的职责边界", async () => { - const onNavigate = vi.fn(); - const mounted = renderPage({ onNavigate }); - await waitForInstallPage(mounted.container); - await flushEffects(); - - expect(mounted.container.textContent).toContain( - "当前 remote 主入口已收口到消息渠道与浏览器连接器", - ); - expect(mounted.container.textContent).toContain( - "OpenClaw 继续负责本地 Gateway、安装修复和兼容接入", - ); - - await act(async () => { - findButton(mounted.container, "去消息渠道").click(); - findButton(mounted.container, "去浏览器连接器").click(); - await flushEffects(); - }); - - expect(onNavigate).toHaveBeenCalledWith("channels"); - expect(onNavigate).toHaveBeenCalledWith("settings", { - tab: "chrome-relay", - }); - }); - - it("Windows 缺依赖时点击安装不应真正调用安装接口", async () => { - const mounted = renderPage(); - await waitForInstallPage(mounted.container); - await flushEffects(); - - expect(mounted.container.textContent).toContain("windows"); - - await act(async () => { - findButton(mounted.container, "触发安装").click(); - await flushEffects(); - }); - - expect(mockInstall).not.toHaveBeenCalled(); - expect(mockToastError).toHaveBeenCalledWith( - "Windows 下请先手动安装 Node.js / Git,完成后点击“重新检测”,再安装 OpenClaw。", - ); - }); - - it("Windows 缺少 Node.js 时点击依赖安装应改为打开下载链接", async () => { - mockGetEnvironmentStatus.mockResolvedValue( - buildEnvironmentStatus({ - nodeStatus: "missing", - gitStatus: "ok", - summary: "Windows 下请先手动安装 Node.js。", - }), - ); - - const mounted = renderPage(); - await waitForInstallPage(mounted.container); - await flushEffects(); - - await act(async () => { - findButton(mounted.container, "触发 Node 安装").click(); - await flushEffects(); - }); - - expect(mockInstallDependency).not.toHaveBeenCalled(); - expect(mockToastInfo).toHaveBeenCalledWith( - "Windows 下请先手动下载安装 Node.js 22+,安装完成后重新检测。", - ); - expect(mockGetNodeDownloadUrl).toHaveBeenCalledTimes(1); - expect(mockOpenUrl).toHaveBeenCalledWith("https://nodejs.org/en/download"); - }); - - it("Windows 缺少 Git 时点击依赖安装应改为打开下载链接", async () => { - mockGetEnvironmentStatus.mockResolvedValue( - buildEnvironmentStatus({ - nodeStatus: "ok", - gitStatus: "missing", - summary: "Windows 下请先手动安装 Git。", - }), - ); - - const mounted = renderPage(); - await waitForInstallPage(mounted.container); - await flushEffects(); - - await act(async () => { - findButton(mounted.container, "触发 Git 安装").click(); - await flushEffects(); - }); - - expect(mockInstallDependency).not.toHaveBeenCalled(); - expect(mockToastInfo).toHaveBeenCalledWith( - "Windows 下请先手动下载安装 Git,并在安装时勾选加入 PATH,完成后重新检测。", - ); - expect(mockGetGitDownloadUrl).toHaveBeenCalledTimes(1); - expect(mockOpenUrl).toHaveBeenCalledWith( - "https://git-scm.com/download/win", - ); - }); - - it("macOS 缺少 Node.js 时点击依赖安装应继续走应用内修复", async () => { - mockDetectDesktopPlatform.mockReturnValue("macos"); - mockGetEnvironmentStatus.mockResolvedValue( - buildEnvironmentStatus({ - nodeStatus: "missing", - gitStatus: "ok", - summary: "macOS 下可继续一键修复 Node.js。", - }), - ); - - const mounted = renderPage(); - await waitForInstallPage(mounted.container); - await flushEffects(); - - await act(async () => { - findButton(mounted.container, "触发 Node 安装").click(); - await flushEffects(); - }); - - expect(mockInstallDependency).toHaveBeenCalledWith("node"); - expect(mockGetNodeDownloadUrl).not.toHaveBeenCalled(); - expect(mockOpenUrl).not.toHaveBeenCalled(); - }); - - it("Windows 依赖已就绪时点击安装应继续调用安装接口", async () => { - mockGetEnvironmentStatus.mockResolvedValue( - buildEnvironmentStatus({ - nodeStatus: "ok", - gitStatus: "ok", - summary: "Windows 运行环境已就绪,可以继续安装 OpenClaw。", - }), - ); - - const mounted = renderPage(); - await waitForInstallPage(mounted.container); - await flushEffects(); - - await act(async () => { - findButton(mounted.container, "触发安装").click(); - await flushEffects(); - }); - await flushInstallTimer(); - - await waitForCondition( - () => mockInstall.mock.calls.length > 0, - 20, - "Windows 就绪环境下未触发安装接口", - ); - expect(mockInstall).toHaveBeenCalledTimes(1); - expect(mockToastError).not.toHaveBeenCalled(); - }); - - it("检测到 OpenClaw 包但命令待生效时点击安装应提示重新检测", async () => { - mockGetEnvironmentStatus.mockResolvedValue( - buildEnvironmentStatus({ - nodeStatus: "ok", - gitStatus: "ok", - openclawStatus: "needs_reload", - openclawVersion: "0.4.1", - openclawPath: "C:/Users/demo/AppData/Roaming/npm", - openclawMessage: - "已在 npm 全局目录检测到 openclaw(0.4.1),但当前进程尚未解析到 openclaw 命令。请点击“重新检测”;若仍失败,请重启 Lime。", - summary: "已检测到 OpenClaw 包,但命令尚未生效;请点击“重新检测”。", - }), - ); - - const mounted = renderPage(); - await waitForInstallPage(mounted.container); - await flushEffects(); - - await act(async () => { - findButton(mounted.container, "触发安装").click(); - await flushEffects(); - }); - - expect(mockInstall).not.toHaveBeenCalled(); - expect(mockToastError).toHaveBeenCalledWith( - "已在 npm 全局目录检测到 openclaw(0.4.1),但当前进程尚未解析到 openclaw 命令。请点击“重新检测”;若仍失败,请重启 Lime。", - ); - }); - - it("macOS 缺依赖时点击安装应继续走自动修复安装流程", async () => { - mockDetectDesktopPlatform.mockReturnValue("macos"); - mockGetEnvironmentStatus.mockResolvedValue( - buildEnvironmentStatus({ - nodeStatus: "missing", - gitStatus: "missing", - summary: "macOS 下可继续一键修复环境并安装 OpenClaw。", - }), - ); - - const mounted = renderPage(); - await waitForInstallPage(mounted.container); - await flushEffects(); - - await act(async () => { - findButton(mounted.container, "触发安装").click(); - await flushEffects(); - }); - await flushInstallTimer(); - - await waitForCondition( - () => mockInstall.mock.calls.length > 0, - 20, - "macOS 缺依赖时未触发安装接口", - ); - expect(mockInstall).toHaveBeenCalledTimes(1); - expect(mockToastError).not.toHaveBeenCalled(); - }); - - it("切换执行环境时应先同步后端再刷新页面状态", async () => { - mockListRuntimeCandidates.mockResolvedValue([ - { - id: "mock-runtime", - source: "nvm", - binDir: "/Users/demo/.nvm/versions/node/v23.4.0/bin", - nodePath: "/Users/demo/.nvm/versions/node/v23.4.0/bin/node", - nodeVersion: "23.4.0", - npmPath: "/Users/demo/.nvm/versions/node/v23.4.0/bin/npm", - npmGlobalPrefix: "/Users/demo/.nvm/versions/node/v23.4.0", - openclawPath: "/Users/demo/.nvm/versions/node/v23.4.0/bin/openclaw", - openclawVersion: "2026.3.8", - openclawPackagePath: - "/Users/demo/.nvm/versions/node/v23.4.0/lib/node_modules/@qingchencloud/openclaw-zh/package.json", - isActive: true, - isPreferred: false, - }, - ]); - mockSetPreferredRuntime.mockResolvedValue({ - success: true, - message: "已固定使用执行环境:nvm · Node 23.4.0。", - }); - - const mounted = renderPage(); - await waitForInstallPage(mounted.container); - await flushEffects(); - - await act(async () => { - findButton(mounted.container, "切换运行时").click(); - await flushEffects(); - }); - - await waitForCondition( - () => - mockSetPreferredRuntime.mock.calls.some( - ([runtimeId]) => runtimeId === "mock-runtime", - ), - 20, - "切换执行环境后未同步到后端", - ); - expect(mockGetEnvironmentStatus.mock.calls.length).toBeGreaterThanOrEqual( - 2, - ); - }); - - it("命令待刷新但 Gateway 已运行时不应阻塞进入后续页面", async () => { - mockGetEnvironmentStatus.mockResolvedValue( - buildEnvironmentStatus({ - nodeStatus: "ok", - gitStatus: "ok", - openclawStatus: "needs_reload", - openclawVersion: "2026.3.13-zh.1", - openclawPath: "/Users/demo/.nvm/versions/node/v23.4.0", - summary: "已检测到 OpenClaw 包,但命令尚未生效。", - }), - ); - mockGetStatus.mockResolvedValue({ status: "running", port: 18790 }); - - const mounted = renderPage(); - await flushEffects(); - - await waitForCondition( - () => - !!mounted.container.querySelector( - '[data-testid="openclaw-runtime-page"]', - ), - 40, - "Gateway 已运行时仍被阻塞在安装页", - ); - }); - - it("命令待刷新时仍应允许进入安装页重新检测", async () => { - mockGetEnvironmentStatus.mockResolvedValue( - buildEnvironmentStatus({ - nodeStatus: "ok", - gitStatus: "ok", - openclawStatus: "needs_reload", - openclawVersion: "2026.3.13-zh.1", - openclawPath: "/Users/demo/.nvm/versions/node/v23.4.0", - summary: "已检测到 OpenClaw 包,但命令尚未生效。", - }), - ); - mockGetStatus.mockResolvedValue({ status: "running", port: 18790 }); - - const mounted = renderPage({ - pageParams: { subpage: "install" }, - onNavigate: vi.fn(), - }); - await waitForInstallPage(mounted.container); - await flushEffects(); - - expect( - mounted.container.querySelector('[data-testid="openclaw-install-page"]'), - ).toBeTruthy(); - }); - - it("工作台概览中的安装路径摘要应压缩显示长路径", async () => { - const longInstallPath = - "/Users/demo/Library/Application Support/lime/runtime/openclaw/node_modules/@qingchencloud/openclaw-zh/package.json"; - - mockGetEnvironmentStatus.mockResolvedValue( - buildEnvironmentStatus({ - nodeStatus: "ok", - gitStatus: "ok", - openclawStatus: "ok", - openclawVersion: "2026.3.18", - openclawPath: longInstallPath, - summary: "OpenClaw 已安装。", - }), - ); - - const mounted = renderPage(); - await flushEffects(); - - await waitForCondition( - () => mounted.container.textContent?.includes("OpenClaw 工作台") ?? false, - 40, - "OpenClaw 工作台未在预期时间内渲染", - ); - - const compactedEntries = Array.from( - mounted.container.querySelectorAll("[title]"), - ).filter((item) => item.getAttribute("title") === longInstallPath); - - expect(compactedEntries.length).toBeGreaterThan(0); - expect( - compactedEntries.some( - (item) => - item.textContent !== longInstallPath && - item.textContent?.includes("..."), - ), - ).toBe(true); - }); - - it("工作台摘要中的桌面面板快捷按钮应直接触发打开动作", async () => { - mockGetEnvironmentStatus.mockResolvedValue( - buildEnvironmentStatus({ - nodeStatus: "ok", - gitStatus: "ok", - openclawStatus: "ok", - openclawVersion: "2026.3.13", - openclawPath: "/Users/demo/.nvm/versions/node/v23.4.0/bin/openclaw", - summary: "OpenClaw 已安装。", - }), - ); - mockGetStatus.mockResolvedValue({ status: "running", port: 18790 }); - - const mounted = renderPage(); - await flushEffects(); - - await waitForCondition( - () => - !!mounted.container.querySelector( - '[data-testid="openclaw-runtime-page"]', - ), - 40, - "OpenClaw 运行页未在预期时间内渲染", - ); - - await act(async () => { - // 切换到快捷操作 tab - findButton(mounted.container, "快捷操作").click(); - await flushEffects(); - }); - - await act(async () => { - findButton(mounted.container, "打开桌面面板").click(); - await flushEffects(); - }); - - expect(mockHandleOpenDashboardWindow).toHaveBeenCalledTimes(1); - }); - - it("智能升级前会自动切到检测到 OpenClaw 的执行环境", async () => { - mockGetEnvironmentStatus.mockResolvedValue( - buildEnvironmentStatus({ - nodeStatus: "ok", - gitStatus: "ok", - openclawStatus: "ok", - openclawVersion: "2026.3.8", - openclawPath: "/Users/demo/.nvm/versions/node/v23.4.0/bin/openclaw", - summary: "OpenClaw 已安装。", - }), - ); - mockDetectDesktopPlatform.mockReturnValue("macos"); - mockListRuntimeCandidates.mockResolvedValue([ - { - id: "node-without-openclaw", - source: "PhpWebStudy", - binDir: "/Users/demo/Library/PhpWebStudy/env/node/bin", - nodePath: "/Users/demo/Library/PhpWebStudy/env/node/bin/node", - nodeVersion: "22.12.0", - npmPath: "/Users/demo/Library/PhpWebStudy/env/node/bin/npm", - npmGlobalPrefix: "/Users/demo/Library/PhpWebStudy/env/node", - openclawPath: null, - openclawVersion: null, - openclawPackagePath: null, - isActive: true, - isPreferred: false, - }, - { - id: "node-with-openclaw", - source: "nvm", - binDir: "/Users/demo/.nvm/versions/node/v23.4.0/bin", - nodePath: "/Users/demo/.nvm/versions/node/v23.4.0/bin/node", - nodeVersion: "23.4.0", - npmPath: "/Users/demo/.nvm/versions/node/v23.4.0/bin/npm", - npmGlobalPrefix: "/Users/demo/.nvm/versions/node/v23.4.0", - openclawPath: "/Users/demo/.nvm/versions/node/v23.4.0/bin/openclaw", - openclawVersion: "2026.3.8", - openclawPackagePath: - "/Users/demo/.nvm/versions/node/v23.4.0/lib/node_modules/@qingchencloud/openclaw-zh/package.json", - isActive: false, - isPreferred: false, - }, - ]); - - const mounted = renderPage(); - await flushEffects(); - - await waitForCondition( - () => - !!mounted.container.querySelector( - '[data-testid="openclaw-runtime-page"]', - ), - 40, - "OpenClaw 运行页未在预期时间内渲染", - ); - - await act(async () => { - findButton(mounted.container, "触发升级").click(); - await flushEffects(); - }); - await flushInstallTimer(); - - await waitForCondition( - () => - mockSetPreferredRuntime.mock.calls.some( - ([runtimeId]) => runtimeId === "node-with-openclaw", - ), - 40, - "升级前未自动切换到检测到 OpenClaw 的执行环境", - ); - expect(mockPerformUpdate).toHaveBeenCalledTimes(1); - expect(mockToastInfo).toHaveBeenCalledWith( - "已自动切换到检测到 OpenClaw 的执行环境。", - expect.objectContaining({ - description: expect.stringContaining("OpenClaw 2026.3.8"), - }), - ); - }); - - it("升级后若运行中仍是旧版本应显示 warning 而不是默认 success", async () => { - mockGetEnvironmentStatus.mockResolvedValue( - buildEnvironmentStatus({ - nodeStatus: "ok", - gitStatus: "ok", - openclawStatus: "ok", - openclawVersion: "2026.3.13-zh.1", - openclawPath: "/Users/demo/.nvm/versions/node/v23.4.0/bin/openclaw", - summary: "OpenClaw 已安装。", - }), - ); - mockDetectDesktopPlatform.mockReturnValue("macos"); - mockGetStatus.mockResolvedValue({ status: "running", port: 18790 }); - mockCheckHealth.mockResolvedValue({ - status: "healthy", - gatewayPort: 18790, - uptime: 20, - version: "2026.3.8", - }); - mockCheckUpdate.mockResolvedValue({ - hasUpdate: true, - currentVersion: "2026.3.13", - latestVersion: "2026.3.13", - channel: "stable", - installKind: "package", - packageManager: "pnpm", - message: null, - }); - mockListRuntimeCandidates.mockResolvedValue([ - { - id: "node-with-openclaw", - source: "nvm", - binDir: "/Users/demo/.nvm/versions/node/v23.4.0/bin", - nodePath: "/Users/demo/.nvm/versions/node/v23.4.0/bin/node", - nodeVersion: "23.4.0", - npmPath: "/Users/demo/.nvm/versions/node/v23.4.0/bin/npm", - npmGlobalPrefix: "/Users/demo/.nvm/versions/node/v23.4.0", - openclawPath: "/Users/demo/.nvm/versions/node/v23.4.0/bin/openclaw", - openclawVersion: "2026.3.13", - openclawPackagePath: - "/Users/demo/.nvm/versions/node/v23.4.0/lib/node_modules/@qingchencloud/openclaw-zh/package.json", - isActive: true, - isPreferred: false, - }, - ]); - - const mounted = renderPage(); - await flushEffects(); - - await waitForCondition( - () => - !!mounted.container.querySelector( - '[data-testid="openclaw-runtime-page"]', - ), - 40, - "OpenClaw 运行页未在预期时间内渲染", - ); - - await act(async () => { - findButton(mounted.container, "触发升级").click(); - await flushEffects(); - }); - await flushInstallTimer(); - - await waitForCondition( - () => mockPerformUpdate.mock.calls.length > 0, - 40, - "未触发升级接口", - ); - - expect(mockToastWarning).toHaveBeenCalledWith( - "新版本已经安装,但当前仍在运行旧版 Gateway。", - expect.objectContaining({ - description: expect.stringContaining("当前运行中 2026.3.8"), - }), - ); - expect(mockToastSuccess).not.toHaveBeenCalledWith("ok"); - }); -}); diff --git a/src/components/openclaw/OpenClawPage.tsx b/src/components/openclaw/OpenClawPage.tsx deleted file mode 100644 index fba43b365..000000000 --- a/src/components/openclaw/OpenClawPage.tsx +++ /dev/null @@ -1,3256 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { - ArrowUpCircle, - Loader2, - MonitorSmartphone, - RefreshCw, - Settings2, - ShieldCheck, - Sparkles, - Wrench, - type LucideIcon, -} from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import type { ConfiguredProvider } from "@/hooks/useConfiguredProviders"; -import { useProviderModels } from "@/hooks/useProviderModels"; -import { useApiKeyProvider } from "@/hooks/useApiKeyProvider"; -import { getRegistryIdFromType } from "@/lib/constants/providerMappings"; -import { - copyTextToClipboard, - detectDesktopPlatform, - type DesktopPlatform, -} from "@/lib/crashDiagnostic"; -import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry"; -import type { - OpenClawPageParams, - OpenClawSubpage, - Page, - PageParams, -} from "@/types/page"; -import { SettingsTabs } from "@/types/settings"; -import { - openclawApi, - type OpenClawBinaryAvailabilityStatus, - type OpenClawBinaryInstallStatus, - type OpenClawChannelInfo, - type OpenClawEnvironmentStatus, - type OpenClawGatewayStatus, - type OpenClawHealthInfo, - type OpenClawInstallProgressEvent, - type OpenClawNodeCheckResult, - type OpenClawRuntimeCandidate, - type OpenClawSyncModelEntry, - type OpenClawUpdateInfo, -} from "@/lib/api/openclaw"; -import { wechatChannelSetRuntimeModel } from "@/lib/api/channelsRuntime"; -import { getOrCreateDefaultProject } from "@/lib/api/project"; -import { cn } from "@/lib/utils"; - -import { OpenClawConfigurePage } from "./OpenClawConfigurePage"; -import { OpenClawDashboardPage } from "./OpenClawDashboardPage"; -import { OpenClawInstallPage } from "./OpenClawInstallPage"; -import { OpenClawMark } from "./OpenClawMark"; -import { OpenClawProgressPage } from "./OpenClawProgressPage"; -import { OpenClawRuntimePage } from "./OpenClawRuntimePage"; -import { OpenClawSceneNav } from "./OpenClawSceneNav"; -import { - type OpenClawOperationKind, - type OpenClawOperationHistoryEntry, - type OpenClawOperationState, - type OpenClawScene, - type OpenClawSceneDefinition, - type OpenClawSceneStatus, - type OpenClawSubpage as LocalOpenClawSubpage, -} from "./types"; -import { useOpenClawStore } from "./useOpenClawStore"; -import { openUrl } from "./openUrl"; -import { compactPathLabel } from "./pathDisplay"; -import { useOpenClawDashboardWindow } from "./useOpenClawDashboardWindow"; -import { - openClawPanelClassName, - openClawPrimaryButtonClassName, - openClawSecondaryButtonClassName, - openClawSubPanelClassName, -} from "./openclawStyles"; - -const OPENCLAW_DOCS_URL = "https://docs.openclaw.ai/"; -const SUPPORTED_PROVIDER_TYPES = new Set([ - "openai", - "openai-response", - "codex", - "anthropic", - "anthropic-compatible", - "gemini", - "new-api", - "gateway", - "ollama", - "fal", -]); - -const progressSubpageByAction: Record = - { - install: "installing", - repair: "installing", - uninstall: "uninstalling", - restart: "restarting", - update: "updating", - }; - -const progressActionBySubpage: Partial< - Record -> = { - installing: "install", - updating: "update", - uninstalling: "uninstall", - restarting: "restart", -}; - -const openClawScenes: OpenClawSceneDefinition[] = [ - { - id: "setup", - title: "安装环境", - description: "检查 Node.js、Git 与 OpenClaw 安装状态。", - }, - { - id: "sync", - title: "配置模型", - description: "选择 Provider、模型并同步独立副本配置。", - }, - { - id: "dashboard", - title: "运行与访问", - description: "启动 Gateway,打开桌面面板或进入 Dashboard。", - }, -]; - -function isOpenClawSubpage(value: unknown): value is OpenClawSubpage { - return [ - "install", - "installing", - "configure", - "runtime", - "updating", - "restarting", - "uninstalling", - "dashboard", - ].includes(String(value)); -} - -function formatNodeStatus(nodeStatus: OpenClawNodeCheckResult | null): string { - if (!nodeStatus) return "未检查"; - if (nodeStatus.status === "ok") { - return `可用${nodeStatus.version ? ` · ${nodeStatus.version}` : ""}`; - } - if (nodeStatus.status === "version_low") { - return `版本过低${nodeStatus.version ? ` · ${nodeStatus.version}` : ""}`; - } - return "未检测到 Node.js"; -} - -function formatBinaryStatus( - status: OpenClawBinaryAvailabilityStatus | null, - successLabel: string, - failureLabel: string, -): string { - if (!status) return "未检查"; - return status.available - ? `${successLabel}${status.path ? ` · ${status.path}` : ""}` - : failureLabel; -} - -type OpenClawRefreshSnapshot = { - appliedRuntimeId: string | null; - environment: OpenClawEnvironmentStatus; - runtimes: OpenClawRuntimeCandidate[]; - updateInfo: OpenClawUpdateInfo | null; - gatewayStatus: OpenClawGatewayStatus; - gatewayPort: number; - healthInfo: OpenClawHealthInfo | null; - channels: OpenClawChannelInfo[]; - dashboardWindowOpen: boolean; -}; - -type OpenClawSnapshotVersionState = { - runtimeCandidate: OpenClawRuntimeCandidate | null; - installedVersion: string | null; - runningVersion: string | null; - versionMismatch: boolean; -}; - -function selectCurrentRuntimeCandidate( - runtimeCandidates: OpenClawRuntimeCandidate[], - preferredRuntimeId: string | null, -): OpenClawRuntimeCandidate | null { - return ( - (preferredRuntimeId - ? runtimeCandidates.find( - (candidate) => candidate.id === preferredRuntimeId, - ) - : null) || - runtimeCandidates.find((candidate) => candidate.isPreferred) || - runtimeCandidates.find((candidate) => candidate.isActive) || - null - ); -} - -function runtimeCandidateHasOpenClawInstallation( - candidate: OpenClawRuntimeCandidate | null | undefined, -): boolean { - return Boolean( - candidate?.openclawVersion || - candidate?.openclawPath || - candidate?.openclawPackagePath, - ); -} - -function formatRuntimeCandidateLabel( - candidate: OpenClawRuntimeCandidate | null | undefined, -): string { - if (!candidate) { - return "自动选择"; - } - - return `${candidate.source} · Node ${candidate.nodeVersion || "未识别"}`; -} - -function formatRuntimeCandidateOpenClawSummary( - candidate: OpenClawRuntimeCandidate | null | undefined, -): string { - if (!candidate) { - return "未识别执行环境"; - } - - if (candidate.openclawVersion) { - return `${formatRuntimeCandidateLabel(candidate)} · OpenClaw ${candidate.openclawVersion}`; - } - - if (candidate.openclawPath) { - return `${formatRuntimeCandidateLabel(candidate)} · 已检测到 OpenClaw 命令`; - } - - if (candidate.openclawPackagePath) { - return `${formatRuntimeCandidateLabel(candidate)} · 已检测到 OpenClaw 包`; - } - - return `${formatRuntimeCandidateLabel(candidate)} · 未检测到 OpenClaw`; -} - -function trimOpenClawVersion( - version: string | null | undefined, -): string | null { - const trimmed = version?.trim(); - return trimmed ? trimmed : null; -} - -function normalizeComparableOpenClawVersion( - version: string | null | undefined, -): string | null { - const trimmed = trimOpenClawVersion(version); - if (!trimmed) { - return null; - } - - return trimmed - .replace(/^v/i, "") - .replace(/-zh(?:\.\d+)?$/i, "") - .trim(); -} - -function resolveInstalledOpenClawVersion(params: { - updateInfo?: OpenClawUpdateInfo | null; - environmentStatus?: OpenClawEnvironmentStatus | null; - runtimeCandidate?: OpenClawRuntimeCandidate | null; -}): string | null { - return ( - trimOpenClawVersion(params.updateInfo?.currentVersion) || - trimOpenClawVersion(params.runtimeCandidate?.openclawVersion) || - trimOpenClawVersion(params.environmentStatus?.openclaw.version) - ); -} - -function resolveRunningOpenClawVersion(params: { - gatewayRunning: boolean; - healthInfo?: OpenClawHealthInfo | null; -}): string | null { - if (!params.gatewayRunning) { - return null; - } - - return trimOpenClawVersion(params.healthInfo?.version); -} - -function hasOpenClawVersionMismatch(params: { - gatewayRunning: boolean; - installedVersion: string | null; - runningVersion: string | null; -}): boolean { - if (!params.gatewayRunning) { - return false; - } - - const installedComparable = normalizeComparableOpenClawVersion( - params.installedVersion, - ); - const runningComparable = normalizeComparableOpenClawVersion( - params.runningVersion, - ); - - return Boolean( - installedComparable && - runningComparable && - installedComparable !== runningComparable, - ); -} - -function resolveSnapshotOpenClawVersionState( - snapshot: OpenClawRefreshSnapshot, -): OpenClawSnapshotVersionState { - const runtimeCandidate = selectCurrentRuntimeCandidate( - snapshot.runtimes, - snapshot.appliedRuntimeId, - ); - const installedVersion = resolveInstalledOpenClawVersion({ - updateInfo: snapshot.updateInfo, - environmentStatus: snapshot.environment, - runtimeCandidate, - }); - const runningVersion = resolveRunningOpenClawVersion({ - gatewayRunning: snapshot.gatewayStatus === "running", - healthInfo: snapshot.healthInfo, - }); - - return { - runtimeCandidate, - installedVersion, - runningVersion, - versionMismatch: hasOpenClawVersionMismatch({ - gatewayRunning: snapshot.gatewayStatus === "running", - installedVersion, - runningVersion, - }), - }; -} - -function buildCompatibleProviders( - providers: ReturnType["providers"], -): ConfiguredProvider[] { - return providers - .filter( - (provider) => - provider.enabled && - provider.api_key_count > 0 && - SUPPORTED_PROVIDER_TYPES.has(provider.type), - ) - .map((provider) => ({ - key: provider.id, - label: provider.name, - registryId: provider.id, - fallbackRegistryId: getRegistryIdFromType( - provider.type, - provider.api_host, - ), - type: provider.type, - providerId: provider.id, - apiHost: provider.api_host, - customModels: provider.custom_models, - credentialType: `${provider.type}_key`, - })); -} - -function toSyncModels( - models: EnhancedModelMetadata[], -): OpenClawSyncModelEntry[] { - return models.map((model) => ({ - id: model.id, - name: model.display_name, - contextWindow: model.limits.context_length ?? undefined, - })); -} - -function openClawOperationLabel(kind: OpenClawOperationKind | null): string { - switch (kind) { - case "install": - return "安装"; - case "repair": - return "修复环境"; - case "update": - return "升级"; - case "uninstall": - return "卸载"; - case "restart": - return "重启"; - default: - return "处理"; - } -} - -function buildOpenClawRepairPrompt( - kind: OpenClawOperationKind | null, - message: string | null, - logs: OpenClawInstallProgressEvent[], - systemInfo: { - os: string; - userAgent: string; - runtime: string; - installPath: string; - nodeStatus: string; - gitStatus: string; - gatewayStatus: string; - gatewayPort: number; - healthStatus: string; - dashboardUrl: string; - }, -): string { - const operationLabel = openClawOperationLabel(kind); - const visibleLogs = logs.slice(-40); - const summarizedError = - visibleLogs - .slice() - .reverse() - .find((log) => log.level === "error" || log.level === "warn")?.message || - message || - "安装/运行过程中出现异常"; - const logText = - visibleLogs.length > 0 - ? visibleLogs - .map((log) => `[${log.level.toUpperCase()}] ${log.message}`) - .join("\n") - : "暂无日志输出"; - - return [ - `我正在${operationLabel} openclaw,但在过程中遇到了这个问题:${summarizedError}。`, - "", - "请帮我:", - "1. 判断最可能的根因", - "2. 给出最小可执行的修复步骤", - "3. 如果需要修改环境变量、Node/npm、PATH、全局包冲突,请明确指出", - "4. 如果可以在当前 Lime / Tauri 项目中修复,也请给出具体修改建议", - "", - "当前系统信息:", - `- 操作系统: ${systemInfo.os}`, - `- User Agent: ${systemInfo.userAgent}`, - `- 执行环境: ${systemInfo.runtime}`, - `- OpenClaw 安装路径: ${systemInfo.installPath}`, - `- Node.js 状态: ${systemInfo.nodeStatus}`, - `- Git 状态: ${systemInfo.gitStatus}`, - `- Gateway 状态: ${systemInfo.gatewayStatus}`, - `- Gateway 端口: ${systemInfo.gatewayPort}`, - `- 健康检查: ${systemInfo.healthStatus}`, - `- Dashboard 地址: ${systemInfo.dashboardUrl}`, - "", - "以下是完整日志:", - logText, - ].join("\n"); -} - -function buildOpenClawRawLogsText( - logs: OpenClawInstallProgressEvent[], -): string { - return logs.length > 0 - ? logs - .map((log) => `[${log.level.toUpperCase()}] ${log.message}`) - .join("\n") - : ""; -} - -function renderBlockedPage( - title: string, - description: string, - actionLabel: string, - onAction: () => void, -) { - return ( -
-

- {title} -

-

{description}

- -
- ); -} - -function resolveOpenClawSubpage( - candidate: OpenClawSubpage, - workflowReady: boolean, - gatewayRunning: boolean, - gatewayStarting: boolean, - operationState: OpenClawOperationState, - options?: { - allowInstallPage?: boolean; - }, -): OpenClawSubpage { - const allowInstallPage = options?.allowInstallPage ?? false; - if (operationState.running && operationState.kind) { - return progressSubpageByAction[operationState.kind]; - } - - if ( - operationState.kind && - operationState.message && - candidate === progressSubpageByAction[operationState.kind] - ) { - return candidate; - } - - if (!workflowReady) { - return "install"; - } - - if (candidate === "install") { - return allowInstallPage ? "install" : "runtime"; - } - - if (candidate === "installing" || candidate === "updating") { - return "runtime"; - } - - if (candidate === "dashboard" && !gatewayRunning && !gatewayStarting) { - return "runtime"; - } - - if ( - (candidate === "uninstalling" || candidate === "restarting") && - !operationState.running - ) { - return gatewayRunning || gatewayStarting ? "runtime" : "configure"; - } - - return candidate; -} - -interface OpenClawPageProps { - pageParams?: OpenClawPageParams; - onNavigate?: (page: Page, params?: PageParams) => void; - isActive?: boolean; -} - -export function OpenClawPage({ - pageParams, - onNavigate, - isActive = false, -}: OpenClawPageProps) { - const desktopPlatform = useMemo( - () => detectDesktopPlatform(), - [], - ); - const isWindowsPlatform = desktopPlatform === "windows"; - const { - providers, - loading: providersLoading, - refresh: refreshProviders, - } = useApiKeyProvider(); - const compatibleProviders = useMemo( - () => buildCompatibleProviders(providers), - [providers], - ); - - const selectedProviderId = useOpenClawStore( - (state) => state.selectedProviderId, - ); - const selectedModelId = useOpenClawStore((state) => state.selectedModelId); - const gatewayPort = useOpenClawStore((state) => state.gatewayPort); - const preferredRuntimeId = useOpenClawStore( - (state) => state.preferredRuntimeId, - ); - const lastSynced = useOpenClawStore((state) => state.lastSynced); - const recentOperation = useOpenClawStore((state) => state.recentOperation); - const setSelectedProviderId = useOpenClawStore( - (state) => state.setSelectedProviderId, - ); - const setSelectedModelId = useOpenClawStore( - (state) => state.setSelectedModelId, - ); - const setGatewayPort = useOpenClawStore((state) => state.setGatewayPort); - const setPreferredRuntimeId = useOpenClawStore( - (state) => state.setPreferredRuntimeId, - ); - const setLastSynced = useOpenClawStore((state) => state.setLastSynced); - const setRecentOperation = useOpenClawStore( - (state) => state.setRecentOperation, - ); - const clearLastSynced = useOpenClawStore((state) => state.clearLastSynced); - - const [fallbackSubpage, setFallbackSubpage] = - useState("install"); - const hasManualFallbackNavigationRef = useRef(false); - const [statusResolved, setStatusResolved] = useState(false); - const [installedStatus, setInstalledStatus] = - useState(null); - const [environmentStatus, setEnvironmentStatus] = - useState(null); - const [nodeStatus, setNodeStatus] = useState( - null, - ); - const [gitStatus, setGitStatus] = - useState(null); - const [gatewayStatus, setGatewayStatus] = - useState("stopped"); - const [healthInfo, setHealthInfo] = useState(null); - const [updateInfo, setUpdateInfo] = useState(null); - const [runtimeCandidates, setRuntimeCandidates] = useState< - OpenClawRuntimeCandidate[] - >([]); - const [channels, setChannels] = useState([]); - const [installLogs, setInstallLogs] = useState< - OpenClawInstallProgressEvent[] - >([]); - const installLogsRef = useRef([]); - const [syncing, setSyncing] = useState(false); - const [starting, setStarting] = useState(false); - const [stopping, setStopping] = useState(false); - const [checkingHealth, setCheckingHealth] = useState(false); - const [checkingUpdate, setCheckingUpdate] = useState(false); - const [switchingRuntime, setSwitchingRuntime] = useState(false); - const [cleaningTemp, setCleaningTemp] = useState(false); - const [handingOffToAgent, setHandingOffToAgent] = useState(false); - const lastWechatRuntimeModelSyncRef = useRef(null); - const [operationState, setOperationState] = useState({ - kind: null, - target: null, - running: false, - title: null, - description: null, - message: null, - returnSubpage: "install", - }); - - const requestedSubpage = isOpenClawSubpage(pageParams?.subpage) - ? pageParams.subpage - : null; - - const selectedProvider = useMemo( - () => - compatibleProviders.find( - (provider) => provider.key === selectedProviderId, - ), - [compatibleProviders, selectedProviderId], - ); - - const { - models: providerModels, - loading: modelsLoading, - error: modelsError, - } = useProviderModels(selectedProvider, { returnFullMetadata: true }); - - const installed = installedStatus?.installed ?? false; - const gatewayRunning = gatewayStatus === "running"; - const gatewayStarting = gatewayStatus === "starting"; - const updating = operationState.running && operationState.kind === "update"; - const hasSelectedConfig = - Boolean(selectedProvider) && selectedModelId.trim().length > 0; - const missingInstallDependencies = useMemo(() => { - if (!environmentStatus) { - return [] as string[]; - } - - return [ - environmentStatus.node.status !== "ok" ? "Node.js" : null, - environmentStatus.git.status !== "ok" ? "Git" : null, - ].filter(Boolean) as string[]; - }, [environmentStatus]); - const installBlockMessage = useMemo(() => { - if (environmentStatus?.openclaw.status === "needs_reload") { - return environmentStatus.openclaw.message; - } - - if (!isWindowsPlatform || missingInstallDependencies.length === 0) { - return null; - } - - return `Windows 下请先手动安装 ${missingInstallDependencies.join(" / ")},完成后点击“重新检测”,再安装 OpenClaw。`; - }, [environmentStatus, isWindowsPlatform, missingInstallDependencies]); - const currentRuntimeCandidate = useMemo( - () => selectCurrentRuntimeCandidate(runtimeCandidates, preferredRuntimeId), - [preferredRuntimeId, runtimeCandidates], - ); - const currentRuntimeSummary = useMemo(() => { - if (!currentRuntimeCandidate) { - return preferredRuntimeId - ? "已固定执行环境,但当前未检测到对应运行时" - : "自动选择执行环境"; - } - - return `${currentRuntimeCandidate.source} · Node ${ - currentRuntimeCandidate.nodeVersion || "未识别" - }${ - currentRuntimeCandidate.openclawVersion - ? ` · OpenClaw ${currentRuntimeCandidate.openclawVersion}` - : "" - }`; - }, [currentRuntimeCandidate, preferredRuntimeId]); - const currentRuntimeHasOpenClawInstallation = useMemo( - () => runtimeCandidateHasOpenClawInstallation(currentRuntimeCandidate), - [currentRuntimeCandidate], - ); - const recommendedUpdateRuntimeCandidate = useMemo(() => { - if (currentRuntimeHasOpenClawInstallation) { - return currentRuntimeCandidate; - } - - return ( - runtimeCandidates.find((candidate) => - runtimeCandidateHasOpenClawInstallation(candidate), - ) || null - ); - }, [ - currentRuntimeCandidate, - currentRuntimeHasOpenClawInstallation, - runtimeCandidates, - ]); - const updateRuntimeRequiresSwitch = useMemo(() => { - if (currentRuntimeHasOpenClawInstallation) { - return false; - } - - return Boolean( - recommendedUpdateRuntimeCandidate && - recommendedUpdateRuntimeCandidate.id !== currentRuntimeCandidate?.id, - ); - }, [ - currentRuntimeCandidate?.id, - currentRuntimeHasOpenClawInstallation, - recommendedUpdateRuntimeCandidate, - ]); - const updateRuntimeReady = useMemo( - () => - currentRuntimeHasOpenClawInstallation || - Boolean(recommendedUpdateRuntimeCandidate), - [currentRuntimeHasOpenClawInstallation, recommendedUpdateRuntimeCandidate], - ); - const updateRuntimeNotice = useMemo(() => { - if (updateRuntimeRequiresSwitch && recommendedUpdateRuntimeCandidate) { - return { - tone: "warning" as const, - title: "升级前会自动切到已安装 OpenClaw 的执行环境", - description: `当前执行环境没有检测到 OpenClaw。推荐使用 ${formatRuntimeCandidateOpenClawSummary( - recommendedUpdateRuntimeCandidate, - )};点击“智能升级”时 Lime 也会自动切换。`, - actionLabel: "立即切换", - }; - } - - if (!updateRuntimeReady) { - return { - tone: "error" as const, - title: "当前还没识别到可升级的执行环境", - description: - "未在任何运行时中检测到 OpenClaw 命令或安装包。智能升级仍会尝试自动识别,但建议先重新检测或完成安装。", - }; - } - - return null; - }, [ - recommendedUpdateRuntimeCandidate, - updateRuntimeReady, - updateRuntimeRequiresSwitch, - ]); - const softInstalled = useMemo( - () => - environmentStatus?.openclaw.status === "needs_reload" && - (gatewayRunning || - gatewayStarting || - Boolean(updateInfo?.currentVersion) || - Boolean(currentRuntimeCandidate?.openclawPath) || - Boolean(currentRuntimeCandidate?.openclawVersion)), - [ - currentRuntimeCandidate?.openclawPath, - currentRuntimeCandidate?.openclawVersion, - environmentStatus?.openclaw.status, - gatewayRunning, - gatewayStarting, - updateInfo?.currentVersion, - ], - ); - const installedVersion = useMemo( - () => - resolveInstalledOpenClawVersion({ - updateInfo, - environmentStatus, - runtimeCandidate: currentRuntimeCandidate, - }), - [currentRuntimeCandidate, environmentStatus, updateInfo], - ); - const runningVersion = useMemo( - () => - resolveRunningOpenClawVersion({ - gatewayRunning, - healthInfo, - }), - [gatewayRunning, healthInfo], - ); - const versionMismatch = useMemo( - () => - hasOpenClawVersionMismatch({ - gatewayRunning, - installedVersion, - runningVersion, - }), - [gatewayRunning, installedVersion, runningVersion], - ); - const versionStatusSummary = useMemo(() => { - if (!installedVersion) { - return { - label: "未识别安装版本", - description: "尚未检测到 OpenClaw 已安装版本。", - }; - } - - if (versionMismatch) { - return { - label: "运行中仍是旧版本", - description: `已安装 ${installedVersion},但当前 Gateway 仍在运行 ${runningVersion || "未知版本"}。请重启 Gateway 让新版本生效。`, - }; - } - - if (gatewayRunning) { - return { - label: runningVersion ? "版本已生效" : "运行中待校验", - description: runningVersion - ? `当前 Gateway 已运行 ${runningVersion}。` - : "Gateway 已运行,但暂未识别运行中的版本号。", - }; - } - - if (gatewayStarting) { - return { - label: "启动中待校验", - description: `已安装 ${installedVersion},等待 Gateway 启动后确认运行中版本。`, - }; - } - - return { - label: "待启动确认", - description: `已安装 ${installedVersion},启动 Gateway 后即可确认新版本是否已生效。`, - }; - }, [ - gatewayRunning, - gatewayStarting, - installedVersion, - runningVersion, - versionMismatch, - ]); - const dashboardProfileVersionKey = useMemo( - () => - normalizeComparableOpenClawVersion(runningVersion || installedVersion), - [installedVersion, runningVersion], - ); - const openclawWorkflowReady = installed || softInstalled; - const canStartGateway = - openclawWorkflowReady && !gatewayRunning && !gatewayStarting; - const canStopGateway = openclawWorkflowReady && gatewayStatus !== "stopped"; - const canRestartGateway = openclawWorkflowReady && gatewayRunning; - const canSync = openclawWorkflowReady && hasSelectedConfig; - const canStartFromConfigure = - canStartGateway && (hasSelectedConfig || !!lastSynced); - const { - dashboardLoading, - dashboardUrl, - dashboardWindowBusy, - dashboardWindowOpen, - refreshDashboardUrl, - refreshDashboardWindowState, - handleOpenDashboardWindow, - handleOpenDashboardExternal, - closeDashboardWindowSilently, - } = useOpenClawDashboardWindow({ - gatewayStatus, - profileVersionKey: dashboardProfileVersionKey, - }); - - const defaultSubpage = useMemo(() => { - if (operationState.running && operationState.kind) { - return progressSubpageByAction[operationState.kind]; - } - - if (!openclawWorkflowReady) { - return "install"; - } - - return "runtime"; - }, [openclawWorkflowReady, operationState.kind, operationState.running]); - - const requestedOrFallbackSubpage = - requestedSubpage ?? (onNavigate ? defaultSubpage : fallbackSubpage); - const allowInstallPage = - environmentStatus?.openclaw.status === "needs_reload"; - const currentSubpage = useMemo( - () => - resolveOpenClawSubpage( - requestedOrFallbackSubpage, - openclawWorkflowReady, - gatewayRunning, - gatewayStarting, - operationState, - { allowInstallPage }, - ), - [ - gatewayRunning, - gatewayStarting, - allowInstallPage, - openclawWorkflowReady, - operationState, - requestedOrFallbackSubpage, - ], - ); - - const currentScene = useMemo(() => { - if ( - currentSubpage === "install" || - currentSubpage === "installing" || - currentSubpage === "uninstalling" - ) { - return "setup"; - } - - if (currentSubpage === "configure") { - return "sync"; - } - - return "dashboard"; - }, [currentSubpage]); - - const currentSubpageLabel = useMemo(() => { - switch (currentSubpage) { - case "install": - return "安装环境"; - case "installing": - return "正在安装"; - case "configure": - return "配置模型"; - case "runtime": - return "运行状态"; - case "updating": - return "正在升级"; - case "restarting": - return "正在重启"; - case "uninstalling": - return "正在卸载"; - case "dashboard": - return "Dashboard 访问"; - default: - return "OpenClaw"; - } - }, [currentSubpage]); - - const navigateSubpage = useCallback( - (subpage: OpenClawSubpage) => { - if (onNavigate) { - onNavigate("openclaw", { subpage }); - } else { - hasManualFallbackNavigationRef.current = true; - setFallbackSubpage(subpage); - } - }, - [onNavigate], - ); - - useEffect(() => { - installLogsRef.current = installLogs; - }, [installLogs]); - - useEffect(() => { - if (compatibleProviders.length === 0) { - if (selectedProviderId) { - setSelectedProviderId(null); - } - return; - } - - if (!selectedProviderId || !selectedProvider) { - setSelectedProviderId(compatibleProviders[0].key); - } - }, [ - compatibleProviders, - selectedProvider, - selectedProviderId, - setSelectedProviderId, - ]); - - useEffect(() => { - if (!selectedProviderId || modelsLoading || providerModels.length === 0) { - return; - } - - if (!selectedModelId) { - setSelectedModelId(providerModels[0].id); - } - }, [ - modelsLoading, - providerModels, - selectedModelId, - selectedProviderId, - setSelectedModelId, - ]); - - useEffect(() => { - const providerId = selectedProvider?.key?.trim() || ""; - const modelId = selectedModelId.trim(); - const selectionKey = - providerId && modelId ? `${providerId}/${modelId}` : null; - - if (!selectionKey) { - lastWechatRuntimeModelSyncRef.current = null; - return; - } - - if (lastWechatRuntimeModelSyncRef.current === selectionKey) { - return; - } - - let cancelled = false; - void wechatChannelSetRuntimeModel({ providerId, modelId }) - .then(() => { - if (!cancelled) { - lastWechatRuntimeModelSyncRef.current = selectionKey; - } - }) - .catch((error) => { - if (!cancelled) { - console.warn("[OpenClaw] 同步微信运行时模型失败:", error); - } - }); - - return () => { - cancelled = true; - }; - }, [selectedModelId, selectedProvider?.key]); - - useEffect(() => { - let active = true; - let unlisten: (() => void) | null = null; - - void openclawApi - .listenInstallProgress((payload) => { - if (!active) return; - setInstallLogs((prev) => [...prev, payload].slice(-400)); - }) - .then((fn) => { - unlisten = fn; - }) - .catch((error) => { - console.warn("[OpenClaw] 安装日志监听失败:", error); - }); - - return () => { - active = false; - if (unlisten) { - void unlisten(); - } - }; - }, []); - - useEffect(() => { - if (!operationState.running) { - return; - } - - let cancelled = false; - - const syncProgressLogs = async () => { - try { - const logs = await openclawApi.getProgressLogs(); - if (!cancelled && logs.length > 0) { - setInstallLogs(logs); - } - } catch { - // 忽略轮询失败,保留事件流或已有日志 - } - }; - - void syncProgressLogs(); - const timer = window.setInterval(() => { - void syncProgressLogs(); - }, 400); - - return () => { - cancelled = true; - window.clearInterval(timer); - }; - }, [operationState.running]); - - const refreshGatewayRuntime = useCallback(async () => { - const status = await openclawApi.getStatus(); - setGatewayStatus(status.status); - if (status.port !== gatewayPort) { - setGatewayPort(status.port); - } - - await refreshDashboardUrl({ silent: true }); - - let nextHealthInfo: OpenClawHealthInfo | null = null; - let nextChannels: OpenClawChannelInfo[] = []; - - if (status.status === "running") { - const [healthResult, channelListResult] = await Promise.allSettled([ - openclawApi.checkHealth(), - openclawApi.getChannels(), - ]); - nextHealthInfo = - healthResult.status === "fulfilled" ? healthResult.value : null; - nextChannels = - channelListResult.status === "fulfilled" && - Array.isArray(channelListResult.value) - ? channelListResult.value - : []; - setHealthInfo(nextHealthInfo); - setChannels(nextChannels); - } else { - setHealthInfo(null); - setChannels([]); - } - - return { - status, - healthInfo: nextHealthInfo, - channels: nextChannels, - }; - }, [gatewayPort, refreshDashboardUrl, setGatewayPort]); - - const refreshUpdateStatus = useCallback( - async ({ showToast = false } = {}) => { - if (!openclawWorkflowReady) { - setUpdateInfo(null); - return null; - } - - const result = await openclawApi.checkUpdate(); - setUpdateInfo(result); - - if (showToast) { - if (result.hasUpdate) { - toast.info( - `检测到 OpenClaw 新版本 ${result.latestVersion || ""}`.trim(), - { - description: result.currentVersion - ? `当前版本 ${result.currentVersion}` - : "可以在当前工作台直接执行升级。", - }, - ); - } else if (result.message) { - toast.warning("暂时无法确认更新状态。", { - description: result.message, - }); - } else { - toast.success("当前 OpenClaw 已是最新状态。"); - } - } - - return result; - }, - [openclawWorkflowReady], - ); - - const syncPreferredRuntimeSelection = useCallback( - async ( - runtimeId: string | null, - options?: { allowAutoReset?: boolean }, - ) => { - const allowAutoReset = options?.allowAutoReset ?? false; - if (!runtimeId && !allowAutoReset) { - return { - appliedRuntimeId: null, - message: "当前使用自动选择执行环境。", - recoveredToAuto: false, - }; - } - - const result = await openclawApi.setPreferredRuntime(runtimeId); - if (result.success) { - return { - appliedRuntimeId: runtimeId, - message: result.message, - recoveredToAuto: false, - }; - } - - if (runtimeId) { - setPreferredRuntimeId(null); - const autoResult = await openclawApi - .setPreferredRuntime(null) - .catch(() => null); - return { - appliedRuntimeId: null, - message: autoResult?.message || result.message, - recoveredToAuto: true, - recoveryReason: result.message, - }; - } - - throw new Error(result.message); - }, - [setPreferredRuntimeId], - ); - - const refreshAll = useCallback( - async (runtimeIdOverride?: string | null) => { - try { - const effectiveRuntimeId = - runtimeIdOverride === undefined - ? preferredRuntimeId - : runtimeIdOverride; - const runtimeSync = - await syncPreferredRuntimeSelection(effectiveRuntimeId); - if (runtimeSync.recoveredToAuto && runtimeSync.recoveryReason) { - toast.warning("已恢复为自动选择执行环境。", { - description: runtimeSync.recoveryReason, - }); - } - - const [environment, runtimes] = await Promise.all([ - openclawApi.getEnvironmentStatus(), - openclawApi.listRuntimeCandidates(), - ]); - setRuntimeCandidates(runtimes); - setEnvironmentStatus(environment); - setInstalledStatus({ - installed: environment.openclaw.status === "ok", - path: environment.openclaw.path, - }); - setNodeStatus({ - status: - environment.node.status === "missing" - ? "not_found" - : environment.node.status, - version: environment.node.version, - path: environment.node.path, - }); - setGitStatus({ - available: environment.git.status === "ok", - path: environment.git.path, - }); - const updateResult = - environment.openclaw.status === "ok" - ? await openclawApi.checkUpdate().catch(() => null) - : null; - setUpdateInfo(updateResult); - const [gatewayRuntime, dashboardWindowOpenState] = await Promise.all([ - refreshGatewayRuntime(), - refreshDashboardWindowState(), - ]); - return { - appliedRuntimeId: runtimeSync.appliedRuntimeId, - environment, - runtimes, - updateInfo: updateResult, - gatewayStatus: gatewayRuntime.status.status, - gatewayPort: gatewayRuntime.status.port, - healthInfo: gatewayRuntime.healthInfo, - channels: gatewayRuntime.channels, - dashboardWindowOpen: dashboardWindowOpenState, - } satisfies OpenClawRefreshSnapshot; - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - return null; - } finally { - setStatusResolved(true); - } - }, - [ - preferredRuntimeId, - refreshDashboardWindowState, - refreshGatewayRuntime, - syncPreferredRuntimeSelection, - ], - ); - - useEffect(() => { - if (!isActive) { - return; - } - - void refreshAll(); - }, [isActive, refreshAll]); - - useEffect(() => { - if (!statusResolved || requestedSubpage || operationState.running) { - return; - } - - if ( - operationState.kind && - operationState.message && - fallbackSubpage === progressSubpageByAction[operationState.kind] - ) { - return; - } - - const preserveInstallPage = - allowInstallPage && - hasManualFallbackNavigationRef.current && - fallbackSubpage === "install"; - const resolvedSubpage = !openclawWorkflowReady ? "install" : "runtime"; - - if ( - !onNavigate && - !preserveInstallPage && - fallbackSubpage !== resolvedSubpage - ) { - setFallbackSubpage(resolvedSubpage); - } - }, [ - allowInstallPage, - fallbackSubpage, - gatewayRunning, - gatewayStarting, - openclawWorkflowReady, - onNavigate, - operationState.kind, - operationState.message, - operationState.running, - requestedSubpage, - statusResolved, - ]); - - useEffect(() => { - if (!isActive) { - return; - } - - if (gatewayStatus !== "running" && gatewayStatus !== "starting") { - return; - } - - const timer = window.setInterval(() => { - void refreshGatewayRuntime().catch((error) => { - console.warn("[OpenClaw] 轮询状态失败:", error); - }); - }, 5000); - - return () => window.clearInterval(timer); - }, [gatewayStatus, isActive, refreshGatewayRuntime]); - - useEffect(() => { - if (!isActive) { - return; - } - - if (currentSubpage === "dashboard" && gatewayRunning && !dashboardUrl) { - void refreshDashboardUrl({ silent: true, showLoading: true }); - } - }, [ - currentSubpage, - dashboardUrl, - gatewayRunning, - isActive, - refreshDashboardUrl, - ]); - - const syncProviderConfig = useCallback( - async ({ showSuccessToast = true, trackLoading = true } = {}) => { - if (!selectedProvider) { - toast.error("请先选择 Provider。"); - return false; - } - - const primaryModelId = selectedModelId.trim(); - if (!primaryModelId) { - toast.error("请先选择或输入主模型 ID。"); - return false; - } - - if (trackLoading) { - setSyncing(true); - } - - try { - const requestModels = toSyncModels(providerModels); - if (!requestModels.some((model) => model.id === primaryModelId)) { - requestModels.unshift({ - id: primaryModelId, - name: primaryModelId, - }); - } - - const result = await openclawApi.syncProviderConfig({ - providerId: selectedProvider.key, - primaryModelId, - models: requestModels, - }); - - if (!result.success) { - toast.error(result.message); - return false; - } - - setLastSynced({ - providerId: selectedProvider.key, - modelId: primaryModelId, - }); - - if (showSuccessToast) { - toast.success(result.message); - } - - return true; - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - return false; - } finally { - if (trackLoading) { - setSyncing(false); - } - } - }, - [providerModels, selectedModelId, selectedProvider, setLastSynced], - ); - - const createOpenClawOperationHistoryEntry = useCallback( - (params: { - kind: OpenClawOperationKind; - target: OpenClawOperationState["target"]; - title: string | null; - description: string | null; - message: string | null; - returnSubpage: OpenClawSubpage; - logs: OpenClawInstallProgressEvent[]; - success: boolean; - }): OpenClawOperationHistoryEntry => { - const { - kind, - target, - title, - description, - message, - returnSubpage, - logs, - success, - } = params; - const rawLogsText = buildOpenClawRawLogsText(logs); - const repairPrompt = buildOpenClawRepairPrompt(kind, message, logs, { - os: - typeof navigator !== "undefined" - ? `${navigator.platform || "unknown"} / ${navigator.language || "unknown"}` - : "unknown", - userAgent: - typeof navigator !== "undefined" - ? navigator.userAgent || "unknown" - : "unknown", - runtime: currentRuntimeSummary, - installPath: installedStatus?.path || "未检测到安装路径", - nodeStatus: formatNodeStatus(nodeStatus), - gitStatus: formatBinaryStatus(gitStatus, "可用", "未检测到 Git"), - gatewayStatus, - gatewayPort, - healthStatus: healthInfo - ? `${healthInfo.status}${healthInfo.version ? ` · ${healthInfo.version}` : ""}` - : "尚未执行健康检查", - dashboardUrl: dashboardUrl || "尚未生成 Dashboard 地址", - }); - - const diagnosticBundleJson = JSON.stringify( - { - generatedAt: new Date().toISOString(), - source: "openclaw-progress", - operation: kind, - running: false, - message, - system: { - os: - typeof navigator !== "undefined" - ? `${navigator.platform || "unknown"} / ${navigator.language || "unknown"}` - : "unknown", - userAgent: - typeof navigator !== "undefined" - ? navigator.userAgent || "unknown" - : "unknown", - runtime: currentRuntimeSummary, - installPath: installedStatus?.path || "未检测到安装路径", - nodeStatus: formatNodeStatus(nodeStatus), - gitStatus: formatBinaryStatus(gitStatus, "可用", "未检测到 Git"), - gatewayStatus, - gatewayPort, - healthStatus: healthInfo - ? `${healthInfo.status}${healthInfo.version ? ` · ${healthInfo.version}` : ""}` - : "尚未执行健康检查", - dashboardUrl: dashboardUrl || "尚未生成 Dashboard 地址", - }, - logs, - }, - null, - 2, - ); - - return { - kind, - target, - title, - description, - message, - returnSubpage, - success, - updatedAt: new Date().toISOString(), - logs, - rawLogsText, - diagnosticBundleJson, - repairPrompt, - }; - }, - [ - currentRuntimeSummary, - dashboardUrl, - gatewayPort, - gatewayStatus, - gitStatus, - healthInfo, - installedStatus?.path, - nodeStatus, - ], - ); - - const runProgressOperation = useCallback( - async (options: { - kind: OpenClawOperationKind; - target?: OpenClawOperationState["target"]; - title?: string; - description?: string; - action: () => Promise<{ success: boolean; message: string }>; - successSubpage: OpenClawSubpage; - returnSubpage: OpenClawSubpage; - initialLogs?: OpenClawInstallProgressEvent[]; - onSuccess?: () => void; - successToast?: - | false - | ((context: { - result: { success: boolean; message: string }; - snapshot: OpenClawRefreshSnapshot | null; - logs: OpenClawInstallProgressEvent[]; - }) => void); - afterSuccessRefresh?: (context: { - result: { success: boolean; message: string }; - snapshot: OpenClawRefreshSnapshot | null; - logs: OpenClawInstallProgressEvent[]; - }) => Promise | void; - }) => { - const { - kind, - target = "environment", - title = null, - description = null, - action, - successSubpage, - returnSubpage, - initialLogs = [], - onSuccess, - successToast, - afterSuccessRefresh, - } = options; - - setInstallLogs(initialLogs); - setOperationState({ - kind, - target, - running: true, - title, - description, - message: null, - returnSubpage, - }); - navigateSubpage(progressSubpageByAction[kind]); - await new Promise((resolve) => window.setTimeout(resolve, 0)); - - try { - const result = await action(); - const latestLogs = await openclawApi - .getProgressLogs() - .catch(() => installLogsRef.current); - const historyLogs = - latestLogs.length > 0 - ? latestLogs - : installLogsRef.current.length > 0 - ? installLogsRef.current - : initialLogs; - - setInstallLogs(historyLogs); - setRecentOperation( - createOpenClawOperationHistoryEntry({ - kind, - target, - title, - description, - message: result.message, - returnSubpage, - logs: historyLogs, - success: result.success, - }), - ); - setOperationState({ - kind, - target, - running: false, - title, - description, - message: result.message, - returnSubpage, - }); - - if (!result.success) { - toast.error(result.message); - await refreshAll(); - return; - } - - onSuccess?.(); - const refreshSnapshot = await refreshAll(); - if (successToast === false) { - // 由调用方自行处理成功提示 - } else if (typeof successToast === "function") { - successToast({ - result, - snapshot: refreshSnapshot, - logs: historyLogs, - }); - } else { - toast.success(result.message); - } - await afterSuccessRefresh?.({ - result, - snapshot: refreshSnapshot, - logs: historyLogs, - }); - navigateSubpage(successSubpage); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const latestLogs = await openclawApi - .getProgressLogs() - .catch(() => installLogsRef.current); - const historyLogs = - latestLogs.length > 0 - ? latestLogs - : installLogsRef.current.length > 0 - ? installLogsRef.current - : initialLogs; - - setInstallLogs(historyLogs); - setRecentOperation( - createOpenClawOperationHistoryEntry({ - kind, - target, - title, - description, - message, - returnSubpage, - logs: historyLogs, - success: false, - }), - ); - setOperationState({ - kind, - target, - running: false, - title, - description, - message, - returnSubpage, - }); - toast.error(message); - await refreshAll(); - } - }, - [ - createOpenClawOperationHistoryEntry, - navigateSubpage, - refreshAll, - setRecentOperation, - ], - ); - - const handleDownloadNode = useCallback(async () => { - try { - const url = await openclawApi.getNodeDownloadUrl(); - await openUrl(url); - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - } - }, []); - - const handleDownloadGit = useCallback(async () => { - try { - const url = await openclawApi.getGitDownloadUrl(); - await openUrl(url); - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - } - }, []); - - const handleInstall = useCallback(async () => { - if (installBlockMessage) { - toast.error(installBlockMessage); - return; - } - - await runProgressOperation({ - kind: "install", - target: "openclaw", - title: isWindowsPlatform - ? "正在安装 OpenClaw" - : "正在修复环境并安装 OpenClaw", - description: isWindowsPlatform - ? "当前环境已通过检测,正在继续安装 OpenClaw。" - : "Lime 会先自动检查并修复 Node.js / Git,再继续安装 OpenClaw。", - action: () => openclawApi.install(), - successSubpage: "runtime", - returnSubpage: "install", - initialLogs: [ - { - level: "info", - message: isWindowsPlatform - ? "已发送安装请求,正在安装 OpenClaw..." - : "已发送安装请求,正在检查并修复 OpenClaw 运行环境...", - }, - ], - }); - }, [installBlockMessage, isWindowsPlatform, runProgressOperation]); - - const handleUninstall = useCallback(async () => { - if (!window.confirm("确定要卸载 OpenClaw 吗?")) { - return; - } - - await closeDashboardWindowSilently(); - const preview = await openclawApi - .getCommandPreview("uninstall") - .catch(() => null); - - await runProgressOperation({ - kind: "uninstall", - target: "openclaw", - action: () => openclawApi.uninstall(), - successSubpage: "install", - returnSubpage: installed ? "configure" : "install", - initialLogs: preview - ? [ - { level: "info", message: preview.title }, - ...preview.command - .split("\n") - .map((line) => ({ level: "info" as const, message: line })), - ] - : [ - { - level: "info", - message: "已发送卸载请求,正在等待后端返回卸载命令...", - }, - ], - onSuccess: () => { - clearLastSynced(); - setSelectedModelId(""); - }, - }); - }, [ - clearLastSynced, - closeDashboardWindowSilently, - installed, - runProgressOperation, - setSelectedModelId, - ]); - - const handleRestart = useCallback(async () => { - await closeDashboardWindowSilently(); - const preview = await openclawApi - .getCommandPreview("restart", gatewayPort) - .catch(() => null); - - await runProgressOperation({ - kind: "restart", - target: "openclaw", - action: () => openclawApi.restartGateway(), - successSubpage: "runtime", - returnSubpage: "runtime", - initialLogs: preview - ? [ - { level: "info", message: preview.title }, - ...preview.command - .split("\n") - .map((line) => ({ level: "info" as const, message: line })), - ] - : [ - { - level: "info", - message: "已发送重启请求,正在停止并重新拉起 Gateway...", - }, - ], - }); - }, [closeDashboardWindowSilently, gatewayPort, runProgressOperation]); - - const handleInstallNode = useCallback(async () => { - if (isWindowsPlatform) { - toast.info( - "Windows 下请先手动下载安装 Node.js 22+,安装完成后重新检测。", - ); - await handleDownloadNode(); - return; - } - - await runProgressOperation({ - kind: "repair", - target: "node", - title: "正在安装 Node.js 环境", - description: "Lime 会优先尝试应用内一键安装或修复 Node.js。", - action: () => openclawApi.installDependency("node"), - successSubpage: "install", - returnSubpage: "install", - initialLogs: [ - { - level: "info", - message: "已发送 Node.js 修复请求,正在准备安装流程...", - }, - ], - }); - }, [handleDownloadNode, isWindowsPlatform, runProgressOperation]); - - const handleInstallGit = useCallback(async () => { - if (isWindowsPlatform) { - toast.info( - "Windows 下请先手动下载安装 Git,并在安装时勾选加入 PATH,完成后重新检测。", - ); - await handleDownloadGit(); - return; - } - - await runProgressOperation({ - kind: "repair", - target: "git", - title: "正在安装 Git 环境", - description: "Lime 会优先尝试应用内一键安装或修复 Git。", - action: () => openclawApi.installDependency("git"), - successSubpage: "install", - returnSubpage: "install", - initialLogs: [ - { - level: "info", - message: "已发送 Git 修复请求,正在准备安装流程...", - }, - ], - }); - }, [handleDownloadGit, isWindowsPlatform, runProgressOperation]); - - const handleSync = useCallback(async () => { - await syncProviderConfig(); - }, [syncProviderConfig]); - - const handleStart = useCallback(async () => { - if (!lastSynced && !hasSelectedConfig) { - toast.error("请先选择 Provider 和模型,或先完成一次配置同步。"); - return; - } - - setStarting(true); - try { - const primaryModelId = selectedModelId.trim(); - const needsSync = - hasSelectedConfig && - selectedProvider && - (!lastSynced || - lastSynced.providerId !== selectedProvider.key || - lastSynced.modelId !== primaryModelId); - - if (needsSync) { - const synced = await syncProviderConfig({ - showSuccessToast: false, - trackLoading: false, - }); - if (!synced) { - return; - } - } - - const result = await openclawApi.startGateway(gatewayPort); - if (!result.success) { - toast.error(result.message); - await refreshGatewayRuntime(); - return; - } - - toast.success(result.message); - await refreshGatewayRuntime(); - await refreshDashboardUrl({ - silent: false, - showLoading: false, - }); - navigateSubpage("runtime"); - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - } finally { - setStarting(false); - } - }, [ - gatewayPort, - hasSelectedConfig, - lastSynced, - navigateSubpage, - refreshDashboardUrl, - refreshGatewayRuntime, - selectedModelId, - selectedProvider, - syncProviderConfig, - ]); - - const handleStop = useCallback(async () => { - setStopping(true); - try { - const result = await openclawApi.stopGateway(); - if (!result.success) { - toast.error(result.message); - return; - } - - await closeDashboardWindowSilently(); - toast.success(result.message); - await refreshGatewayRuntime(); - navigateSubpage("configure"); - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - } finally { - setStopping(false); - } - }, [closeDashboardWindowSilently, navigateSubpage, refreshGatewayRuntime]); - - const handleCheckHealth = useCallback(async () => { - setCheckingHealth(true); - try { - const health = await openclawApi.checkHealth(); - setHealthInfo(health); - if (health.status === "healthy") { - toast.success("Gateway 健康检查通过。"); - } else { - toast.warning("Gateway 当前不可用。", { - description: "请确认已同步配置并成功启动。", - }); - } - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - } finally { - setCheckingHealth(false); - } - }, []); - - const handleCheckUpdate = useCallback(async () => { - setCheckingUpdate(true); - try { - await refreshUpdateStatus({ showToast: true }); - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - } finally { - setCheckingUpdate(false); - } - }, [refreshUpdateStatus]); - - const handleSelectPreferredRuntime = useCallback( - async (runtimeId: string | null) => { - setSwitchingRuntime(true); - try { - const result = await syncPreferredRuntimeSelection(runtimeId, { - allowAutoReset: runtimeId === null, - }); - setPreferredRuntimeId(result.appliedRuntimeId); - await refreshAll(result.appliedRuntimeId); - toast.success(result.message); - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - } finally { - setSwitchingRuntime(false); - } - }, - [refreshAll, setPreferredRuntimeId, syncPreferredRuntimeSelection], - ); - - const ensureUpdateRuntimeReady = useCallback(async () => { - if (currentRuntimeHasOpenClawInstallation) { - return true; - } - - if (!recommendedUpdateRuntimeCandidate) { - toast.warning("当前执行环境里还没有检测到 OpenClaw。", { - description: - "智能升级会继续尝试自动识别安装来源;如果仍失败,请先在执行环境卡片里切到已安装 OpenClaw 的 Node 运行时。", - }); - return true; - } - - setSwitchingRuntime(true); - try { - const result = await syncPreferredRuntimeSelection( - recommendedUpdateRuntimeCandidate.id, - ); - if (result.appliedRuntimeId !== recommendedUpdateRuntimeCandidate.id) { - throw new Error(result.recoveryReason || result.message); - } - - setPreferredRuntimeId(result.appliedRuntimeId); - toast.info("已自动切换到检测到 OpenClaw 的执行环境。", { - description: formatRuntimeCandidateOpenClawSummary( - recommendedUpdateRuntimeCandidate, - ), - }); - return true; - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - return false; - } finally { - setSwitchingRuntime(false); - } - }, [ - currentRuntimeHasOpenClawInstallation, - recommendedUpdateRuntimeCandidate, - setPreferredRuntimeId, - syncPreferredRuntimeSelection, - ]); - - const handleUseRecommendedUpdateRuntime = useCallback(async () => { - if (!recommendedUpdateRuntimeCandidate) { - return; - } - - await handleSelectPreferredRuntime(recommendedUpdateRuntimeCandidate.id); - }, [handleSelectPreferredRuntime, recommendedUpdateRuntimeCandidate]); - - const handlePerformUpdate = useCallback(async () => { - const runtimePrepared = await ensureUpdateRuntimeReady(); - if (!runtimePrepared) { - return; - } - - const reopenDashboardAfterUpdate = dashboardWindowOpen; - await closeDashboardWindowSilently(); - await runProgressOperation({ - kind: "update", - target: "openclaw", - title: "正在智能升级 OpenClaw", - description: - "将优先调用 openclaw update;如果官方自更新无法识别安装来源,Lime 会自动尝试同运行时的全局安装升级兜底。", - action: () => openclawApi.performUpdate(), - successSubpage: "runtime", - returnSubpage: "runtime", - initialLogs: [ - { - level: "info", - message: updateInfo?.hasUpdate - ? `已检测到新版本 ${updateInfo.latestVersion || "待确认"},开始智能升级...` - : "开始执行 OpenClaw 智能升级...", - }, - ], - successToast: ({ result, snapshot }) => { - if (!snapshot) { - toast.success(result.message); - return; - } - - const snapshotVersionState = - resolveSnapshotOpenClawVersionState(snapshot); - - if (snapshotVersionState.versionMismatch) { - toast.warning("新版本已经安装,但当前仍在运行旧版 Gateway。", { - description: `已安装 ${snapshotVersionState.installedVersion || "新版本"},当前运行中 ${snapshotVersionState.runningVersion || "未知版本"}。请点击“重启 Gateway”让桌面版切换到新版本。`, - }); - return; - } - - toast.success( - snapshotVersionState.installedVersion - ? `OpenClaw 已升级到 ${snapshotVersionState.installedVersion}。` - : result.message, - { - description: - snapshot.gatewayStatus === "running" - ? `当前运行中 ${snapshotVersionState.runningVersion || snapshotVersionState.installedVersion || "新版本"}。` - : "Gateway 未运行,启动后即可进入新版本桌面版。", - }, - ); - }, - afterSuccessRefresh: async ({ snapshot }) => { - if (!snapshot) { - return; - } - - if ( - reopenDashboardAfterUpdate && - snapshot.gatewayStatus === "running" && - !resolveSnapshotOpenClawVersionState(snapshot).versionMismatch - ) { - await handleOpenDashboardWindow(); - } - }, - }); - }, [ - dashboardWindowOpen, - closeDashboardWindowSilently, - ensureUpdateRuntimeReady, - handleOpenDashboardWindow, - runProgressOperation, - updateInfo?.hasUpdate, - updateInfo?.latestVersion, - ]); - - const handleCleanupTempArtifacts = useCallback(async () => { - setCleaningTemp(true); - try { - const result = await openclawApi.cleanupTempArtifacts(); - if (result.success) { - toast.success(result.message); - } else { - toast.warning(result.message); - } - await refreshAll(); - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - } finally { - setCleaningTemp(false); - } - }, [refreshAll]); - - const handleCopyPath = useCallback(async () => { - const path = installedStatus?.path; - if (!path?.trim()) { - toast.error("当前没有可复制的安装路径。"); - return; - } - - try { - await copyTextToClipboard(path, { - fallbackErrorMessage: "复制安装路径失败,请重试。", - permissionDeniedMessage: - "剪贴板权限被系统拒绝,请先点击 Lime 窗口后重试复制安装路径。", - inactiveWindowMessage: - "当前窗口未激活,先点击 Lime 窗口后再复制安装路径。", - }); - toast.success("安装路径已复制。"); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "复制安装路径失败。", - ); - } - }, [installedStatus?.path]); - - const handleCloseProgress = useCallback(() => { - navigateSubpage(operationState.returnSubpage); - }, [navigateSubpage, operationState.returnSubpage]); - - const handleOpenRecentOperationLogsPage = useCallback(() => { - if (!recentOperation) { - toast.error("当前没有可查看的历史日志。"); - return; - } - - setInstallLogs(recentOperation.logs); - setOperationState({ - kind: recentOperation.kind, - target: recentOperation.target, - running: false, - title: recentOperation.title, - description: recentOperation.description, - message: recentOperation.message, - returnSubpage: "runtime", - }); - navigateSubpage(progressSubpageByAction[recentOperation.kind]); - }, [navigateSubpage, recentOperation]); - - const openClawRepairPrompt = useMemo( - () => - buildOpenClawRepairPrompt( - operationState.kind, - operationState.message, - installLogs, - { - os: - typeof navigator !== "undefined" - ? `${navigator.platform || "unknown"} / ${navigator.language || "unknown"}` - : "unknown", - userAgent: - typeof navigator !== "undefined" - ? navigator.userAgent || "unknown" - : "unknown", - runtime: currentRuntimeSummary, - installPath: installedStatus?.path || "未检测到安装路径", - nodeStatus: formatNodeStatus(nodeStatus), - gitStatus: formatBinaryStatus(gitStatus, "可用", "未检测到 Git"), - gatewayStatus, - gatewayPort, - healthStatus: healthInfo - ? `${healthInfo.status}${healthInfo.version ? ` · ${healthInfo.version}` : ""}` - : "尚未执行健康检查", - dashboardUrl: dashboardUrl || "尚未生成 Dashboard 地址", - }, - ), - [ - currentRuntimeSummary, - dashboardUrl, - gatewayPort, - gatewayStatus, - gitStatus, - healthInfo, - installLogs, - installedStatus?.path, - nodeStatus, - operationState.kind, - operationState.message, - ], - ); - - const openClawRawLogsText = useMemo( - () => buildOpenClawRawLogsText(installLogs), - [installLogs], - ); - - const openClawDiagnosticBundleJson = useMemo( - () => - JSON.stringify( - { - generatedAt: new Date().toISOString(), - source: "openclaw-progress", - operation: operationState.kind, - running: operationState.running, - message: operationState.message, - system: { - os: - typeof navigator !== "undefined" - ? `${navigator.platform || "unknown"} / ${navigator.language || "unknown"}` - : "unknown", - userAgent: - typeof navigator !== "undefined" - ? navigator.userAgent || "unknown" - : "unknown", - runtime: currentRuntimeSummary, - installPath: installedStatus?.path || "未检测到安装路径", - nodeStatus: formatNodeStatus(nodeStatus), - gitStatus: formatBinaryStatus(gitStatus, "可用", "未检测到 Git"), - gatewayStatus, - gatewayPort, - healthStatus: healthInfo - ? `${healthInfo.status}${healthInfo.version ? ` · ${healthInfo.version}` : ""}` - : "尚未执行健康检查", - dashboardUrl: dashboardUrl || "尚未生成 Dashboard 地址", - }, - logs: installLogs, - }, - null, - 2, - ), - [ - currentRuntimeSummary, - dashboardUrl, - gatewayPort, - gatewayStatus, - gitStatus, - healthInfo, - installLogs, - installedStatus?.path, - nodeStatus, - operationState.kind, - operationState.message, - operationState.running, - ], - ); - - const handleCopyOpenClawRepairPrompt = useCallback(async () => { - try { - await copyTextToClipboard(openClawRepairPrompt, { - fallbackErrorMessage: "复制 OpenClaw 修复提示词失败,请重试。", - permissionDeniedMessage: - "剪贴板权限被系统拒绝,请先点击 Lime 窗口后重试复制修复提示词。", - inactiveWindowMessage: - "当前窗口未激活,先点击 Lime 窗口后再复制修复提示词。", - }); - toast.success("OpenClaw 修复提示词已复制。"); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "复制修复提示词失败。", - ); - } - }, [openClawRepairPrompt]); - - const handleCopyOpenClawLogs = useCallback(async () => { - if (!openClawRawLogsText.trim()) { - toast.error("当前没有可复制的日志。"); - return; - } - - try { - await copyTextToClipboard(openClawRawLogsText, { - fallbackErrorMessage: "复制 OpenClaw 日志失败,请重试。", - permissionDeniedMessage: - "剪贴板权限被系统拒绝,请先点击 Lime 窗口后重试复制日志。", - inactiveWindowMessage: "当前窗口未激活,先点击 Lime 窗口后再复制日志。", - }); - toast.success("OpenClaw 纯日志已复制。"); - } catch (error) { - toast.error(error instanceof Error ? error.message : "复制纯日志失败。"); - } - }, [openClawRawLogsText]); - - const handleCopyOpenClawDiagnosticBundle = useCallback(async () => { - try { - await copyTextToClipboard(openClawDiagnosticBundleJson, { - fallbackErrorMessage: "复制 OpenClaw JSON 诊断包失败,请重试。", - permissionDeniedMessage: - "剪贴板权限被系统拒绝,请先点击 Lime 窗口后重试复制诊断包。", - inactiveWindowMessage: - "当前窗口未激活,先点击 Lime 窗口后再复制诊断包。", - }); - toast.success("OpenClaw JSON 诊断包已复制。"); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "复制 JSON 诊断包失败。", - ); - } - }, [openClawDiagnosticBundleJson]); - - const handleCopyRecentOperationLogs = useCallback(async () => { - const rawLogsText = recentOperation?.rawLogsText?.trim(); - if (!rawLogsText) { - toast.error("当前没有可复制的历史日志。"); - return; - } - - try { - await copyTextToClipboard(rawLogsText, { - fallbackErrorMessage: "复制历史日志失败,请重试。", - permissionDeniedMessage: - "剪贴板权限被系统拒绝,请先点击 Lime 窗口后重试复制历史日志。", - inactiveWindowMessage: - "当前窗口未激活,先点击 Lime 窗口后再复制历史日志。", - }); - toast.success("历史日志已复制。"); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "复制历史日志失败。", - ); - } - }, [recentOperation?.rawLogsText]); - - const handleCopyRecentOperationDiagnosticBundle = useCallback(async () => { - const diagnosticBundleJson = recentOperation?.diagnosticBundleJson?.trim(); - if (!diagnosticBundleJson) { - toast.error("当前没有可复制的历史诊断包。"); - return; - } - - try { - await copyTextToClipboard(diagnosticBundleJson, { - fallbackErrorMessage: "复制历史诊断包失败,请重试。", - permissionDeniedMessage: - "剪贴板权限被系统拒绝,请先点击 Lime 窗口后重试复制历史诊断包。", - inactiveWindowMessage: - "当前窗口未激活,先点击 Lime 窗口后再复制历史诊断包。", - }); - toast.success("历史诊断包已复制。"); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "复制历史诊断包失败。", - ); - } - }, [recentOperation?.diagnosticBundleJson]); - - const handleAskAgentFixOpenClaw = useCallback(async () => { - const prompt = openClawRepairPrompt.trim(); - if (!prompt) { - toast.error("当前没有可用于诊断的日志内容。"); - return; - } - - setHandingOffToAgent(true); - toast.info("正在创建新话题并转交给 AI...", { - id: "openclaw-agent-handoff", - }); - - const project = await getOrCreateDefaultProject().catch((error) => { - toast.error( - error instanceof Error ? error.message : "创建默认项目失败。", - ); - setHandingOffToAgent(false); - return null; - }); - - if (!project) { - return; - } - - onNavigate?.("agent", { - projectId: project.id, - initialUserPrompt: prompt, - initialSessionName: "OpenClaw 修复", - entryBannerMessage: "已从 OpenClaw 故障诊断进入,诊断请求已自动发送。", - newChatAt: Date.now(), - theme: "general", - lockTheme: false, - }); - setHandingOffToAgent(false); - }, [onNavigate, openClawRepairPrompt]); - const resolveSceneStatus = useCallback( - (scene: OpenClawScene): OpenClawSceneStatus => { - switch (scene) { - case "setup": - if ( - operationState.running && - (operationState.kind === "install" || - operationState.kind === "repair" || - operationState.kind === "uninstall") - ) { - return { label: "处理中", tone: "starting" }; - } - if (installed) { - return { label: "已安装", tone: "done" }; - } - if (environmentStatus?.openclaw.status === "needs_reload") { - return { label: "待刷新", tone: "active" }; - } - if (softInstalled) { - return { label: "可继续", tone: "active" }; - } - return { label: "待安装", tone: "idle" }; - case "sync": - if (!openclawWorkflowReady) { - return { label: "等待安装", tone: "idle" }; - } - if (syncing) { - return { label: "同步中", tone: "starting" }; - } - if (lastSynced) { - return { label: "已同步", tone: "done" }; - } - if (hasSelectedConfig) { - return { label: "待同步", tone: "active" }; - } - return compatibleProviders.length > 0 - ? { label: "待选择", tone: "active" } - : { label: "缺少 Provider", tone: "error" }; - case "dashboard": - if (!openclawWorkflowReady) { - return { label: "等待安装", tone: "idle" }; - } - if (operationState.running && operationState.kind === "update") { - return { label: "升级中", tone: "starting" }; - } - if (operationState.running && operationState.kind === "restart") { - return { label: "重启中", tone: "starting" }; - } - if (gatewayStatus === "error") { - return { label: "异常", tone: "error" }; - } - if (gatewayRunning) { - return { - label: dashboardWindowOpen ? "面板已开" : "运行中", - tone: dashboardWindowOpen ? "connected" : "healthy", - }; - } - if (gatewayStarting || starting) { - return { label: "启动中", tone: "starting" }; - } - if (canStartFromConfigure || !!lastSynced) { - return { label: "待启动", tone: "active" }; - } - return { label: "待配置", tone: "idle" }; - default: - return { label: "待处理", tone: "idle" }; - } - }, - [ - canStartFromConfigure, - compatibleProviders.length, - dashboardWindowOpen, - environmentStatus?.openclaw.status, - gatewayRunning, - gatewayStarting, - gatewayStatus, - hasSelectedConfig, - installed, - openclawWorkflowReady, - lastSynced, - operationState.kind, - operationState.running, - softInstalled, - starting, - syncing, - ], - ); - - const pageDescription = useMemo(() => { - if (!statusResolved && !operationState.running) { - return "正在检测本地安装、Gateway 与配置状态,稍后会自动进入正确页面。"; - } - - switch (currentSubpage) { - case "install": - return softInstalled - ? "当前运行中的 Gateway 或已识别到版本表明 OpenClaw 已可用,但命令解析仍未完全对齐。你可以继续下一步,后续再修正执行环境。" - : environmentStatus?.summary || - "先确认 Node.js、Git 与 OpenClaw 本体状态,再决定是否执行一键修复。"; - case "installing": - case "uninstalling": - case "updating": - case "restarting": - return ( - operationState.description || - "当前正在执行 OpenClaw 操作,日志会持续更新。" - ); - case "configure": - return "在一个工作台里完成 Provider 选择、模型同步与启动前准备,避免在设置与运行页之间来回跳转。"; - case "runtime": - if (versionMismatch) { - return `新版本已经安装到 ${installedVersion || "当前环境"},但 Gateway 仍在运行 ${runningVersion || "旧版本"}。重启 Gateway 后,桌面面板和 Dashboard 才会切到新版本。`; - } - if (updateRuntimeRequiresSwitch && recommendedUpdateRuntimeCandidate) { - return `当前执行环境没有检测到 OpenClaw。智能升级时,Lime 会先切到 ${formatRuntimeCandidateOpenClawSummary( - recommendedUpdateRuntimeCandidate, - )},再继续升级。`; - } - return gatewayRunning - ? "Gateway 已准备就绪,可以直接打开桌面面板,或进入 Dashboard 访问页进一步检查地址与 token。" - : "这里集中处理启动、停止、重启与健康检查。启动前如未同步模型,请先回到配置页。"; - case "dashboard": - if (versionMismatch) { - return `已安装 ${installedVersion || "新版本"},但当前 Dashboard 仍连接到运行中的 ${runningVersion || "旧版本"}。先重启 Gateway,再重新打开桌面面板。`; - } - if (updateRuntimeRequiresSwitch && recommendedUpdateRuntimeCandidate) { - return `已检测到更合适的执行环境 ${formatRuntimeCandidateOpenClawSummary( - recommendedUpdateRuntimeCandidate, - )}。智能升级时,Lime 会自动切换过去。`; - } - return "通过桌面面板或系统浏览器访问 OpenClaw Dashboard,并在这里确认地址、token 与面板状态。"; - default: - return "统一管理 OpenClaw 的安装、模型同步、Gateway 运行与 Dashboard 访问。"; - } - }, [ - currentSubpage, - environmentStatus?.summary, - gatewayRunning, - installedVersion, - operationState.description, - operationState.running, - recommendedUpdateRuntimeCandidate, - runningVersion, - softInstalled, - statusResolved, - updateRuntimeRequiresSwitch, - versionMismatch, - ]); - - const summaryCards = useMemo< - Array<{ - key: string; - title: string; - value: string; - description: string; - descriptionTitle?: string; - icon: LucideIcon; - iconClassName: string; - valueClassName?: string; - }> - >( - () => [ - { - key: "setup", - title: "安装环境", - value: installed - ? "已安装" - : softInstalled - ? "可继续" - : operationState.running - ? "处理中" - : "待安装", - descriptionTitle: - environmentStatus?.openclaw.path || - currentRuntimeCandidate?.openclawPath || - currentRuntimeCandidate?.openclawPackagePath || - undefined, - description: - environmentStatus?.openclaw.path || - currentRuntimeCandidate?.openclawPath || - currentRuntimeCandidate?.openclawPackagePath - ? compactPathLabel( - environmentStatus?.openclaw.path || - currentRuntimeCandidate?.openclawPath || - currentRuntimeCandidate?.openclawPackagePath, - 54, - ) - : "等待检测安装路径", - icon: Wrench, - iconClassName: "border-slate-200 bg-slate-100 text-slate-700", - }, - { - key: "sync", - title: "模型同步", - value: lastSynced?.modelId || selectedModelId.trim() || "未选择", - description: lastSynced - ? `最近同步:${lastSynced.providerId}` - : selectedProvider?.label || "先选择 Provider 与模型", - icon: Settings2, - iconClassName: "border-sky-200 bg-sky-100 text-sky-700", - valueClassName: "text-xl leading-8", - }, - { - key: "runtime", - title: "Gateway", - value: gatewayRunning ? "运行中" : gatewayStatus, - description: `端口 ${gatewayPort} · ${ - channels.length > 0 ? `${channels.length} 个通道` : "等待通道发现" - }`, - icon: ShieldCheck, - iconClassName: "border-emerald-200 bg-emerald-100 text-emerald-700", - }, - { - key: "dashboard", - title: "桌面面板", - value: dashboardWindowOpen ? "已打开" : "未打开", - description: dashboardUrl - ? "Dashboard 地址已生成" - : "等待生成 Dashboard 地址", - icon: MonitorSmartphone, - iconClassName: "border-amber-200 bg-amber-100 text-amber-700", - }, - ], - [ - channels.length, - dashboardUrl, - dashboardWindowOpen, - environmentStatus?.openclaw.path, - gatewayPort, - gatewayRunning, - gatewayStatus, - installed, - lastSynced, - operationState.running, - currentRuntimeCandidate?.openclawPackagePath, - currentRuntimeCandidate?.openclawPath, - selectedModelId, - selectedProvider?.label, - softInstalled, - ], - ); - - const handleSelectScene = useCallback( - (scene: OpenClawScene) => { - if (scene === "setup") { - navigateSubpage("install"); - return; - } - if (scene === "sync") { - navigateSubpage("configure"); - return; - } - navigateSubpage(gatewayRunning ? "dashboard" : "runtime"); - }, - [gatewayRunning, navigateSubpage], - ); - - let pageContent; - if (!statusResolved && !operationState.running) { - pageContent = ( -
- -

- 正在检查 OpenClaw 状态 -

-

- 正在检测本地安装、Gateway 与配置状态,稍后会自动进入正确页面。 -

-
- ); - } else if (currentSubpage === "install") { - pageContent = ( - void handleInstall()} - onInstallNode={() => void handleInstallNode()} - onInstallGit={() => void handleInstallGit()} - onRefresh={() => void refreshAll()} - onCleanupTemp={() => void handleCleanupTempArtifacts()} - onOpenDocs={() => void openUrl(OPENCLAW_DOCS_URL)} - onDownloadNode={() => void handleDownloadNode()} - onDownloadGit={() => void handleDownloadGit()} - onSelectPreferredRuntime={(runtimeId) => - void handleSelectPreferredRuntime(runtimeId) - } - /> - ); - } else if ( - currentSubpage === "installing" || - currentSubpage === "uninstalling" || - currentSubpage === "updating" || - currentSubpage === "restarting" - ) { - pageContent = ( - void handleCopyOpenClawLogs()} - onCopyDiagnosticBundle={() => void handleCopyOpenClawDiagnosticBundle()} - onCopyRepairPrompt={() => void handleCopyOpenClawRepairPrompt()} - onAskAgentFix={handleAskAgentFixOpenClaw} - /> - ); - } else if (!openclawWorkflowReady) { - pageContent = ( - void handleInstall()} - onInstallNode={() => void handleInstallNode()} - onInstallGit={() => void handleInstallGit()} - onRefresh={() => void refreshAll()} - onCleanupTemp={() => void handleCleanupTempArtifacts()} - onOpenDocs={() => void openUrl(OPENCLAW_DOCS_URL)} - onDownloadNode={() => void handleDownloadNode()} - onDownloadGit={() => void handleDownloadGit()} - onSelectPreferredRuntime={(runtimeId) => - void handleSelectPreferredRuntime(runtimeId) - } - /> - ); - } else if (currentSubpage === "configure") { - pageContent = ( - void handleCopyPath()} - onUninstall={() => void handleUninstall()} - onOpenDocs={() => void openUrl(OPENCLAW_DOCS_URL)} - onSelectProvider={(providerId) => { - setSelectedProviderId(providerId || null); - setSelectedModelId(""); - }} - onSelectModel={setSelectedModelId} - onInputModel={setSelectedModelId} - onRefreshProviders={() => void refreshProviders()} - onSync={() => void handleSync()} - onStart={() => void handleStart()} - onOpenRuntime={() => navigateSubpage("runtime")} - onGoProviderSettings={() => - onNavigate?.("settings", { tab: SettingsTabs.Providers }) - } - /> - ); - } else if (currentSubpage === "runtime") { - pageContent = ( - void handleStart()} - onStop={() => void handleStop()} - onRestart={() => void handleRestart()} - onOpenDashboard={() => void handleOpenDashboardWindow()} - onOpenDashboardPage={() => navigateSubpage("dashboard")} - onBackToConfigure={() => navigateSubpage("configure")} - onCheckHealth={() => void handleCheckHealth()} - onCheckUpdate={() => void handleCheckUpdate()} - onUpdate={() => void handlePerformUpdate()} - onUseRecommendedUpdateRuntime={() => - void handleUseRecommendedUpdateRuntime() - } - onSelectPreferredRuntime={(runtimeId) => - void handleSelectPreferredRuntime(runtimeId) - } - onOpenRecentLogs={() => void handleOpenRecentOperationLogsPage()} - onCopyRecentLogs={() => void handleCopyRecentOperationLogs()} - onCopyRecentDiagnosticBundle={() => - void handleCopyRecentOperationDiagnosticBundle() - } - /> - ); - } else if (currentSubpage === "dashboard") { - if (!gatewayRunning && !gatewayStarting) { - pageContent = renderBlockedPage( - "Dashboard 暂不可用", - "Gateway 当前未运行,请先进入运行页启动后再打开 Dashboard。", - "返回运行页", - () => navigateSubpage("runtime"), - ); - } else { - pageContent = ( - navigateSubpage("runtime")} - onOpenExternal={() => void handleOpenDashboardExternal()} - onOpenWindow={() => void handleOpenDashboardWindow()} - onUpdate={() => void handlePerformUpdate()} - onRefresh={() => - void Promise.all([ - refreshDashboardUrl({ silent: false, showLoading: true }), - refreshDashboardWindowState(), - ]) - } - /> - ); - } - } else { - pageContent = renderBlockedPage( - "页面状态异常", - "当前 OpenClaw 页面状态无法识别,请返回配置页重试。", - "返回配置页", - () => navigateSubpage("configure"), - ); - } - - return ( -
-
-
-
-
-
-
- -
- - OPENCLAW WORKSPACE - -
-
- -
-

- OpenClaw 工作台 -

-

- {pageDescription} -

-
- -
- - {currentSubpageLabel} - - - {installed - ? "环境已安装" - : softInstalled - ? "环境可继续" - : "环境待安装"} - - - Gateway {gatewayRunning ? "运行中" : gatewayStatus} - -
- -
-
-
-

- REMOTE COMPAT -

-

- 当前 remote 主入口已收口到消息渠道与浏览器连接器 -

-

- OpenClaw 继续负责本地 - Gateway、安装修复和兼容接入,适合处理环境准备、 Gateway - 运行和本地兼容链路,而不是替代当前 remote 主入口。 -

-
- {onNavigate ? ( -
- - -
- ) : null} -
-
-
- - - - 概览 - 快捷操作 - 版本状态 - - - -
- {summaryCards.map((card) => { - const CardIcon = card.icon; - return ( -
-
-
-

- {card.title} -

-

- {card.description} -

-
-
- -
-
-

- {card.value} -

-
- ); - })} -
-
- - -
- - {(environmentStatus?.openclaw.status === "needs_reload" || - !installed) && ( - - )} - {updateInfo?.hasUpdate ? ( - - ) : null} - - -
-
- - -
-
-
-
-

- VERSION STATUS -

-

- {versionStatusSummary.label} -

-
- {versionMismatch && canRestartGateway ? ( - - ) : null} -
-

- {versionStatusSummary.description} -

-
- -
- {versionMismatch ? ( - - 需重启 Gateway 才会切到新版本 - - ) : null} - {updateInfo?.hasUpdate ? ( - - 可升级至 {updateInfo.latestVersion || "新版本"} - - ) : null} - {updateRuntimeRequiresSwitch && - recommendedUpdateRuntimeCandidate ? ( - - 升级将自动切到{" "} - {formatRuntimeCandidateLabel( - recommendedUpdateRuntimeCandidate, - )} - - ) : null} -
-
-
-
-
-
- -
- - -
{pageContent}
-
-
-
-
- ); -} diff --git a/src/components/openclaw/OpenClawProgressPage.tsx b/src/components/openclaw/OpenClawProgressPage.tsx deleted file mode 100644 index 2e0413e23..000000000 --- a/src/components/openclaw/OpenClawProgressPage.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { Bot, Copy, Loader2 } from "lucide-react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { OpenClawInstallProgressEvent } from "@/lib/api/openclaw"; -import { cn } from "@/lib/utils"; -import type { OpenClawOperationKind } from "./types"; -import { - openClawPanelClassName, - openClawSecondaryButtonClassName, - openClawSubPanelClassName, -} from "./openclawStyles"; - -interface OpenClawProgressPageProps { - kind: OpenClawOperationKind; - title?: string | null; - description?: string | null; - running: boolean; - handingOffToAgent?: boolean; - message: string | null; - logs: OpenClawInstallProgressEvent[]; - repairPrompt: string; - onClose: () => void; - onCopyLogs: () => void; - onCopyDiagnosticBundle: () => void; - onCopyRepairPrompt: () => void; - onAskAgentFix: () => void; -} - -const titleMap: Record = { - install: "正在修复环境并安装 OpenClaw", - uninstall: "正在卸载 OpenClaw", - restart: "正在重启 Gateway", - repair: "正在修复 OpenClaw 环境", - update: "正在升级 OpenClaw", -}; - -const descriptionMap: Record = { - install: "正在准备 Node.js、Git 和 OpenClaw 环境,请保持当前页面并等待完成。", - uninstall: "正在卸载 OpenClaw,请等待进度完成后返回安装页。", - restart: "正在重启 Gateway,完成后将返回运行页。", - repair: "正在修复 OpenClaw 依赖环境,请保持当前页面并等待完成。", - update: "正在检查并升级 OpenClaw 本体,完成后会自动刷新版本与运行状态。", -}; - -export function OpenClawProgressPage({ - kind, - title, - description, - running, - handingOffToAgent = false, - message, - logs, - repairPrompt, - onClose, - onCopyLogs, - onCopyDiagnosticBundle, - onCopyRepairPrompt, - onAskAgentFix, -}: OpenClawProgressPageProps) { - return ( -
-
-
- IN PROGRESS -
-

- {title || titleMap[kind]} -

-

- {description || descriptionMap[kind]} -

- -
- - {running ? "处理中" : "已结束"} - - - {logs.length} 条日志 - - {message ? ( - - {message} - - ) : null} -
-
- -
-
-
-

操作日志

-

- {running - ? "命令执行中,可随时复制当前日志,或直接选中文本。" - : message || "操作已结束,可直接复制或手动选中日志。"} -

-
-
- - - - - -
-
- - - - - 完整日志 - - - 结果摘要 - - - - -
- {logs.length === 0 ? ( -
- {running ? "正在等待日志输出..." : "暂无日志输出"} -
- ) : ( -
- {logs.map((log, index) => ( -
- - [{log.level.toUpperCase()}] - {" "} - {log.message} -
- ))} -
- )} -
-
- - -
-
-
- 当前阶段 -
-
- {title || titleMap[kind]} -
-
- {running - ? "任务仍在执行,日志会持续追加到“完整日志”标签。" - : message || "当前操作已结束。"} -
-
- -
-
- 诊断动作 -
-
- 如果操作失败,可以切到“完整日志”直接复制,或复制 JSON - 诊断包、修复提示词继续排障。 -
-
-
-
-
-
-
- ); -} diff --git a/src/components/openclaw/OpenClawRuntimePage.tsx b/src/components/openclaw/OpenClawRuntimePage.tsx deleted file mode 100644 index 4e6733c11..000000000 --- a/src/components/openclaw/OpenClawRuntimePage.tsx +++ /dev/null @@ -1,753 +0,0 @@ -import { - ArrowUpCircle, - Copy, - ExternalLink, - Loader2, - MonitorSmartphone, - Play, - Power, - RefreshCw, - ShieldCheck, -} from "lucide-react"; -import type { - OpenClawGatewayStatus, - OpenClawHealthInfo, - OpenClawRuntimeCandidate, - OpenClawUpdateInfo, -} from "@/lib/api/openclaw"; -import { cn } from "@/lib/utils"; -import { OpenClawExecutionEnvironmentCard } from "./OpenClawExecutionEnvironmentCard"; -import { - openClawPanelClassName, - openClawPrimaryButtonClassName, - openClawSecondaryButtonClassName, - openClawSubPanelClassName, -} from "./openclawStyles"; - -interface OpenClawUpdateRuntimeNotice { - tone: "warning" | "error"; - title: string; - description: string; - actionLabel?: string; -} - -interface OpenClawRuntimePageProps { - gatewayStatus: OpenClawGatewayStatus; - gatewayPort: number; - healthInfo: OpenClawHealthInfo | null; - updateInfo: OpenClawUpdateInfo | null; - installedVersion: string | null; - runningVersion: string | null; - versionMismatch: boolean; - updateRuntimeNotice: OpenClawUpdateRuntimeNotice | null; - runtimeCandidates: OpenClawRuntimeCandidate[]; - preferredRuntimeId: string | null; - channelCount: number; - startReady: boolean; - canStart: boolean; - canStop: boolean; - canRestart: boolean; - starting: boolean; - stopping: boolean; - restarting: boolean; - checkingHealth: boolean; - checkingUpdate: boolean; - switchingRuntime: boolean; - updating: boolean; - dashboardWindowOpen: boolean; - dashboardWindowBusy: boolean; - recentOperationLabel: string | null; - recentOperationMessage: string | null; - recentOperationUpdatedAt: string | null; - recentOperationSucceeded: boolean | null; - recentLogCount: number; - onStart: () => void; - onStop: () => void; - onRestart: () => void; - onOpenDashboard: () => void; - onOpenDashboardPage: () => void; - onBackToConfigure: () => void; - onCheckHealth: () => void; - onCheckUpdate: () => void; - onUpdate: () => void; - onUseRecommendedUpdateRuntime: () => void; - onSelectPreferredRuntime: (runtimeId: string | null) => void; - onOpenRecentLogs: () => void; - onCopyRecentLogs: () => void; - onCopyRecentDiagnosticBundle: () => void; -} - -function titleForStatus(status: OpenClawGatewayStatus): string { - switch (status) { - case "running": - return "Gateway 已准备就绪"; - case "starting": - return "Gateway 正在启动"; - case "error": - return "Gateway 状态异常"; - default: - return "Gateway 当前未运行"; - } -} - -function descriptionForStatus(status: OpenClawGatewayStatus): string { - switch (status) { - case "running": - return "可以直接打开桌面面板,或进入 Dashboard 访问页查看完整界面。"; - case "starting": - return "请稍等片刻,启动完成后再打开桌面面板或访问 Dashboard。"; - case "error": - return "建议先检查健康状态,再决定是否回到配置页重新同步模型并重启。"; - default: - return "完成启动后即可打开 Dashboard;如果还未同步模型配置,请先回到配置页。"; - } -} - -export function OpenClawRuntimePage({ - gatewayStatus, - gatewayPort, - healthInfo, - updateInfo, - installedVersion, - runningVersion, - versionMismatch, - updateRuntimeNotice, - runtimeCandidates, - preferredRuntimeId, - channelCount, - startReady, - canStart, - canStop, - canRestart, - starting, - stopping, - restarting, - checkingHealth, - checkingUpdate, - switchingRuntime, - updating, - dashboardWindowOpen, - dashboardWindowBusy, - recentOperationLabel, - recentOperationMessage, - recentOperationUpdatedAt, - recentOperationSucceeded, - recentLogCount, - onStart, - onStop, - onRestart, - onOpenDashboard, - onOpenDashboardPage, - onBackToConfigure, - onCheckHealth, - onCheckUpdate, - onUpdate, - onUseRecommendedUpdateRuntime, - onSelectPreferredRuntime, - onOpenRecentLogs, - onCopyRecentLogs, - onCopyRecentDiagnosticBundle, -}: OpenClawRuntimePageProps) { - const running = gatewayStatus === "running"; - const dashboardActionLabel = dashboardWindowOpen - ? "聚焦桌面面板" - : "打开桌面面板"; - const dashboardActionHint = running - ? dashboardWindowOpen - ? "桌面版已经打开,点击即可快速回到 OpenClaw。" - : "Gateway 已运行,现在可以一键直达桌面版。" - : gatewayStatus === "starting" - ? "Gateway 正在启动,完成后这里会变成一键打开入口。" - : startReady - ? "先启动 Gateway,运行后即可在顶部一键打开桌面版。" - : "先回到配置页完成模型同步,再启动后打开桌面版。"; - const dashboardActionDisabled = !running || dashboardWindowBusy; - const healthText = healthInfo - ? `${healthInfo.status}${healthInfo.version ? ` · ${healthInfo.version}` : ""}${healthInfo.uptime ? ` · 运行 ${healthInfo.uptime}s` : ""}` - : "尚未执行健康检查"; - const installedVersionText = installedVersion || "未检测到"; - const runningVersionText = running - ? runningVersion || "暂未识别" - : gatewayStatus === "starting" - ? "启动中" - : "Gateway 未运行"; - const updateStatusLabel = versionMismatch - ? "待生效" - : updateInfo?.hasUpdate - ? "可升级" - : "已检查"; - const updateDescription = versionMismatch - ? `已安装 ${installedVersionText},但当前 Gateway 仍在运行 ${runningVersionText}。请重启 Gateway,让桌面版切到新版本。` - : updateInfo?.hasUpdate - ? `检测到新版本 ${updateInfo.latestVersion || "待确认"}。` - : updateInfo?.message - ? updateInfo.message - : "可在工作台内直接检查和执行 OpenClaw 升级。"; - const recentOperationTimeText = recentOperationUpdatedAt - ? new Date(recentOperationUpdatedAt).toLocaleString("zh-CN", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }) - : null; - - return ( -
-
-
-
-
- RUNTIME -
-

- {titleForStatus(gatewayStatus)} -

-

- {descriptionForStatus(gatewayStatus)} -

- -
- - {running ? "运行中" : gatewayStatus} · {gatewayPort} - - - {running ? `${channelCount} 个通道` : "等待启动后发现通道"} - - - {dashboardWindowOpen ? "桌面面板已打开" : "桌面面板未打开"} - -
-
- -
-
-
- DESKTOP ACCESS -
- -
- -
- - - -
-
-
-
- -
-
-
-
-
健康状态
-
- {healthText} -
- -
- -
-
通道状态
-
- {running - ? `当前已发现 ${channelCount} 个通道,可通过桌面面板或浏览器访问 Dashboard。` - : startReady - ? "Gateway 启动后会自动刷新可用通道数量。" - : "当前尚未完成模型配置同步,请先返回配置页选择模型后再启动 Gateway。"} -
- -
-
- -
-
- 更多访问方式 -
-

- 顶部已经提供桌面版快捷入口;这里保留桌面面板和 Dashboard - 访问页两种方式,便于日常使用与诊断 token、地址。 -

- -
- - - -
-
-
- -
-
-
-
-
- 版本升级 -
-

- 在工作台内执行智能升级。系统会优先走官方 `openclaw - update`,失败时自动尝试同运行时的全局安装升级兜底。 -

-
- - {updateStatusLabel} - -
- -
-
-
- 已安装版本 -
-
- {installedVersionText} -
-
-
-
- 运行中版本 -
-
- {runningVersionText} -
-
-
-
- 更新通道 -
-
- {updateInfo?.channel || "stable"} -
-
- {updateInfo?.installKind - ? `${updateInfo.installKind}${updateInfo.packageManager ? ` · ${updateInfo.packageManager}` : ""}` - : "等待检测安装来源"} -
-
-
-
- 升级状态 -
-
- {versionMismatch - ? "新版本已安装,等待运行态切换" - : updateInfo?.hasUpdate - ? `可升级至 ${updateInfo.latestVersion || "待确认"}` - : "当前未检测到新版本"} -
-
- {updateDescription} -
-
- {updateRuntimeNotice ? ( -
-
-
-
- 升级执行环境 -
-
- {updateRuntimeNotice.title} -
-
- {updateRuntimeNotice.description} -
-
- {updateRuntimeNotice.actionLabel ? ( - - ) : null} -
-
- ) : null} -
-
-
-
- 版本生效状态 -
-
- {versionMismatch - ? "新版本已安装,运行态未切换" - : "当前运行版本已对齐"} -
-
- {versionMismatch - ? "这通常表示 Gateway 还在跑旧进程。重启后,桌面面板和 Dashboard 才会进入新版本。" - : running - ? "已安装版本与运行中版本一致。" - : "Gateway 启动后会继续确认版本是否已生效。"} -
-
- {versionMismatch ? ( - - ) : null} -
-
- - -
- -
- - -
-
- -
-
- 当前运行摘要 -
-
-
-
- Gateway -
-
- {running ? "运行中" : gatewayStatus} · 端口 {gatewayPort} -
-
-
-
- 桌面面板 -
-
- {dashboardWindowOpen ? "已打开,可直接聚焦" : "尚未打开"} -
-
-
-
-
-
- 最近一次操作日志 -
-
- {recentOperationLabel - ? `${recentOperationLabel} · ${recentLogCount} 条日志` - : "暂无最近操作记录"} -
-
- {recentOperationMessage || - "执行安装、升级、重启等操作后,这里会保留一个查看日志入口。"} -
- {recentOperationTimeText ? ( -
- {recentOperationSucceeded === null - ? "最近一次记录" - : recentOperationSucceeded - ? "最近一次成功操作" - : "最近一次失败操作"} - {` · ${recentOperationTimeText}`} -
- ) : null} -
-
- - - -
-
-
-
-
- 建议动作 -
-
- {running - ? "直接打开桌面面板查看 Dashboard;如页面异常,先做健康检查再尝试重启。" - : startReady - ? "可以先启动 Gateway;如果之前已经开过桌面面板,启动成功后再重新聚焦。" - : "先回到配置页选择 Provider 与模型,并完成一次同步。"} -
-
-
-
-
-
-
- ); -} diff --git a/src/components/openclaw/OpenClawSceneNav.tsx b/src/components/openclaw/OpenClawSceneNav.tsx deleted file mode 100644 index 7aefac6f5..000000000 --- a/src/components/openclaw/OpenClawSceneNav.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { CheckCircle2, ChevronRight, Circle, PlayCircle } from "lucide-react"; -import { cn } from "@/lib/utils"; -import type { - OpenClawScene, - OpenClawSceneDefinition, - OpenClawSceneStatus, -} from "./types"; -import { openClawPanelClassName } from "./openclawStyles"; - -interface OpenClawSceneNavProps { - scenes: OpenClawSceneDefinition[]; - currentScene: OpenClawScene; - onSelect: (scene: OpenClawScene) => void; - resolveStatus: (scene: OpenClawScene) => OpenClawSceneStatus; -} - -function statusBadgeClass(tone: string): string { - switch (tone) { - case "running": - case "healthy": - case "connected": - case "done": - return "border-emerald-300/60 bg-emerald-50 text-emerald-700"; - case "starting": - case "active": - return "border-amber-300/60 bg-amber-50 text-amber-700"; - case "error": - case "unhealthy": - case "disconnected": - return "border-rose-300/60 bg-rose-50 text-rose-700"; - default: - return "border-slate-200 bg-slate-100 text-slate-600"; - } -} - -function renderSceneIcon(current: boolean, tone: string) { - if (current) { - return ; - } - - if (tone === "done") { - return ; - } - - return ; -} - -export function OpenClawSceneNav({ - scenes, - currentScene, - onSelect, - resolveStatus, -}: OpenClawSceneNavProps) { - return ( -
-
-

流程导航

-

- 按“安装环境 → 配置模型 → 运行与访问”的顺序推进,避免跳步骤后状态混乱。 -

-
- -
- {scenes.map((scene) => { - const current = currentScene === scene.id; - const status = resolveStatus(scene.id); - - return ( - - ); - })} -
-
- ); -} diff --git a/src/components/openclaw/index.ts b/src/components/openclaw/index.ts deleted file mode 100644 index 1a9600201..000000000 --- a/src/components/openclaw/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { OpenClawPage } from "./OpenClawPage"; -export { useOpenClawStore } from "./useOpenClawStore"; diff --git a/src/components/openclaw/openclawStyles.ts b/src/components/openclaw/openclawStyles.ts deleted file mode 100644 index b7c4f66ec..000000000 --- a/src/components/openclaw/openclawStyles.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const openClawPanelClassName = - "rounded-[26px] border border-slate-200/80 bg-white/95 p-5 shadow-sm shadow-slate-950/5"; - -export const openClawSubPanelClassName = - "rounded-[22px] border border-slate-200/80 bg-slate-50/80 p-4"; - -export const openClawPrimaryButtonClassName = - "inline-flex items-center justify-center gap-2 rounded-xl border border-emerald-200 bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)] px-4 py-2.5 text-sm font-medium text-white shadow-sm shadow-emerald-950/15 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-60"; - -export const openClawSecondaryButtonClassName = - "inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"; - -export const openClawDangerButtonClassName = - "inline-flex items-center justify-center gap-2 rounded-xl border border-rose-200 bg-white px-4 py-2.5 text-sm font-medium text-rose-700 transition hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-60"; - -export const openClawInputClassName = - "mt-2 w-full rounded-xl border border-slate-200 bg-slate-50/80 px-3 py-3 text-sm outline-none transition focus:border-slate-300 focus:ring-2 focus:ring-slate-200"; diff --git a/src/components/openclaw/pathDisplay.ts b/src/components/openclaw/pathDisplay.ts deleted file mode 100644 index 78a93b7ca..000000000 --- a/src/components/openclaw/pathDisplay.ts +++ /dev/null @@ -1,39 +0,0 @@ -const POSIX_HOME_PATTERN = /^\/(?:Users|home)\/[^/]+/; -const WINDOWS_HOME_PATTERN = /^[A-Za-z]:[\\/]+Users[\\/]+[^\\/]+/; - -function normalizeDisplayPath(path: string): string { - const trimmed = path.trim(); - if (!trimmed) { - return trimmed; - } - - if (POSIX_HOME_PATTERN.test(trimmed)) { - return trimmed.replace(POSIX_HOME_PATTERN, "~"); - } - - if (WINDOWS_HOME_PATTERN.test(trimmed)) { - return trimmed.replace(WINDOWS_HOME_PATTERN, "~").replace(/\\/g, "/"); - } - - return trimmed; -} - -export function compactPathLabel( - path: string | null | undefined, - maxLength = 52, -): string { - if (!path) { - return "未检测到"; - } - - const normalized = normalizeDisplayPath(path); - if (normalized.length <= maxLength) { - return normalized; - } - - const visibleLength = Math.max(maxLength - 3, 12); - const headLength = Math.max(10, Math.ceil(visibleLength * 0.58)); - const tailLength = Math.max(6, visibleLength - headLength); - - return `${normalized.slice(0, headLength)}...${normalized.slice(-tailLength)}`; -} diff --git a/src/components/openclaw/types.ts b/src/components/openclaw/types.ts deleted file mode 100644 index dcfc1bba5..000000000 --- a/src/components/openclaw/types.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { OpenClawSubpage } from "@/types/page"; -import type { OpenClawInstallProgressEvent } from "@/lib/api/openclaw"; - -export type { OpenClawSubpage }; - -export type OpenClawOperationKind = - | "install" - | "uninstall" - | "restart" - | "repair" - | "update"; -export type OpenClawScene = "setup" | "sync" | "dashboard"; - -export interface OpenClawLastSynced { - providerId: string; - modelId: string; -} - -export interface OpenClawOperationState { - kind: OpenClawOperationKind | null; - target: "openclaw" | "node" | "git" | "cleanup" | "environment" | null; - running: boolean; - title: string | null; - description: string | null; - message: string | null; - returnSubpage: OpenClawSubpage; -} - -export interface OpenClawOperationHistoryEntry { - kind: OpenClawOperationKind; - target: OpenClawOperationState["target"]; - title: string | null; - description: string | null; - message: string | null; - returnSubpage: OpenClawSubpage; - success: boolean; - updatedAt: string; - logs: OpenClawInstallProgressEvent[]; - rawLogsText: string; - diagnosticBundleJson: string; - repairPrompt: string; -} - -export interface OpenClawSceneDefinition { - id: OpenClawScene; - title: string; - description: string; -} - -export interface OpenClawSceneStatus { - label: string; - tone: string; -} diff --git a/src/components/openclaw/useOpenClawDashboardWindow.ts b/src/components/openclaw/useOpenClawDashboardWindow.ts deleted file mode 100644 index 75e813aac..000000000 --- a/src/components/openclaw/useOpenClawDashboardWindow.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; -import { openclawApi, type OpenClawGatewayStatus } from "@/lib/api/openclaw"; -import { - closeOpenClawDashboardWindow, - focusOpenClawDashboardWindow, - isOpenClawDashboardWindowOpen, - openOpenClawDashboardWindow, - reloadOpenClawDashboardWindow, - resolveOpenClawDashboardProfileKey, -} from "@/lib/api/openclawDashboardWindow"; -import { openUrl } from "./openUrl"; - -interface UseOpenClawDashboardWindowOptions { - gatewayStatus: OpenClawGatewayStatus; - profileVersionKey?: string | null; -} - -export function useOpenClawDashboardWindow({ - gatewayStatus, - profileVersionKey, -}: UseOpenClawDashboardWindowOptions) { - const [dashboardUrl, setDashboardUrl] = useState(null); - const [dashboardLoading, setDashboardLoading] = useState(false); - const [dashboardWindowOpen, setDashboardWindowOpen] = useState(false); - const [dashboardWindowBusy, setDashboardWindowBusy] = useState(false); - const lastDashboardProfileKeyRef = useRef(null); - - const refreshDashboardUrl = useCallback( - async ({ silent = true, showLoading = false } = {}) => { - if (showLoading) { - setDashboardLoading(true); - } - - try { - const url = await openclawApi.getDashboardUrl(); - setDashboardUrl(url); - return url; - } catch (error) { - if (!silent) { - toast.error(error instanceof Error ? error.message : String(error)); - } - return null; - } finally { - if (showLoading) { - setDashboardLoading(false); - } - } - }, - [], - ); - - const refreshDashboardWindowState = useCallback(async () => { - try { - const opened = await isOpenClawDashboardWindowOpen(); - setDashboardWindowOpen(opened); - return opened; - } catch (error) { - console.warn("[OpenClaw] 查询 Dashboard 窗口状态失败:", error); - setDashboardWindowOpen(false); - return false; - } - }, []); - - const resolveDashboardUrl = useCallback(async () => { - return ( - dashboardUrl ?? - (await refreshDashboardUrl({ - silent: false, - showLoading: true, - })) - ); - }, [dashboardUrl, refreshDashboardUrl]); - - const handleOpenDashboardWindow = useCallback(async () => { - const url = await resolveDashboardUrl(); - if (!url) { - return false; - } - - const profileKey = resolveOpenClawDashboardProfileKey(profileVersionKey); - const shouldRecreate = - dashboardWindowOpen && - lastDashboardProfileKeyRef.current !== null && - lastDashboardProfileKeyRef.current !== profileKey; - - setDashboardWindowBusy(true); - try { - const result = await openOpenClawDashboardWindow(url, { - forceRecreate: shouldRecreate, - profileVersionKey, - }); - lastDashboardProfileKeyRef.current = profileKey; - await refreshDashboardWindowState(); - toast.success( - shouldRecreate - ? "Dashboard 桌面面板已按新版本重新打开。" - : result.reused - ? "Dashboard 桌面面板已聚焦。" - : "Dashboard 桌面面板已打开。", - ); - return true; - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - return false; - } finally { - setDashboardWindowBusy(false); - } - }, [ - dashboardWindowOpen, - profileVersionKey, - refreshDashboardWindowState, - resolveDashboardUrl, - ]); - - const handleFocusDashboardWindow = useCallback(async () => { - setDashboardWindowBusy(true); - try { - const focused = await focusOpenClawDashboardWindow(); - if (!focused) { - toast.error("Dashboard 桌面面板尚未打开。"); - await refreshDashboardWindowState(); - return false; - } - - toast.success("Dashboard 桌面面板已聚焦。"); - await refreshDashboardWindowState(); - return true; - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - return false; - } finally { - setDashboardWindowBusy(false); - } - }, [refreshDashboardWindowState]); - - const handleReloadDashboardWindow = useCallback(async () => { - const url = await refreshDashboardUrl({ - silent: false, - showLoading: true, - }); - if (!url) { - return false; - } - - setDashboardWindowBusy(true); - try { - const profileKey = resolveOpenClawDashboardProfileKey(profileVersionKey); - const shouldRecreate = - dashboardWindowOpen && - lastDashboardProfileKeyRef.current !== null && - lastDashboardProfileKeyRef.current !== profileKey; - const reloaded = shouldRecreate - ? false - : await reloadOpenClawDashboardWindow(url); - if (reloaded) { - lastDashboardProfileKeyRef.current = profileKey; - toast.success("Dashboard 桌面面板已重载。"); - } else { - const result = await openOpenClawDashboardWindow(url, { - forceRecreate: shouldRecreate, - profileVersionKey, - }); - lastDashboardProfileKeyRef.current = profileKey; - toast.success( - shouldRecreate - ? "Dashboard 桌面面板已按新版本重新打开。" - : result.reused - ? "Dashboard 桌面面板已聚焦。" - : "Dashboard 桌面面板已打开。", - ); - } - await refreshDashboardWindowState(); - return true; - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - return false; - } finally { - setDashboardWindowBusy(false); - } - }, [ - dashboardWindowOpen, - profileVersionKey, - refreshDashboardUrl, - refreshDashboardWindowState, - ]); - - const handleCloseDashboardWindow = useCallback(async () => { - setDashboardWindowBusy(true); - try { - const closed = await closeOpenClawDashboardWindow(); - if (closed) { - lastDashboardProfileKeyRef.current = null; - toast.success("Dashboard 桌面面板已关闭。"); - } - await refreshDashboardWindowState(); - return closed; - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - return false; - } finally { - setDashboardWindowBusy(false); - } - }, [refreshDashboardWindowState]); - - const closeDashboardWindowSilently = useCallback(async () => { - setDashboardWindowOpen(false); - lastDashboardProfileKeyRef.current = null; - try { - await closeOpenClawDashboardWindow(); - } catch (error) { - console.warn("[OpenClaw] 关闭 Dashboard 窗口失败:", error); - } - }, []); - - const handleRefreshDashboardStatus = useCallback(async () => { - setDashboardWindowBusy(true); - try { - await Promise.all([ - refreshDashboardUrl({ - silent: false, - showLoading: true, - }), - refreshDashboardWindowState(), - ]); - toast.success("Dashboard 状态已刷新。"); - return true; - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - return false; - } finally { - setDashboardWindowBusy(false); - } - }, [refreshDashboardUrl, refreshDashboardWindowState]); - - const handleOpenDashboardExternal = useCallback(async () => { - const url = await resolveDashboardUrl(); - if (url) { - await openUrl(url); - return true; - } - return false; - }, [resolveDashboardUrl]); - - useEffect(() => { - if (gatewayStatus !== "running" && gatewayStatus !== "starting") { - void closeDashboardWindowSilently(); - return; - } - - void refreshDashboardWindowState(); - - const syncWindowState = () => { - void refreshDashboardWindowState(); - }; - - const onFocus = () => { - syncWindowState(); - }; - - const onVisibilityChange = () => { - if (document.visibilityState === "visible") { - syncWindowState(); - } - }; - - const timer = window.setInterval(syncWindowState, 5000); - window.addEventListener("focus", onFocus); - document.addEventListener("visibilitychange", onVisibilityChange); - - return () => { - window.clearInterval(timer); - window.removeEventListener("focus", onFocus); - document.removeEventListener("visibilitychange", onVisibilityChange); - }; - }, [ - closeDashboardWindowSilently, - gatewayStatus, - refreshDashboardWindowState, - ]); - - return { - dashboardLoading, - dashboardUrl, - dashboardWindowBusy, - dashboardWindowOpen, - refreshDashboardUrl, - refreshDashboardWindowState, - handleOpenDashboardWindow, - handleFocusDashboardWindow, - handleReloadDashboardWindow, - handleCloseDashboardWindow, - handleRefreshDashboardStatus, - handleOpenDashboardExternal, - closeDashboardWindowSilently, - }; -} - -export type UseOpenClawDashboardWindowReturn = ReturnType< - typeof useOpenClawDashboardWindow ->; diff --git a/src/components/openclaw/useOpenClawStore.ts b/src/components/openclaw/useOpenClawStore.ts deleted file mode 100644 index 4f158155c..000000000 --- a/src/components/openclaw/useOpenClawStore.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { create } from "zustand"; -import { persist, createJSONStorage } from "zustand/middleware"; -import type { - OpenClawLastSynced, - OpenClawOperationHistoryEntry, -} from "./types"; - -interface OpenClawStoreState { - selectedProviderId: string | null; - selectedModelId: string; - gatewayPort: number; - preferredRuntimeId: string | null; - lastSynced: OpenClawLastSynced | null; - recentOperation: OpenClawOperationHistoryEntry | null; - setSelectedProviderId: (providerId: string | null) => void; - setSelectedModelId: (modelId: string) => void; - setGatewayPort: (port: number) => void; - setPreferredRuntimeId: (runtimeId: string | null) => void; - setLastSynced: (lastSynced: OpenClawLastSynced | null) => void; - setRecentOperation: ( - recentOperation: OpenClawOperationHistoryEntry | null, - ) => void; - clearLastSynced: () => void; - clearRecentOperation: () => void; -} - -export const useOpenClawStore = create()( - persist( - (set) => ({ - selectedProviderId: null, - selectedModelId: "", - gatewayPort: 18790, - preferredRuntimeId: null, - lastSynced: null, - recentOperation: null, - setSelectedProviderId: (selectedProviderId) => - set({ selectedProviderId }), - setSelectedModelId: (selectedModelId) => set({ selectedModelId }), - setGatewayPort: (gatewayPort) => set({ gatewayPort }), - setPreferredRuntimeId: (preferredRuntimeId) => - set({ preferredRuntimeId }), - setLastSynced: (lastSynced) => set({ lastSynced }), - setRecentOperation: (recentOperation) => set({ recentOperation }), - clearLastSynced: () => set({ lastSynced: null }), - clearRecentOperation: () => set({ recentOperation: null }), - }), - { - name: "lime:openclaw-module", - storage: createJSONStorage(() => localStorage), - }, - ), -); diff --git a/src/components/projects/CreateProjectDialog.test.tsx b/src/components/projects/CreateProjectDialog.test.tsx index 242cfadbe..cab7bd4db 100644 --- a/src/components/projects/CreateProjectDialog.test.tsx +++ b/src/components/projects/CreateProjectDialog.test.tsx @@ -17,6 +17,7 @@ const { mockGetProjectByRootPath, mockGetWorkspaceProjectsRoot, mockResolveProjectRootPath, + mockTranslate, } = vi.hoisted(() => ({ mockExtractErrorMessage: vi.fn((error: unknown) => error instanceof Error ? error.message : String(error), @@ -25,6 +26,21 @@ const { mockGetProjectByRootPath: vi.fn(), mockGetWorkspaceProjectsRoot: vi.fn(), mockResolveProjectRootPath: vi.fn(), + mockTranslate: ( + key: string, + options?: string | { defaultValue?: string; [key: string]: unknown }, + ) => { + const template = + typeof options === "string" ? options : (options?.defaultValue ?? key); + + if (typeof options === "string") { + return template; + } + + return template.replace(/{{(\w+)}}/g, (_, name: string) => + String(options?.[name] ?? ""), + ); + }, })); vi.mock("sonner", () => ({ @@ -34,6 +50,12 @@ vi.mock("sonner", () => ({ }, })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: mockTranslate, + }), +})); + vi.mock("@/lib/api/project", () => ({ USER_PROJECT_TYPES: ["general"], extractErrorMessage: mockExtractErrorMessage, diff --git a/src/components/projects/CreateProjectDialog.tsx b/src/components/projects/CreateProjectDialog.tsx index eac739319..5b31ebeb3 100644 --- a/src/components/projects/CreateProjectDialog.tsx +++ b/src/components/projects/CreateProjectDialog.tsx @@ -5,6 +5,7 @@ */ import { useState, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; import { Dialog, @@ -48,6 +49,7 @@ export function CreateProjectDialog({ defaultName, allowedTypes, }: CreateProjectDialogProps) { + const { t } = useTranslation("common"); const [name, setName] = useState(""); const [type, setType] = useState(defaultType || "general"); const [isSubmitting, setIsSubmitting] = useState(false); @@ -70,6 +72,30 @@ export function CreateProjectDialog({ }, [allowedTypes]); const fallbackType = visibleTypes[0] || "general"; + const getProjectTypeDisplayLabel = (projectType: ProjectType) => + t(`common.createProjectDialog.projectType.${projectType}`, { + defaultValue: getProjectTypeLabel(projectType), + }); + const createProjectErrorMessageCopy = useMemo( + () => ({ + invalidPath: t("common.createProjectDialog.error.invalidPath", { + defaultValue: "项目目录无效,请重新选择", + }), + objectError: t("common.createProjectDialog.error.object", { + defaultValue: "创建项目失败,请查看日志", + }), + pathExists: t("common.createProjectDialog.error.pathExists", { + defaultValue: "项目目录已存在,请更换项目名称或清理同名目录", + }), + staleSchema: t("common.createProjectDialog.error.staleSchema", { + defaultValue: "数据库结构过旧,请重启应用以执行迁移", + }), + unknown: t("common.createProjectDialog.error.unknown", { + defaultValue: "未知错误", + }), + }), + [t], + ); // 当对话框打开且 defaultType 变化时,更新类型选择 useEffect(() => { @@ -178,7 +204,12 @@ export function CreateProjectDialog({ } if (existingProject) { - setPathConflictMessage(`路径已存在项目:${existingProject.name}`); + setPathConflictMessage( + t("common.createProjectDialog.path.conflict", { + defaultValue: "路径已存在项目:{{name}}", + name: existingProject.name, + }), + ); } else { setPathConflictMessage(""); } @@ -199,7 +230,7 @@ export function CreateProjectDialog({ return () => { mounted = false; }; - }, [open, resolvedProjectPath]); + }, [open, resolvedProjectPath, t]); const handleSubmit = async () => { if (!name.trim()) return; @@ -217,25 +248,52 @@ export function CreateProjectDialog({ } catch (error) { console.error("创建项目失败:", error); const message = extractErrorMessage(error); - const friendlyMessage = getCreateProjectErrorMessage(message); - toast.error(`创建项目失败: ${friendlyMessage}`); + const friendlyMessage = getCreateProjectErrorMessage( + message, + createProjectErrorMessageCopy, + ); + toast.error( + t("common.createProjectDialog.toast.createFailed", { + defaultValue: "创建项目失败:{{message}}", + message: friendlyMessage, + }), + ); } finally { setIsSubmitting(false); } }; const topBadges = [ - getProjectTypeLabel(type), - workspaceRootPath ? "工作区目录已解析" : "等待目录加载", - pathConflictMessage ? "路径冲突" : "路径可用", + getProjectTypeDisplayLabel(type), + workspaceRootPath + ? t("common.createProjectDialog.status.workspaceResolved", { + defaultValue: "工作区目录已解析", + }) + : t("common.createProjectDialog.status.waitingWorkspace", { + defaultValue: "等待目录加载", + }), + pathConflictMessage + ? t("common.createProjectDialog.status.pathConflict", { + defaultValue: "路径冲突", + }) + : t("common.createProjectDialog.status.pathAvailable", { + defaultValue: "路径可用", + }), ]; return ( - 新建项目 + + {t("common.createProjectDialog.title", { + defaultValue: "新建项目", + })} + - 创建一个新的内容创作项目,目录将固定在 workspace 目录下。 + {t("common.createProjectDialog.description", { + defaultValue: + "创建一个新的内容创作项目,目录将固定在 workspace 目录下。", + })} @@ -247,10 +305,15 @@ export function CreateProjectDialog({
- 创建新的项目工作台 + {t("common.createProjectDialog.hero.title", { + defaultValue: "创建新的项目工作台", + })}
- 先确定项目名称和类型,再确认目录路径,后续内容、风格和记忆都会挂到这个项目下。 + {t("common.createProjectDialog.hero.description", { + defaultValue: + "先确定项目名称和类型,再确认目录路径,后续内容、风格和记忆都会挂到这个项目下。", + })}
@@ -264,12 +327,21 @@ export function CreateProjectDialog({ ))}
- + setName(e.target.value)} - placeholder="输入项目名称..." + placeholder={t( + "common.createProjectDialog.name.placeholder", + { + defaultValue: "输入项目名称...", + }, + )} autoFocus className="h-11 border-slate-200/80 bg-white/90" /> @@ -280,32 +352,40 @@ export function CreateProjectDialog({
- 选择项目类型 + {t("common.createProjectDialog.type.title", { + defaultValue: "选择项目类型", + })}
- 类型会影响后续默认内容、工作台结构和推荐视图。 + {t("common.createProjectDialog.type.description", { + defaultValue: "类型会影响后续默认内容、工作台结构和推荐视图。", + })}
- {visibleTypes.map((t) => ( + {visibleTypes.map((projectType) => ( @@ -318,39 +398,70 @@ export function CreateProjectDialog({
- 目录与路径 + {t("common.createProjectDialog.path.title", { + defaultValue: "目录与路径", + })}
- 项目会固定创建在 workspace 根目录下,避免目录来源不一致。 + {t("common.createProjectDialog.path.description", { + defaultValue: + "项目会固定创建在 workspace 根目录下,避免目录来源不一致。", + })}
- +
- +

- 将创建到:{resolvedProjectPath || "请输入项目名称"} + {t("common.createProjectDialog.path.willCreateAt", { + defaultValue: "将创建到:{{path}}", + path: + resolvedProjectPath || + t("common.createProjectDialog.path.nameRequired", { + defaultValue: "请输入项目名称", + }), + })}

{pathChecking && ( -

正在检查路径...

+

+ {t("common.createProjectDialog.path.checking", { + defaultValue: "正在检查路径...", + })} +

)} {!pathChecking && pathConflictMessage && (

@@ -363,12 +474,29 @@ export function CreateProjectDialog({

- 创建建议 + {t("common.createProjectDialog.tips.title", { + defaultValue: "创建建议", + })}
-

项目名称尽量稳定,后续会同步影响目录名和项目识别。

-

如果路径已冲突,优先改项目名,不要手工改系统目录。

-

项目类型决定默认工作台结构,先选最贴近当前任务的类型。

+

+ {t("common.createProjectDialog.tips.name", { + defaultValue: + "项目名称尽量稳定,后续会同步影响目录名和项目识别。", + })} +

+

+ {t("common.createProjectDialog.tips.path", { + defaultValue: + "如果路径已冲突,优先改项目名,不要手工改系统目录。", + })} +

+

+ {t("common.createProjectDialog.tips.type", { + defaultValue: + "项目类型决定默认工作台结构,先选最贴近当前任务的类型。", + })} +

@@ -379,7 +507,7 @@ export function CreateProjectDialog({ className="border-slate-200/80 bg-white" onClick={() => onOpenChange(false)} > - 取消 + {t("common.cancel", { defaultValue: "取消" })} diff --git a/src/components/projects/ProjectSelector.tsx b/src/components/projects/ProjectSelector.tsx index cd5337b37..6efb54cf6 100644 --- a/src/components/projects/ProjectSelector.tsx +++ b/src/components/projects/ProjectSelector.tsx @@ -6,6 +6,7 @@ */ import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Check, ChevronDown, @@ -42,7 +43,6 @@ import { } from "@/lib/api/project"; import { toProjectView } from "@/lib/projectView"; import type { Project } from "@/types/project"; -import { WorkspaceTypeLabels } from "@/types/workspace"; import { cn } from "@/lib/utils"; import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayIdleTask"; import { CreateProjectDialog } from "./CreateProjectDialog"; @@ -121,9 +121,19 @@ function resolveDefaultProjectType(workspaceType?: string): ProjectType { return "general"; } -function formatProjectPathPreview(rootPath?: string): string { +interface ProjectSelectorTextCopy { + defaultProjectBadge: string; + noDirectory: string; + pendingProject: string; + workspaceTypeLabels: Record; +} + +function formatProjectPathPreview( + rootPath: string | undefined, + noDirectoryLabel: string, +): string { if (!rootPath) { - return "未设置目录"; + return noDirectoryLabel; } const segments = rootPath.split(/[\\/]+/).filter(Boolean); @@ -138,14 +148,26 @@ function formatProjectPathPreview(rootPath?: string): string { return `…/${segments.slice(-2).join("/")}`; } -function getProjectMetaText(project: Project | null | undefined): string { +function getWorkspaceTypeLabel( + workspaceTypeLabels: Record, + workspaceType: string, +): string { + return workspaceTypeLabels[workspaceType] ?? workspaceType; +} + +function getProjectMetaText( + project: Project | null | undefined, + copy: ProjectSelectorTextCopy, +): string { if (!project) { - return "待选择项目"; + return copy.pendingProject; } - const meta = [WorkspaceTypeLabels[project.workspaceType]]; + const meta = [ + getWorkspaceTypeLabel(copy.workspaceTypeLabels, project.workspaceType), + ]; if (project.isDefault) { - meta.unshift("默认项目"); + meta.unshift(copy.defaultProjectBadge); } return meta.join(" · "); @@ -153,12 +175,13 @@ function getProjectMetaText(project: Project | null | undefined): string { function getProjectSummaryText( project: Project | null | undefined, + copy: ProjectSelectorTextCopy, ): string | null { if (!project) { return null; } - return `${project.name} · ${getProjectMetaText(project)} · ${formatProjectPathPreview(project.rootPath)}`; + return `${project.name} · ${getProjectMetaText(project, copy)} · ${formatProjectPathPreview(project.rootPath, copy.noDirectory)}`; } /** @@ -173,7 +196,7 @@ export function ProjectSelector({ onOpenChange, passiveTrigger = false, workspaceType, - placeholder = "选择项目", + placeholder, disabled = false, className, dropdownSide = "top", @@ -185,6 +208,7 @@ export function ProjectSelector({ deferProjectListLoad = false, autoSelectFallback = true, }: ProjectSelectorProps) { + const { t } = useTranslation("common"); const { projects, generalProjects, @@ -223,12 +247,71 @@ export function ProjectSelector({ const compactLikeTrigger = compact || embedded || workspaceTab; const compactPanel = compact || workspaceTab; const open = openProp ?? internalOpen; - const entityLabel = workspaceTab ? "工作区" : "项目"; - const createEntityLabel = workspaceTab ? "新建工作区" : "新建项目"; - const managementTitle = workspaceTab ? "工作区管理" : "项目管理"; + const entityLabel = workspaceTab + ? t("common.projectSelector.entity.workspace", { + defaultValue: "工作区", + }) + : t("common.projectSelector.entity.project", { + defaultValue: "项目", + }); + const createEntityLabel = workspaceTab + ? t("common.projectSelector.action.createWorkspace", { + defaultValue: "新建工作区", + }) + : t("common.projectSelector.action.createProject", { + defaultValue: "新建项目", + }); + const managementTitle = workspaceTab + ? t("common.projectSelector.management.title.workspace", { + defaultValue: "工作区管理", + }) + : t("common.projectSelector.management.title.project", { + defaultValue: "项目管理", + }); const managementDescription = workspaceTab - ? "当前只管理可见工作区,不影响本地目录与已有文件。" - : "当前只管理可见项目,不影响本地目录与已有文件。"; + ? t("common.projectSelector.management.description.workspace", { + defaultValue: "当前只管理可见工作区,不影响本地目录与已有文件。", + }) + : t("common.projectSelector.management.description.project", { + defaultValue: "当前只管理可见项目,不影响本地目录与已有文件。", + }); + const resolvedPlaceholder = + placeholder ?? + (workspaceTab + ? t("common.projectSelector.placeholder.workspace", { + defaultValue: "选择工作区", + }) + : t("common.projectSelector.placeholder.project", { + defaultValue: "选择项目", + })); + const projectSelectorCopy = useMemo( + () => ({ + defaultProjectBadge: t("common.projectSelector.meta.default", { + defaultValue: "默认项目", + }), + noDirectory: t("common.projectSelector.path.notSet", { + defaultValue: "未设置目录", + }), + pendingProject: t("common.projectSelector.meta.pending", { + defaultValue: "待选择项目", + }), + workspaceTypeLabels: { + persistent: t("common.projectSelector.workspaceType.persistent", { + defaultValue: "持久化", + }), + temporary: t("common.projectSelector.workspaceType.temporary", { + defaultValue: "临时", + }), + blog: t("common.projectSelector.workspaceType.blog", { + defaultValue: "博客", + }), + general: t("common.projectSelector.workspaceType.general", { + defaultValue: "通用", + }), + }, + }), + [t], + ); const projectSource = workspaceType === "general" ? generalProjects : projects; @@ -482,7 +565,12 @@ export function ProjectSelector({ }); onChange(project.id); handleOpenChange(false); - toast.success(`${entityLabel}已创建`); + toast.success( + t("common.projectSelector.toast.created", { + defaultValue: "{{entity}}已创建", + entity: entityLabel, + }), + ); }; const handleOpenRename = () => { @@ -504,7 +592,12 @@ export function ProjectSelector({ const nextName = renameName.trim(); if (!nextName) { - toast.error(`${entityLabel}名称不能为空`); + toast.error( + t("common.projectSelector.toast.nameRequired", { + defaultValue: "{{entity}}名称不能为空", + entity: entityLabel, + }), + ); return; } @@ -513,10 +606,18 @@ export function ProjectSelector({ await rename(renameTarget.id, nextName); setRenameDialogOpen(false); setRenameTargetId(null); - toast.success(`${entityLabel}名称已更新`); + toast.success( + t("common.projectSelector.toast.renamed", { + defaultValue: "{{entity}}名称已更新", + entity: entityLabel, + }), + ); } catch (error) { toast.error( - `重命名失败: ${error instanceof Error ? error.message : String(error)}`, + t("common.projectSelector.toast.renameFailed", { + defaultValue: "重命名失败:{{message}}", + message: error instanceof Error ? error.message : String(error), + }), ); } finally { setIsRenaming(false); @@ -559,10 +660,18 @@ export function ProjectSelector({ setDeleteDialogOpen(false); setDeleteTargetId(null); - toast.success(`${entityLabel}已删除,本地目录未删除`); + toast.success( + t("common.projectSelector.toast.deleted", { + defaultValue: "{{entity}}已删除,本地目录未删除", + entity: entityLabel, + }), + ); } catch (error) { toast.error( - `删除失败: ${error instanceof Error ? error.message : String(error)}`, + t("common.projectSelector.toast.deleteFailed", { + defaultValue: "删除失败:{{message}}", + message: error instanceof Error ? error.message : String(error), + }), ); } finally { setIsDeleting(false); @@ -571,9 +680,15 @@ export function ProjectSelector({ const managementHint = selectedProject && !canRenameProject(selectedProject) - ? `默认${entityLabel}不可重命名或删除` + ? t("common.projectSelector.management.defaultLocked", { + defaultValue: "默认{{entity}}不可重命名或删除", + entity: entityLabel, + }) : null; - const projectSummaryText = getProjectSummaryText(selectedProject); + const projectSummaryText = getProjectSummaryText( + selectedProject, + projectSelectorCopy, + ); const popoverWidthClass = compactPanel ? "w-[392px]" : "w-[420px]"; const headerPaddingClass = compactPanel ? "px-4 py-3" : "px-4 py-4"; const bodyPaddingClass = compactPanel ? "px-4 py-3" : "px-4 py-4"; @@ -604,7 +719,7 @@ export function ProjectSelector({ title={ selectedProject ? `${selectedProject.name}\n${selectedProject.rootPath}` - : placeholder + : resolvedPlaceholder } > - {selectedProject?.name || placeholder} + {selectedProject?.name || resolvedPlaceholder} @@ -645,11 +760,13 @@ export function ProjectSelector({ {compactLikeTrigger ? ( - {selectedProject?.name || placeholder} + {selectedProject?.name || resolvedPlaceholder} {selectedProject?.isDefault ? ( - 默认 + {t("common.projectSelector.badge.default", { + defaultValue: "默认", + })} ) : null} @@ -657,11 +774,13 @@ export function ProjectSelector({ - {selectedProject?.name || placeholder} + {selectedProject?.name || resolvedPlaceholder} {selectedProject?.isDefault ? ( - 默认 + {t("common.projectSelector.badge.default", { + defaultValue: "默认", + })} ) : null} @@ -708,25 +827,38 @@ export function ProjectSelector({
- 选择{entityLabel} + {t("common.projectSelector.header.title", { + defaultValue: "选择{{entity}}", + entity: entityLabel, + })}
- 在这里切换{entityLabel}、搜索{entityLabel},并管理当前可见 - {entityLabel}列表。 + {t("common.projectSelector.header.description", { + defaultValue: + "在这里切换{{entity}}、搜索{{entity}},并管理当前可见{{entity}}列表。", + entity: entityLabel, + })}
- {filteredProjects.length} 个{entityLabel} + {t("common.projectSelector.header.count", { + count: filteredProjects.length, + defaultValue: "{{count}} 个{{entity}}", + entity: entityLabel, + })}
{!compactPanel && projectSummaryText ? (
- 当前{entityLabel}: + {t("common.projectSelector.current.label", { + defaultValue: "当前{{entity}}:", + entity: entityLabel, + })} {projectSummaryText}
@@ -737,7 +869,10 @@ export function ProjectSelector({ setSearchQuery(event.target.value)} - placeholder={`搜索${entityLabel}`} + placeholder={t("common.projectSelector.search.placeholder", { + defaultValue: "搜索{{entity}}", + entity: entityLabel, + })} className={cn( compactPanel ? "h-9" : "h-10", "border-slate-200/80 bg-white/85 pl-9 focus-visible:border-slate-300 focus-visible:ring-1 focus-visible:ring-slate-300 focus-visible:ring-offset-0", @@ -759,11 +894,13 @@ export function ProjectSelector({ > {displayLoading ? (
- 加载中... + {t("common.loading", { defaultValue: "加载中..." })}
) : filteredProjects.length === 0 ? (
- 未找到匹配项目 + {t("common.projectSelector.empty", { + defaultValue: "未找到匹配项目", + })}
) : ( filteredProjects.map((project) => { @@ -817,14 +954,19 @@ export function ProjectSelector({ variant="outline" className="border-amber-200/80 bg-amber-50 text-[10px] font-medium text-amber-700" > - 默认 + {t("common.projectSelector.badge.default", { + defaultValue: "默认", + })} ) : null} - {WorkspaceTypeLabels[project.workspaceType]} + {getWorkspaceTypeLabel( + projectSelectorCopy.workspaceTypeLabels, + project.workspaceType, + )}
{compact - ? formatProjectPathPreview(project.rootPath) + ? formatProjectPathPreview( + project.rootPath, + projectSelectorCopy.noDirectory, + ) : project.rootPath}
{project.tags.length > 0 ? ( @@ -909,7 +1054,9 @@ export function ProjectSelector({ disabled={!canRenameProject(selectedProject)} > - 重命名 + {t("common.projectSelector.action.rename", { + defaultValue: "重命名", + })}
{managementHint ? ( @@ -958,16 +1105,26 @@ export function ProjectSelector({ > - 重命名{entityLabel} + + {t("common.projectSelector.rename.title", { + defaultValue: "重命名{{entity}}", + entity: entityLabel, + })} + - 更新{entityLabel}名称,不会修改本地目录路径。 + {t("common.projectSelector.rename.description", { + defaultValue: "更新{{entity}}名称,不会修改本地目录路径。", + entity: entityLabel, + })}
setRenameName(event.target.value)} - placeholder="输入新的项目名称" + placeholder={t("common.projectSelector.rename.placeholder", { + defaultValue: "输入新的项目名称", + })} autoFocus />
@@ -984,14 +1141,18 @@ export function ProjectSelector({ }} disabled={isRenaming} > - 取消 + {t("common.cancel", { defaultValue: "取消" })}
@@ -1011,19 +1172,30 @@ export function ProjectSelector({ - 删除{entityLabel} + {t("common.projectSelector.delete.title", { + defaultValue: "删除{{entity}}", + entity: entityLabel, + })} - 确定要删除{entityLabel} - {deleteTarget ? `「${deleteTarget.name}」` : ""} - 吗? + {t("common.projectSelector.delete.description", { + defaultValue: "确定要删除{{entity}}{{name}}吗?", + entity: entityLabel, + name: deleteTarget ? `「${deleteTarget.name}」` : "", + })}
-

此操作不可恢复

+

+ {t("common.projectSelector.delete.dangerTitle", { + defaultValue: "此操作不可恢复", + })} +

- 仅删除项目记录,不删除本地目录和已有文件。 + {t("common.projectSelector.delete.dangerDescription", { + defaultValue: "仅删除项目记录,不删除本地目录和已有文件。", + })}

@@ -1040,7 +1212,7 @@ export function ProjectSelector({ }} disabled={isDeleting} > - 取消 + {t("common.cancel", { defaultValue: "取消" })}
diff --git a/src/components/projects/ProjectSelector.ui.test.tsx b/src/components/projects/ProjectSelector.ui.test.tsx index d3b18ca1c..897dbb525 100644 --- a/src/components/projects/ProjectSelector.ui.test.tsx +++ b/src/components/projects/ProjectSelector.ui.test.tsx @@ -26,6 +26,24 @@ vi.mock("sonner", () => ({ }, })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: ( + key: string, + options?: { + count?: number; + defaultValue?: string; + [key: string]: unknown; + }, + ) => { + const template = options?.defaultValue ?? key; + return template.replace(/{{(\w+)}}/g, (_, name: string) => + String(options?.[name] ?? ""), + ); + }, + }), +})); + vi.mock("@/hooks/useProjects", () => ({ useProjects: mockUseProjects, })); diff --git a/src/components/sceneapps/SceneAppReviewFeedbackBanner.tsx b/src/components/sceneapps/SceneAppReviewFeedbackBanner.tsx index 1dd81f8a1..5c2630af4 100644 --- a/src/components/sceneapps/SceneAppReviewFeedbackBanner.tsx +++ b/src/components/sceneapps/SceneAppReviewFeedbackBanner.tsx @@ -1,5 +1,8 @@ +import { useTranslation } from "react-i18next"; import { + buildReviewFeedbackProjectionCopy, buildReviewFeedbackProjection, + formatReviewFeedbackTemplate, type ReviewFeedbackProjection, } from "@/components/agent/chat/utils/reviewFeedbackProjection"; import type { CuratedTaskRecommendationSignal } from "@/components/agent/chat/utils/curatedTaskRecommendationSignals"; @@ -19,6 +22,7 @@ function SceneAppReviewFeedbackProjectionBanner({ dataTestId?: string; onContinueReviewFeedback?: (taskId: string) => void; }) { + const { t } = useTranslation("agent"); const primarySuggestedTask = projection.suggestedTasks[0] ?? null; return ( @@ -28,7 +32,7 @@ function SceneAppReviewFeedbackProjectionBanner({ >
- 围绕最近判断 + {t("reviewFeedback.badge", "围绕最近判断")} {projection.suggestedTaskTitles.length > 0 ? ( @@ -37,7 +41,13 @@ function SceneAppReviewFeedbackProjectionBanner({ ) : null}
- 最近判断已更新:{projection.signal.title} + {formatReviewFeedbackTemplate( + t("reviewFeedback.title", { + defaultValue: "最近判断已更新:{{title}}", + title: projection.signal.title, + }), + { title: projection.signal.title }, + )}
{projection.signal.summary} @@ -55,10 +65,19 @@ function SceneAppReviewFeedbackProjectionBanner({ onContinueReviewFeedback(primarySuggestedTask.taskId) } > - 继续去「{primarySuggestedTask.title}」 + {formatReviewFeedbackTemplate( + t("reviewFeedback.action", { + defaultValue: "继续去「{{title}}」", + title: primarySuggestedTask.title, + }), + { title: primarySuggestedTask.title }, + )} - 会继续带着当前结果基线,不用重新整理一遍。 + {t( + "reviewFeedback.helper.sceneApp", + "会继续带着当前结果基线,不用重新整理一遍。", + )}
) : null} @@ -71,7 +90,11 @@ export function SceneAppReviewFeedbackBanner({ dataTestId, onContinueReviewFeedback, }: SceneAppReviewFeedbackBannerProps) { - const projection = buildReviewFeedbackProjection({ signal }); + const { t } = useTranslation("agent"); + const projection = buildReviewFeedbackProjection({ + copy: buildReviewFeedbackProjectionCopy(t), + signal, + }); if (!projection) { return null; } diff --git a/src/components/settings-v2/_layout/SettingsSidebar.test.tsx b/src/components/settings-v2/_layout/SettingsSidebar.test.tsx index ba29b9520..e3f73cc50 100644 --- a/src/components/settings-v2/_layout/SettingsSidebar.test.tsx +++ b/src/components/settings-v2/_layout/SettingsSidebar.test.tsx @@ -1,31 +1,56 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Home, Palette } from "lucide-react"; -import { SettingsGroupKey, SettingsTabs } from "@/types/settings"; +import { SettingsTabs } from "@/types/settings"; -const mockUseSettingsCategory = vi.fn(); +const { mockUseTranslation } = vi.hoisted(() => { + const translations: Record = { + "settings.layout.sidebar.experimentalBadge": "Labs badge from i18n", + "settings.layout.sidebar.floatingNav.fallbackLabel": + "Settings nav from i18n", + "settings.layout.sidebar.floatingNav.openAria": + "Open settings navigation from i18n", + }; -vi.mock("../hooks/useSettingsCategory", () => ({ - useSettingsCategory: () => mockUseSettingsCategory(), + return { + mockUseTranslation: vi.fn((_namespace?: string) => ({ + t: (key: string, options?: unknown) => { + if (typeof options === "string") { + return translations[key] ?? options; + } + + if (options && typeof options === "object") { + const values = options as Record; + const template = + translations[key] ?? + (typeof values.defaultValue === "string" + ? values.defaultValue + : key); + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => + String(values[name] ?? ""), + ); + } + + return translations[key] ?? key; + }, + })), + }; +}); + +vi.mock("react-i18next", () => ({ + useTranslation: mockUseTranslation, })); import { SettingsSidebar } from "./SettingsSidebar"; -interface RenderResult { +interface Mounted { container: HTMLDivElement; root: Root; } -const mounted: RenderResult[] = []; +const mounted: Mounted[] = []; -function renderSidebar( - props?: Partial<{ - activeTab: SettingsTabs; - onTabChange: (tab: SettingsTabs) => void; - onTabPrefetch: (tab: SettingsTabs) => void; - }>, -): RenderResult { +function renderSidebar(activeTab: SettingsTabs = SettingsTabs.Developer) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -33,16 +58,15 @@ function renderSidebar( act(() => { root.render( , ); }); - const rendered = { container, root }; - mounted.push(rendered); - return rendered; + mounted.push({ container, root }); + return container; } beforeEach(() => { @@ -51,36 +75,10 @@ beforeEach(() => { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; - - mockUseSettingsCategory.mockReturnValue([ - { - key: SettingsGroupKey.Overview, - title: "概览", - items: [ - { - key: SettingsTabs.Home, - label: "设置首页", - icon: Home, - }, - ], - }, - { - key: SettingsGroupKey.General, - title: "通用", - items: [ - { - key: SettingsTabs.Appearance, - label: "外观", - icon: Palette, - }, - ], - }, - ]); + vi.clearAllMocks(); }); afterEach(() => { - mockUseSettingsCategory.mockReset(); - while (mounted.length > 0) { const target = mounted.pop(); if (!target) { @@ -92,135 +90,25 @@ afterEach(() => { }); target.container.remove(); } + vi.clearAllMocks(); }); describe("SettingsSidebar", () => { - it("侧边栏应暴露主题化容器和当前导航状态", () => { - const { container } = renderSidebar({ - activeTab: SettingsTabs.Appearance, - }); - const sidebar = container.querySelector('[data-testid="settings-sidebar"]'); - const activeButton = Array.from(container.querySelectorAll("button")).find( - (item) => item.textContent?.includes("外观"), - ); - const inactiveButton = Array.from( - container.querySelectorAll("button"), - ).find((item) => item.textContent?.includes("设置首页")); + it("应通过 settings namespace 渲染实验 badge 与浮动导航 aria", () => { + const container = renderSidebar(); - expect(sidebar).not.toBeNull(); - expect(activeButton?.getAttribute("data-active")).toBe("true"); - expect(inactiveButton?.getAttribute("data-active")).toBe("false"); - }); - - it("点击导航项时应触发 tab 切换", () => { - const onTabChange = vi.fn(); - const { container } = renderSidebar({ onTabChange }); - const button = Array.from(container.querySelectorAll("button")).find( - (item) => item.textContent?.includes("外观"), - ); - - act(() => { - button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - expect(onTabChange).toHaveBeenCalledWith(SettingsTabs.Appearance); - }); - - it("悬停导航项时应触发 tab 预取", () => { - const onTabPrefetch = vi.fn(); - const { container } = renderSidebar({ onTabPrefetch }); - const button = Array.from(container.querySelectorAll("button")).find( - (item) => item.textContent?.includes("外观"), - ); - - act(() => { - button?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); - }); - - expect(onTabPrefetch).toHaveBeenCalledWith(SettingsTabs.Appearance); - }); - - it("小屏悬浮导航按钮应显示当前页面并按需展开菜单", () => { - const { container } = renderSidebar({ - activeTab: SettingsTabs.Appearance, - }); - const floatingButton = container.querySelector( - '[data-testid="settings-floating-nav-button"]', - ); - - expect(floatingButton).not.toBeNull(); - expect(floatingButton?.textContent).toContain("外观"); - expect(floatingButton?.getAttribute("aria-expanded")).toBe("false"); + expect(mockUseTranslation).toHaveBeenCalledWith("settings"); + expect(container.textContent).toContain("Labs badge from i18n"); expect( - container.querySelector('[data-testid="settings-floating-nav-panel"]'), - ).toBeNull(); - - act(() => { - floatingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - const floatingPanel = container.querySelector( - '[data-testid="settings-floating-nav-panel"]', - ); - const activeButton = Array.from( - floatingPanel?.querySelectorAll("button") ?? [], - ).find((item) => item.textContent?.includes("外观")); - - expect(floatingButton?.getAttribute("aria-expanded")).toBe("true"); - expect(floatingPanel).not.toBeNull(); - expect(activeButton?.getAttribute("data-active")).toBe("true"); + container + .querySelector('[data-testid="settings-floating-nav-button"]') + ?.getAttribute("aria-label"), + ).toBe("Open settings navigation from i18n"); }); - it("点击悬浮菜单项后应切换 tab 并关闭浮层", () => { - const onTabChange = vi.fn(); - const { container } = renderSidebar({ onTabChange }); - const floatingButton = container.querySelector( - '[data-testid="settings-floating-nav-button"]', - ); + it("无 active item 时应使用 settings namespace 的浮动导航兜底标题", () => { + const container = renderSidebar("__missing__" as SettingsTabs); - act(() => { - floatingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - const floatingPanel = container.querySelector( - '[data-testid="settings-floating-nav-panel"]', - ); - const appearanceButton = Array.from( - floatingPanel?.querySelectorAll("button") ?? [], - ).find((item) => item.textContent?.includes("外观")); - - act(() => { - appearanceButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); - }); - - expect(onTabChange).toHaveBeenCalledWith(SettingsTabs.Appearance); - expect( - container.querySelector('[data-testid="settings-floating-nav-panel"]'), - ).toBeNull(); - expect(floatingButton?.getAttribute("aria-expanded")).toBe("false"); - }); - - it("按 Escape 应关闭悬浮导航浮层", () => { - const { container } = renderSidebar(); - const floatingButton = container.querySelector( - '[data-testid="settings-floating-nav-button"]', - ); - - act(() => { - floatingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect( - container.querySelector('[data-testid="settings-floating-nav-panel"]'), - ).not.toBeNull(); - - act(() => { - document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); - }); - - expect( - container.querySelector('[data-testid="settings-floating-nav-panel"]'), - ).toBeNull(); + expect(container.textContent).toContain("Settings nav from i18n"); }); }); diff --git a/src/components/settings-v2/_layout/SettingsSidebar.tsx b/src/components/settings-v2/_layout/SettingsSidebar.tsx index af61cfc9e..b7d8758e6 100644 --- a/src/components/settings-v2/_layout/SettingsSidebar.tsx +++ b/src/components/settings-v2/_layout/SettingsSidebar.tsx @@ -8,6 +8,7 @@ import styled from "styled-components"; import { ChevronDown } from "lucide-react"; import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useSettingsCategory, type CategoryGroup, @@ -265,6 +266,7 @@ export function SettingsSidebar({ onTabChange, onTabPrefetch, }: SettingsSidebarProps) { + const { t } = useTranslation("settings"); const categoryGroups = useSettingsCategory(); const floatingPanelId = useId(); const floatingRootRef = useRef(null); @@ -357,7 +359,9 @@ export function SettingsSidebar({ {item.label} {item.experimental && ( - 实验 + + {t("settings.layout.sidebar.experimentalBadge", "实验")} + )} ))} @@ -375,13 +379,20 @@ export function SettingsSidebar({ type="button" aria-expanded={floatingOpen} aria-controls={floatingPanelId} - aria-label="打开设置导航" + aria-label={t( + "settings.layout.sidebar.floatingNav.openAria", + "打开设置导航", + )} data-testid="settings-floating-nav-button" onClick={() => setFloatingOpen((value) => !value)} > {ActiveIcon ? : null} - {activeItem?.label ?? "设置导航"} + {activeItem?.label ?? + t( + "settings.layout.sidebar.floatingNav.fallbackLabel", + "设置导航", + )} @@ -408,7 +419,12 @@ export function SettingsSidebar({ {item.label} {item.experimental && ( - 实验 + + {t( + "settings.layout.sidebar.experimentalBadge", + "实验", + )} + )} ))} diff --git a/src/components/settings-v2/account/profile/index.test.tsx b/src/components/settings-v2/account/profile/index.test.tsx index 0f769b746..5df1c283d 100644 --- a/src/components/settings-v2/account/profile/index.test.tsx +++ b/src/components/settings-v2/account/profile/index.test.tsx @@ -7,6 +7,7 @@ const { mockGetConfig, mockSaveConfig, mockUseTranslation } = vi.hoisted( mockGetConfig: vi.fn(), mockSaveConfig: vi.fn(), mockUseTranslation: vi.fn((_namespace?: string) => ({ + i18n: { language: "zh-CN" }, t: (key: string, options?: unknown) => { if (typeof options === "string") { return options; @@ -45,6 +46,25 @@ interface Mounted { const mounted: Mounted[] = []; +function createProfileTranslator() { + return (key: string, options?: unknown) => { + if (typeof options === "string") { + return options; + } + + if (options && typeof options === "object") { + const values = options as Record; + const template = + typeof values.defaultValue === "string" ? values.defaultValue : key; + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => + String(values[name] ?? ""), + ); + } + + return key; + }; +} + function renderComponent(): HTMLDivElement { const container = document.createElement("div"); document.body.appendChild(container); @@ -205,6 +225,30 @@ describe("ProfileSettings", () => { expect(mockUseTranslation).toHaveBeenCalledWith("settings"); }); + it("应按当前 locale 格式化资料统计数量", async () => { + const englishT = createProfileTranslator(); + mockUseTranslation.mockImplementation(() => ({ + i18n: { language: "en-US" }, + t: englishT, + })); + mockGetConfig.mockResolvedValueOnce({ + default_provider: "openai", + user_profile: { + nickname: "Ada", + bio: "Builds useful tools.", + email: "ada@example.com", + tags: Array.from({ length: 1000 }, (_, index) => `tag-${index}`), + }, + }); + + const container = renderComponent(); + await waitForLoad(); + + const text = container.textContent ?? ""; + expect(text).toContain("标签:1,000"); + expect(text).not.toContain("标签:1000"); + }); + it("编辑昵称后应保存完整资料", async () => { const container = renderComponent(); await waitForLoad(); diff --git a/src/components/settings-v2/account/profile/index.tsx b/src/components/settings-v2/account/profile/index.tsx index 82da7a9d6..cd269b26e 100644 --- a/src/components/settings-v2/account/profile/index.tsx +++ b/src/components/settings-v2/account/profile/index.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; import { WorkbenchInfoTip } from "@/components/media/WorkbenchInfoTip"; +import { formatNumber as formatLocaleNumber } from "@/i18n/format"; import { cn } from "@/lib/utils"; import { getConfig, @@ -262,7 +263,7 @@ function ProfileFieldCard({ } export function ProfileSettings() { - const { t } = useTranslation("settings"); + const { t, i18n } = useTranslation("settings"); const [config, setConfig] = useState(null); const [profile, setProfile] = useState(DEFAULT_USER_PROFILE); const [loading, setLoading] = useState(true); @@ -477,8 +478,11 @@ export function ProfileSettings() { showMessage( "error", t("settings.profile.message.fileTooLarge", { - size: (file.size / 1024 / 1024).toFixed(2), - max: 5, + size: formatLocaleNumber(file.size / 1024 / 1024, { + locale: i18n.language, + maximumFractionDigits: 2, + }), + max: formatLocaleNumber(5, { locale: i18n.language }), defaultValue: "文件过大 ({{size}}MB),最大支持 {{max}}MB", }), ); @@ -551,6 +555,18 @@ export function ProfileSettings() { .slice(0, 6); const quickTags = tags.slice(0, 3); const extraTagCount = Math.max(tags.length - quickTags.length, 0); + const completionPercentLabel = formatLocaleNumber(completionPercent, { + locale: i18n.language, + }); + const tagsCountLabel = formatLocaleNumber(tags.length, { + locale: i18n.language, + }); + const extraTagCountLabel = formatLocaleNumber(extraTagCount, { + locale: i18n.language, + }); + const profileFieldCountLabel = formatLocaleNumber(profileFields.length, { + locale: i18n.language, + }); const isInitialLoading = loading && !config; if (isInitialLoading) { @@ -632,13 +648,13 @@ export function ProfileSettings() { {t("settings.profile.hero.completion", { - percent: completionPercent, + percent: completionPercentLabel, defaultValue: "完成度:{{percent}}%", })} {t("settings.profile.hero.tags", { - count: tags.length, + count: tagsCountLabel, defaultValue: "标签:{{count}}", })} @@ -721,7 +737,7 @@ export function ProfileSettings() { {extraTagCount > 0 && ( {t("settings.profile.avatar.extraTags", { - count: extraTagCount, + count: extraTagCountLabel, defaultValue: "+{{count}} 个标签", })} @@ -776,7 +792,7 @@ export function ProfileSettings() {
{t("settings.profile.basic.itemCount", { - count: profileFields.length, + count: profileFieldCountLabel, defaultValue: "{{count}} 项", })} @@ -843,7 +859,7 @@ export function ProfileSettings() {
{t("settings.profile.tags.selectedCount", { - count: tags.length, + count: tagsCountLabel, defaultValue: "{{count}} 个已选", })} diff --git a/src/components/settings-v2/account/stats/index.test.tsx b/src/components/settings-v2/account/stats/index.test.tsx index 6191043f5..17175b7d0 100644 --- a/src/components/settings-v2/account/stats/index.test.tsx +++ b/src/components/settings-v2/account/stats/index.test.tsx @@ -55,6 +55,30 @@ interface Mounted { const mounted: Mounted[] = []; +function createStatsTranslator(overrides: Record) { + return (key: string, options?: unknown) => { + const resolveTemplate = () => { + if (overrides[key]) { + return overrides[key]; + } + if (typeof options === "string") { + return options; + } + if (options && typeof options === "object") { + const values = options as Record; + if (typeof values.defaultValue === "string") { + return values.defaultValue; + } + } + return key; + }; + + return resolveTemplate().replace(/\{\{(\w+)\}\}/g, (_, name: string) => + String((options as Record | undefined)?.[name] ?? ""), + ); + }; +} + function renderComponent(): HTMLDivElement { const container = document.createElement("div"); document.body.appendChild(container); @@ -119,6 +143,11 @@ beforeEach(() => { ).IS_REACT_ACT_ENVIRONMENT = true; vi.clearAllMocks(); + const defaultT = createStatsTranslator({}); + mockUseTranslation.mockImplementation(() => ({ + i18n: { language: "zh-CN" }, + t: defaultT, + })); mockGetUsageStats.mockResolvedValue({ total_conversations: 240, @@ -202,6 +231,24 @@ describe("StatsSettings", () => { expect(mockUseTranslation).toHaveBeenCalledWith("settings"); }); + it("应使用 settings namespace 生成热力图日期范围", async () => { + const englishT = createStatsTranslator({ + "settings.stats.heatmap.rangeValue": "{{start}} to {{end}}", + }); + mockUseTranslation.mockImplementation(() => ({ + i18n: { language: "en-US" }, + t: englishT, + })); + + const container = renderComponent(); + await waitForLoad(); + + expect(container.textContent ?? "").toContain("3/1 to 3/3"); + expect(container.textContent ?? "").not.toContain("3/1 - 3/3"); + expect(container.textContent ?? "").toContain("18K Token"); + expect(container.textContent ?? "").not.toContain("18.0K Token"); + }); + it("切换时间范围后应重新拉取对应统计", async () => { const container = renderComponent(); await waitForLoad(); diff --git a/src/components/settings-v2/account/stats/index.tsx b/src/components/settings-v2/account/stats/index.tsx index 30da312b5..ac08380be 100644 --- a/src/components/settings-v2/account/stats/index.tsx +++ b/src/components/settings-v2/account/stats/index.tsx @@ -10,7 +10,10 @@ import type { TFunction } from "i18next"; import { BarChart3, Brain, CalendarDays, Coins, RefreshCw } from "lucide-react"; import { useTranslation } from "react-i18next"; import { WorkbenchInfoTip } from "@/components/media/WorkbenchInfoTip"; -import { formatDate as formatLocaleDate } from "@/i18n/format"; +import { + formatDate as formatLocaleDate, + formatNumber as formatLocaleNumber, +} from "@/i18n/format"; import { cn } from "@/lib/utils"; import { getDailyUsageTrends, @@ -49,10 +52,12 @@ const ACTIVE_TIME_RANGE_BUTTON_CLASS = const PROGRESS_BAR_FILL_CLASS = "bg-[linear-gradient(90deg,#14b8a6_0%,#10b981_100%)]"; -function formatCompactNumber(num: number): string { - if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; - if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; - return num.toString(); +function formatCompactNumber(num: number, locale?: string): string { + return formatLocaleNumber(num, { + locale, + notation: "compact", + maximumFractionDigits: 1, + }); } function formatTime( @@ -120,7 +125,7 @@ function SegmentCard({ minutes, accentClassName, }: SegmentCardProps) { - const { t } = useTranslation("settings"); + const { t, i18n } = useTranslation("settings"); return (
@@ -159,7 +164,7 @@ function SegmentCard({ {t("settings.stats.segment.tokens", { - tokens: formatCompactNumber(tokens), + tokens: formatCompactNumber(tokens, i18n.language), defaultValue: "Token:{{tokens}}", })} @@ -282,7 +287,7 @@ export function StatsSettings() { const peakDayLabel = peakDay ? t("settings.stats.peakDay.value", { date: formatShortDate(peakDay.date, i18n.language), - tokens: formatCompactNumber(peakDay.tokens), + tokens: formatCompactNumber(peakDay.tokens, i18n.language), defaultValue: "{{date}} · {{tokens}} Token", }) : t("settings.stats.empty.noData", "暂无数据"); @@ -297,13 +302,14 @@ export function StatsSettings() { const heatmapDays = dailyUsage.slice(-35); const heatmapRangeLabel = heatmapDays.length > 0 - ? `${formatShortDate( - heatmapDays[0].date, - i18n.language, - )} - ${formatShortDate( - heatmapDays[heatmapDays.length - 1].date, - i18n.language, - )}` + ? t("settings.stats.heatmap.rangeValue", { + start: formatShortDate(heatmapDays[0].date, i18n.language), + end: formatShortDate( + heatmapDays[heatmapDays.length - 1].date, + i18n.language, + ), + defaultValue: "{{start}} - {{end}}", + }) : t("settings.stats.empty.noActivity", "暂无活跃记录"); const heatmapCells: Array = [ ...Array.from({ length: Math.max(35 - heatmapDays.length, 0) }, () => null), @@ -415,13 +421,13 @@ export function StatsSettings() { {t("settings.stats.summary.averageTokens", { - tokens: formatCompactNumber(averageDailyTokens), + tokens: formatCompactNumber(averageDailyTokens, i18n.language), defaultValue: "日均 {{tokens}} Token", })} {t("settings.stats.summary.rangeTokens", { - tokens: formatCompactNumber(totalRangeTokens), + tokens: formatCompactNumber(totalRangeTokens, i18n.language), defaultValue: "当前区间 {{tokens}} Token", })} @@ -604,6 +610,7 @@ export function StatsSettings() { conversations: topModel?.conversations || 0, tokens: formatCompactNumber( topModel?.tokens || 0, + i18n.language, ), defaultValue: "{{conversations}} 次对话 · {{tokens}} Token", @@ -660,7 +667,10 @@ export function StatsSettings() {

{t("settings.stats.models.itemMeta", { conversations: model.conversations, - tokens: formatCompactNumber(model.tokens), + tokens: formatCompactNumber( + model.tokens, + i18n.language, + ), defaultValue: "{{conversations}} 次对话 · {{tokens}} Token", })} @@ -722,7 +732,7 @@ export function StatsSettings() {

- {formatCompactNumber(totalRangeTokens)} Token + {formatCompactNumber(totalRangeTokens, i18n.language)} Token {t("settings.stats.trends.conversations", { @@ -750,7 +760,10 @@ export function StatsSettings() { {t("settings.stats.trends.average", { - tokens: formatCompactNumber(averageDailyTokens), + tokens: formatCompactNumber( + averageDailyTokens, + i18n.language, + ), defaultValue: "区间均值:{{tokens}}", })} @@ -766,7 +779,7 @@ export function StatsSettings() { bottom: `${(index / (chartGuideValues.length - 1)) * 100}%`, }} > - {formatCompactNumber(value)} + {formatCompactNumber(value, i18n.language)}
))}
@@ -805,7 +818,11 @@ export function StatsSettings() { style={{ height: `${Math.max(height, 6)}%` }} >
- {formatCompactNumber(day.tokens)} Token + {formatCompactNumber( + day.tokens, + i18n.language, + )}{" "} + Token
@@ -899,14 +916,17 @@ export function StatsSettings() { )} title={ day - ? `${day.date}: ${formatCompactNumber(day.tokens)} Token` + ? `${day.date}: ${formatCompactNumber( + day.tokens, + i18n.language, + )} Token` : "" } > {day ? (
{formatShortDate(day.date, i18n.language)} ·{" "} - {formatCompactNumber(day.tokens)} + {formatCompactNumber(day.tokens, i18n.language)}
) : null} diff --git a/src/components/settings-v2/account/user-center-session/index.test.tsx b/src/components/settings-v2/account/user-center-session/index.test.tsx index 13dc22000..17855463b 100644 --- a/src/components/settings-v2/account/user-center-session/index.test.tsx +++ b/src/components/settings-v2/account/user-center-session/index.test.tsx @@ -24,13 +24,14 @@ const { return key; }); - return { - mockUseOemCloudAccess: vi.fn(), - mockFormatOemCloudDateTime: vi.fn((value?: string) => `fmt:${value ?? ""}`), - mockUseTranslation: vi.fn((_namespace?: string) => ({ - t: mockTranslate, - })), - }; + return { + mockUseOemCloudAccess: vi.fn(), + mockFormatOemCloudDateTime: vi.fn((value?: string) => `fmt:${value ?? ""}`), + mockUseTranslation: vi.fn((_namespace?: string) => ({ + i18n: { language: "zh-CN" }, + t: mockTranslate, + })), + }; }); vi.mock("@/hooks/useOemCloudAccess", () => ({ @@ -279,9 +280,13 @@ describe("UserCenterSessionSettings", () => { const { container } = renderPage(); const text = container.textContent ?? ""; + const expectedExpiry = new Intl.DateTimeFormat("zh-CN", { + dateStyle: "medium", + timeStyle: "short", + }).format(Date.parse("2026-03-25T08:00:00.000Z")); expect(text).toContain("Demo Operator"); - expect(text).toContain("fmt:2026-03-25T08:00:00.000Z"); + expect(text).toContain(expectedExpiry); expect(text).toContain("2 项技能 / 1 个入口"); expect(text).toContain("状态:已登录"); expect(text).toContain("默认服务:Lime Hub 主服务 · gpt-5.2-pro"); @@ -307,6 +312,68 @@ describe("UserCenterSessionSettings", () => { expect(handleLogout).toHaveBeenCalledTimes(1); }); + it("应按当前 locale 格式化会话时间与能力数量", () => { + mockUseTranslation.mockImplementationOnce((_namespace?: string) => ({ + i18n: { language: "en-US" }, + t: (key: string, options?: unknown) => { + if (typeof options === "string") { + return options; + } + + if (options && typeof options === "object") { + const values = options as Record; + const template = + typeof values.defaultValue === "string" ? values.defaultValue : key; + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => + String(values[name] ?? ""), + ); + } + + return key; + }, + })); + mockUseOemCloudAccess.mockReturnValue( + createAccessState({ + session: { + tenant: { id: "tenant-0001" }, + user: { + id: "user-001", + email: "operator@example.com", + displayName: "Demo Operator", + }, + session: { + id: "session-001", + expiresAt: "2026-03-25T08:00:00.000Z", + }, + }, + bootstrap: { + serviceSkillCatalog: { + items: Array.from({ length: 1000 }, (_, index) => ({ + id: `skill-${index}`, + })), + }, + sceneCatalog: Array.from({ length: 1234 }, (_, index) => ({ + id: `scene-${index}`, + })), + features: { + profileEditable: true, + }, + }, + }), + ); + + const { container } = renderPage(); + const expectedExpiry = new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + }).format(Date.parse("2026-03-25T08:00:00.000Z")); + const text = container.textContent ?? ""; + + expect(text).toContain(expectedExpiry); + expect(text).toContain("1,000 项技能 / 1,234 个入口"); + expect(text).not.toContain("1000 项技能 / 1234 个入口"); + }); + it("应把账户总览和登录结果说明收进 tips", async () => { renderPage(); diff --git a/src/components/settings-v2/account/user-center-session/index.tsx b/src/components/settings-v2/account/user-center-session/index.tsx index 9c536c95c..610fb5e16 100644 --- a/src/components/settings-v2/account/user-center-session/index.tsx +++ b/src/components/settings-v2/account/user-center-session/index.tsx @@ -14,10 +14,11 @@ import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { WorkbenchInfoTip } from "@/components/media/WorkbenchInfoTip"; import { ProviderIcon } from "@/icons/providers"; +import { useOemCloudAccess } from "@/hooks/useOemCloudAccess"; import { - formatOemCloudDateTime, - useOemCloudAccess, -} from "@/hooks/useOemCloudAccess"; + formatDate as formatLocaleDate, + formatNumber as formatLocaleNumber, +} from "@/i18n/format"; import { cn } from "@/lib/utils"; const SURFACE_CLASS_NAME = @@ -103,8 +104,29 @@ function buildAccountInitials(value?: string) { return normalized.slice(0, 2).toUpperCase(); } +function formatSessionDateTime( + value: string | undefined, + locale: string, + unknownLabel: string, +) { + if (!value) { + return unknownLabel; + } + + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + return value; + } + + return formatLocaleDate(timestamp, { + locale, + dateStyle: "medium", + timeStyle: "short", + }); +} + export function UserCenterSessionSettings() { - const { t } = useTranslation("settings"); + const { t, i18n } = useTranslation("settings"); const [showAlternativeMethods, setShowAlternativeMethods] = useState(false); const { runtime, @@ -160,8 +182,13 @@ export function UserCenterSessionSettings() { ); const syncedCapabilitiesSummary = session ? t("settings.userCenterSession.account.syncedCapabilities", { - skills: resolveServiceSkillCount(bootstrap?.serviceSkillCatalog), - scenes: bootstrap?.sceneCatalog?.length || 0, + skills: formatLocaleNumber( + resolveServiceSkillCount(bootstrap?.serviceSkillCatalog), + { locale: i18n.language }, + ), + scenes: formatLocaleNumber(bootstrap?.sceneCatalog?.length || 0, { + locale: i18n.language, + }), defaultValue: "{{skills}} 项技能 / {{scenes}} 个入口", }) : t("settings.userCenterSession.account.syncedPending", "登录后自动同步"); @@ -426,7 +453,14 @@ export function UserCenterSessionSettings() { "settings.userCenterSession.value.expiresAt.label", "会话有效期", )} - value={formatOemCloudDateTime(session.session.expiresAt)} + value={formatSessionDateTime( + session.session.expiresAt, + i18n.language, + t( + "settings.userCenterSession.value.expiresAt.unknown", + "未知", + ), + )} hint={t( "settings.userCenterSession.value.expiresAt.hint", "到期后需要重新登录。", diff --git a/src/components/settings-v2/agent/providers/index.test.tsx b/src/components/settings-v2/agent/providers/index.test.tsx index 95142ec9e..2da0620f5 100644 --- a/src/components/settings-v2/agent/providers/index.test.tsx +++ b/src/components/settings-v2/agent/providers/index.test.tsx @@ -107,7 +107,7 @@ vi.mock("@/lib/providerDataEvents", () => ({ mockSubscribeProviderDataChanged(...args), })); -vi.mock("@/components/openclaw/openUrl", () => ({ +vi.mock("@/lib/openUrl", () => ({ openUrl: (...args: unknown[]) => mockOpenUrl(...args), })); diff --git a/src/components/settings-v2/agent/providers/index.tsx b/src/components/settings-v2/agent/providers/index.tsx index f65b38670..c68f6fda0 100644 --- a/src/components/settings-v2/agent/providers/index.tsx +++ b/src/components/settings-v2/agent/providers/index.tsx @@ -13,7 +13,7 @@ import { import { useTranslation } from "react-i18next"; import { WorkbenchInfoTip } from "@/components/media/WorkbenchInfoTip"; import { ApiKeyProviderSection } from "@/components/api-key-provider"; -import { openUrl } from "@/components/openclaw/openUrl"; +import { openUrl } from "@/lib/openUrl"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useOemCloudAccess } from "@/hooks/useOemCloudAccess"; import { formatDate } from "@/i18n/format"; diff --git a/src/components/settings-v2/general/appearance/index.test.tsx b/src/components/settings-v2/general/appearance/index.test.tsx index 1446a8c5b..b9423cc18 100644 --- a/src/components/settings-v2/general/appearance/index.test.tsx +++ b/src/components/settings-v2/general/appearance/index.test.tsx @@ -204,7 +204,6 @@ describe("AppearanceSettings", () => { expect(text).not.toContain("持续流程"); expect(text).not.toContain("消息渠道"); expect(text).toContain("插件中心"); - expect(text).toContain("OpenClaw"); expect(text).toContain("桌宠"); expect(text).toContain("推荐行为"); expect(text).toContain("推荐自动附带选中内容"); @@ -213,6 +212,32 @@ describe("AppearanceSettings", () => { expect(buttonTexts).not.toContain("设置"); }); + it("应通过 settings namespace 渲染英文外观 chrome", async () => { + await changeLimeLocale("en-US"); + mockGetConfig.mockResolvedValue({ + ...createMockConfig(), + language: "en-US", + }); + + const { container } = await renderPage(); + const text = container.textContent ?? ""; + + expect(text).toContain("Appearance"); + expect(text).toContain("Theme: System"); + expect(text).toContain("Palette: Graphite Green"); + expect(text).toContain("Language: English"); + expect(text).toContain("Sound cues: On"); + expect(text).toContain("Base Appearance"); + expect(text).toContain("Theme Mode"); + expect(text).toContain("Color Palette"); + expect(text).toContain("Random"); + expect(text).toContain("Setup & Recovery"); + expect(text).toContain("Optional System Entries"); + expect(text).toContain("Plugin Center"); + expect(text).toContain("Recommendation Behavior"); + expect(text).not.toContain("管理主题、语言"); + }); + it("切换界面语言时应写入 BCP 47 locale 并同步兼容 Patch 语言", async () => { const { container } = await renderPage(); const englishButton = Array.from(container.querySelectorAll("button")).find( diff --git a/src/components/settings-v2/general/appearance/index.tsx b/src/components/settings-v2/general/appearance/index.tsx index 5dae62724..18dae842f 100644 --- a/src/components/settings-v2/general/appearance/index.tsx +++ b/src/components/settings-v2/general/appearance/index.tsx @@ -21,7 +21,6 @@ import { Sparkles, Sun, Volume2, - Waypoints, Bot, Shuffle, type LucideIcon, @@ -87,6 +86,7 @@ interface SurfacePanelProps { iconClassName?: string; title: string; description: string; + tipAriaLabel?: string; aside?: ReactNode; children: ReactNode; } @@ -98,30 +98,22 @@ interface HiddenSystemEntryOption { icon: LucideIcon; } -const THEME_OPTIONS: ThemeOption[] = [ - ...LIME_THEME_MODE_OPTIONS.map((option) => ({ - ...option, - icon: option.id === "light" ? Sun : option.id === "dark" ? Moon : Monitor, - })), -]; +const THEME_OPTION_ICONS: Record = { + dark: Moon, + light: Sun, + system: Monitor, +}; -const HIDDEN_SYSTEM_ENTRY_OPTIONS: HiddenSystemEntryOption[] = [ +const HIDDEN_SYSTEM_ENTRY_BASE_OPTIONS: Pick< + HiddenSystemEntryOption, + "id" | "icon" +>[] = [ { id: "plugins", - label: "插件中心", - description: "在系统区显示插件安装、管理与扩展入口。", icon: Puzzle, }, - { - id: "openclaw", - label: "OpenClaw", - description: "在系统区显示 OpenClaw 兼容运行入口。", - icon: Waypoints, - }, { id: "companion", - label: "桌宠", - description: "在系统区显示桌宠管理与连接诊断入口。", icon: Bot, }, ]; @@ -164,6 +156,7 @@ function SurfacePanel({ iconClassName, title, description, + tipAriaLabel, aside, children, }: SurfacePanelProps) { @@ -175,7 +168,7 @@ function SurfacePanel({ {title} @@ -202,14 +195,6 @@ function LoadingSkeleton() { ); } -function resolveThemeLabel(theme: LimeThemeMode) { - return THEME_OPTIONS.find((option) => option.id === theme)?.label || "系统"; -} - -function resolveColorSchemeLabel(colorSchemeId: LimeColorSchemeId) { - return getLimeColorScheme(colorSchemeId).label; -} - export function AppearanceSettings() { const { t } = useTranslation("settings"); const [loading, setLoading] = useState(true); @@ -243,12 +228,17 @@ export function AppearanceSettings() { ); } catch (err) { console.error("加载外观设置失败:", err); - setError("加载外观设置失败,请稍后重试。"); + setError( + t( + "settings.appearance.error.load", + "加载外观设置失败,请稍后重试。", + ), + ); setConfig(null); } finally { setLoading(false); } - }, []); + }, [t]); useEffect(() => { const refreshAppearance = () => { @@ -300,14 +290,72 @@ export function AppearanceSettings() { }; }, [loadConfig]); + const themeOptions = useMemo( + () => + LIME_THEME_MODE_OPTIONS.map((option) => ({ + id: option.id, + icon: THEME_OPTION_ICONS[option.id], + label: t( + `settings.appearance.theme.options.${option.id}.label`, + option.label, + ), + description: t( + `settings.appearance.theme.options.${option.id}.description`, + option.description, + ), + })), + [t], + ); + + const colorSchemeOptions = useMemo( + () => + LIME_COLOR_SCHEMES.map((option) => ({ + ...option, + label: t( + `settings.appearance.colorScheme.options.${option.id}.label`, + option.label, + ), + description: t( + `settings.appearance.colorScheme.options.${option.id}.description`, + option.description, + ), + })), + [t], + ); + + const hiddenSystemEntryOptions = useMemo( + () => + HIDDEN_SYSTEM_ENTRY_BASE_OPTIONS.map((option) => ({ + ...option, + label: t( + `settings.appearance.systemEntries.options.${option.id}.label`, + option.id, + ), + description: t( + `settings.appearance.systemEntries.options.${option.id}.description`, + "", + ), + })), + [t], + ); + + const currentThemeLabel = + themeOptions.find((option) => option.id === theme)?.label ?? + t("settings.appearance.theme.options.system.label", "跟随系统"); + const currentColorSchemeLabel = + colorSchemeOptions.find((option) => option.id === colorSchemeId)?.label ?? + getLimeColorScheme(colorSchemeId).label; + const workspaceSummary = useMemo( () => ({ - themeLabel: resolveThemeLabel(theme), - colorSchemeLabel: resolveColorSchemeLabel(colorSchemeId), + themeLabel: currentThemeLabel, + colorSchemeLabel: currentColorSchemeLabel, languageLabel: resolveLocaleOptionLabel(language), - soundsLabel: soundEnabled ? "已开启" : "已关闭", + soundsLabel: soundEnabled + ? t("settings.appearance.status.enabled", "已开启") + : t("settings.appearance.status.disabled", "已关闭"), }), - [colorSchemeId, language, soundEnabled, theme], + [currentColorSchemeLabel, currentThemeLabel, language, soundEnabled, t], ); const enabledNavigationItems = useMemo( @@ -377,10 +425,15 @@ export function AppearanceSettings() { setLanguageState(previousLanguage); setI18nLanguage(toLegacyPatchLanguage(previousLanguage)); await changeLimeLocale(previousLanguage); - setError("保存语言设置失败,请重试。"); + setError( + t( + "settings.appearance.error.saveLanguage", + "保存语言设置失败,请重试。", + ), + ); } }, - [config, language, setI18nLanguage], + [config, language, setI18nLanguage, t], ); const handleSoundToggle = useCallback( @@ -420,10 +473,15 @@ export function AppearanceSettings() { console.error("保存推荐上下文设置失败:", err); setAppendSelectedTextToRecommendation(previousValue); setConfig(previousConfig); - setError("保存聊天外观设置失败,请重试。"); + setError( + t( + "settings.appearance.error.saveChatAppearance", + "保存聊天外观设置失败,请重试。", + ), + ); } }, - [appendSelectedTextToRecommendation, config], + [appendSelectedTextToRecommendation, config, t], ); const handleHiddenSystemEntryToggle = useCallback( @@ -457,10 +515,15 @@ export function AppearanceSettings() { } catch (err) { console.error("保存系统入口设置失败:", err); setConfig(previousConfig); - setError("保存系统入口设置失败,请重试。"); + setError( + t( + "settings.appearance.error.saveSystemEntries", + "保存系统入口设置失败,请重试。", + ), + ); } }, - [config, enabledNavigationItems], + [config, enabledNavigationItems, t], ); const handleResetOnboarding = useCallback(() => { @@ -482,7 +545,7 @@ export function AppearanceSettings() { onClick={() => void loadConfig()} className="rounded-full border border-rose-200 bg-white px-3 py-1.5 text-xs font-medium text-rose-700 transition hover:border-rose-300 hover:bg-rose-50" > - 重新加载 + {t("settings.appearance.action.reload", "重新加载")} ) : null} @@ -492,28 +555,46 @@ export function AppearanceSettings() {

- 外观 + {t("settings.appearance.hero.title", "外观")}

- 管理主题、语言、提示音效、推荐行为和底部入口。 + {t( + "settings.appearance.hero.description", + "管理主题、语言、提示音效、推荐行为和底部入口。", + )}

- 主题:{workspaceSummary.themeLabel} + {t("settings.appearance.summary.theme", { + theme: workspaceSummary.themeLabel, + defaultValue: "主题:{{theme}}", + })} - 配色:{workspaceSummary.colorSchemeLabel} + {t("settings.appearance.summary.colorScheme", { + colorScheme: workspaceSummary.colorSchemeLabel, + defaultValue: "配色:{{colorScheme}}", + })} - 语言:{workspaceSummary.languageLabel} + {t("settings.appearance.summary.language", { + language: workspaceSummary.languageLabel, + defaultValue: "语言:{{language}}", + })} - 提示音效:{workspaceSummary.soundsLabel} + {t("settings.appearance.summary.sounds", { + status: workspaceSummary.soundsLabel, + defaultValue: "提示音效:{{status}}", + })}
@@ -532,8 +616,15 @@ export function AppearanceSettings() {
@@ -541,22 +632,31 @@ export function AppearanceSettings() {

- 主题模式 + {t("settings.appearance.theme.title", "主题模式")}

- 当前:{resolveThemeLabel(theme)} + {t("settings.appearance.current.theme", { + theme: currentThemeLabel, + defaultValue: "当前:{{theme}}", + })}
- {THEME_OPTIONS.map((option) => { + {themeOptions.map((option) => { const active = theme === option.id; return (
- 当前:{resolveColorSchemeLabel(colorSchemeId)} + {t("settings.appearance.current.colorScheme", { + colorScheme: currentColorSchemeLabel, + defaultValue: "当前:{{colorScheme}}", + })}
@@ -626,12 +735,17 @@ export function AppearanceSettings() { -

随机

+

+ {t("settings.appearance.colorScheme.random.title", "随机")} +

- 每次点击随机切换一个配色。 + {t( + "settings.appearance.colorScheme.random.description", + "每次点击随机切换一个配色。", + )}

- {LIME_COLOR_SCHEMES.map((option) => { + {colorSchemeOptions.map((option) => { const active = colorSchemeId === option.id; return ( @@ -823,18 +986,28 @@ export function AppearanceSettings() { - 已开启 {hiddenSystemEntryCount} /{" "} - {CONFIGURABLE_FOOTER_SIDEBAR_NAV_ITEMS.length} + {t("settings.appearance.systemEntries.summary", { + count: hiddenSystemEntryCount, + total: CONFIGURABLE_FOOTER_SIDEBAR_NAV_ITEMS.length, + defaultValue: "已开启 {{count}} / {{total}}", + })} } >
- {HIDDEN_SYSTEM_ENTRY_OPTIONS.map((option) => { + {hiddenSystemEntryOptions.map((option) => { const checked = enabledNavigationItems.includes(option.id); return (
@@ -869,7 +1048,15 @@ export function AppearanceSettings() { : CONTEXT_STATUS_PILL_CLASS } > - {checked ? "已显示" : "已隐藏"} + {checked + ? t( + "settings.appearance.systemEntries.status.visible", + "已显示", + ) + : t( + "settings.appearance.systemEntries.status.hidden", + "已隐藏", + )}
@@ -893,8 +1086,15 @@ export function AppearanceSettings() {
@@ -905,11 +1105,20 @@ export function AppearanceSettings() {

- 推荐行为 + {t( + "settings.appearance.recommendation.innerTitle", + "推荐行为", + )}

@@ -921,11 +1130,20 @@ export function AppearanceSettings() {

- 推荐自动附带选中内容 + {t( + "settings.appearance.recommendation.appendSelected.title", + "推荐自动附带选中内容", + )}

@@ -938,14 +1156,19 @@ export function AppearanceSettings() { : CONTEXT_STATUS_PILL_CLASS } > - {appendSelectedTextToRecommendation ? "已开启" : "已关闭"} + {appendSelectedTextToRecommendation + ? t("settings.appearance.status.enabled", "已开启") + : t("settings.appearance.status.disabled", "已关闭")} { - void handleRecommendationSelectionToggle(checked); - }} - aria-label="切换推荐自动附带选中内容" + void handleRecommendationSelectionToggle(checked); + }} + aria-label={t( + "settings.appearance.recommendation.appendSelected.toggleAria", + "切换推荐自动附带选中内容", + )} disabled={!config} />
diff --git a/src/components/settings-v2/general/hotkeys/index.test.tsx b/src/components/settings-v2/general/hotkeys/index.test.tsx index 38cfa2aed..21be83a7d 100644 --- a/src/components/settings-v2/general/hotkeys/index.test.tsx +++ b/src/components/settings-v2/general/hotkeys/index.test.tsx @@ -311,6 +311,8 @@ describe("HotkeysSettings", () => { expect(text).toContain("功能未启用"); expect(text).toContain("未注册到系统"); expect(text).toContain("未绑定翻译指令"); + expect(text).toContain("未设置"); + expect(text).not.toContain("__lime_unset_shortcut__"); }); it("应把首屏和统计说明收进 tips", async () => { diff --git a/src/components/settings-v2/general/hotkeys/index.tsx b/src/components/settings-v2/general/hotkeys/index.tsx index e8188db04..67adaed3b 100644 --- a/src/components/settings-v2/general/hotkeys/index.tsx +++ b/src/components/settings-v2/general/hotkeys/index.tsx @@ -35,7 +35,10 @@ import { getHotkeyRuntimeStatus, type HotkeyRuntimeStatus, } from "@/lib/api/hotkeys"; -import { resolveHotkeyPlatform } from "@/lib/hotkeys/platform"; +import { + resolveHotkeyPlatform, + UNSET_SHORTCUT_TOKEN, +} from "@/lib/hotkeys/platform"; import { buildAuditedHotkeyCatalog, createHotkeyCatalogCopy, @@ -140,12 +143,12 @@ function HotkeyRow({ key={`${item.id}-${key}`} className={cn( "inline-flex min-h-9 min-w-9 items-center justify-center rounded-[14px] border px-3 text-sm font-medium shadow-sm", - key === "未设置" + key === UNSET_SHORTCUT_TOKEN ? "border-amber-200 bg-amber-50 text-amber-700" : "border-slate-200 bg-white text-slate-700", )} > - {key === "未设置" ? copy.unsetKey : key} + {key === UNSET_SHORTCUT_TOKEN ? copy.unsetKey : key} ))}
diff --git a/src/components/settings-v2/home/index.test.tsx b/src/components/settings-v2/home/index.test.tsx index b3fdd010e..479f5e45d 100644 --- a/src/components/settings-v2/home/index.test.tsx +++ b/src/components/settings-v2/home/index.test.tsx @@ -43,6 +43,30 @@ interface RenderResult { const mounted: RenderResult[] = []; +function createTestTranslator(overrides: Record) { + return (key: string, options?: unknown) => { + const resolveTemplate = () => { + if (overrides[key]) { + return overrides[key]; + } + if (typeof options === "string") { + return options; + } + if (options && typeof options === "object") { + const values = options as Record; + if (typeof values.defaultValue === "string") { + return values.defaultValue; + } + } + return key; + }; + + return resolveTemplate().replace(/\{\{(\w+)\}\}/g, (_, name: string) => + String((options as Record | undefined)?.[name] ?? ""), + ); + }; +} + function renderPage( onTabChange = vi.fn(), onTabPrefetch?: (tab: SettingsTabs) => void, @@ -182,6 +206,23 @@ describe("SettingsHomePage", () => { expect(text).not.toContain("权限、稳定性与运行开关"); }); + it("应使用 settings namespace 生成分组说明 aria", () => { + mockUseTranslation.mockImplementationOnce(() => ({ + t: createTestTranslator({ + "settings.home.group.tipAria": "{{title}} help", + }), + })); + + const { container } = renderPage(); + + expect( + container.querySelector("button[aria-label='通用 help']"), + ).toBeInstanceOf(HTMLButtonElement); + expect( + container.querySelector("button[aria-label='通用说明']"), + ).toBeNull(); + }); + it("点击常用入口时应触发 tab 切换", () => { const onTabChange = vi.fn(); const { container } = renderPage(onTabChange); diff --git a/src/components/settings-v2/home/index.tsx b/src/components/settings-v2/home/index.tsx index 38e82f856..8981b9f27 100644 --- a/src/components/settings-v2/home/index.tsx +++ b/src/components/settings-v2/home/index.tsx @@ -486,7 +486,10 @@ export function SettingsHomePage({
diff --git a/src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx b/src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx index 9fa403362..4ea450867 100644 --- a/src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx +++ b/src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx @@ -27,11 +27,10 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { - LEGACY_SERVICE_SKILL_EXECUTION_COMPAT_LABEL, - LEGACY_SERVICE_SKILL_EXECUTION_COMPAT_NOTE, type AutomationServiceSkillContext, } from "./serviceSkillContext"; import { + defaultAutomationPresentationCopy, deliveryStatusVariant, deliveryToneClass, isLegacyBrowserAutomation, @@ -45,8 +44,12 @@ import { runInfoToneClass, runStatusVariant, statusVariant, + type AutomationPresentationCopy, } from "./automationPresentation"; -import { resolveAgentTurnAutomationAccessMode } from "./automationAccessMode"; +import { + automationAccessModeLabelWithCopy, + resolveAgentTurnAutomationAccessMode, +} from "./automationAccessMode"; type SettingsTranslate = TFunction<"settings">; @@ -69,6 +72,230 @@ function detailsText( }, translated); } +function buildDetailsPresentationCopy( + t: SettingsTranslate, +): AutomationPresentationCopy { + return { + ...defaultAutomationPresentationCopy, + legacyBrowserAutomationNotice: detailsText( + t, + "settings.automation.details.legacy.message", + "浏览器自动化已下线,系统不会再自动启动 Chrome。请删除旧流程,并改建为 Agent 对话持续流程。", + ), + legacyBrowserAutomationStatus: detailsText( + t, + "settings.automation.details.status.offline", + "已下线", + ), + scheduleHours: (count) => + detailsText( + t, + "settings.automation.details.schedule.hours", + "每 {{count}} 小时", + { count }, + ), + scheduleMinutes: (count) => + detailsText( + t, + "settings.automation.details.schedule.minutes", + "每 {{count}} 分钟", + { count }, + ), + scheduleSeconds: (count) => + detailsText( + t, + "settings.automation.details.schedule.seconds", + "每 {{count}} 秒", + { count }, + ), + scheduleCron: (expr) => + detailsText( + t, + "settings.automation.details.schedule.cron", + "Cron: {{expr}}", + { expr }, + ), + scheduleAt: (time) => + detailsText( + t, + "settings.automation.details.schedule.at", + "一次性: {{time}}", + { time }, + ), + payloadBrowserSession: detailsText( + t, + "settings.automation.details.payload.browserSession", + "浏览器自动化", + ), + payloadAgentTurn: detailsText( + t, + "settings.automation.details.payload.agentTurn", + "Agent 对话", + ), + legacyPayloadProfile: (profile) => + detailsText( + t, + "settings.automation.details.legacy.payload.profile", + "资料: {{profile}}", + { profile }, + ), + legacyPayloadEnvironment: (environment) => + detailsText( + t, + "settings.automation.details.legacy.payload.environment", + "环境预设: {{environment}}", + { environment }, + ), + legacyPayloadUrl: (url) => + detailsText( + t, + "settings.automation.details.legacy.payload.url", + "启动地址: {{url}}", + { url }, + ), + legacyPayloadTargetId: (targetId) => + detailsText( + t, + "settings.automation.details.legacy.payload.targetId", + "Target ID: {{targetId}}", + { targetId }, + ), + legacyPayloadWindow: (status) => + detailsText( + t, + "settings.automation.details.legacy.payload.window", + "调试窗口: {{status}}", + { status }, + ), + legacyPayloadWindowOpen: detailsText( + t, + "settings.automation.details.legacy.payload.windowOpen", + "打开", + ), + legacyPayloadWindowClosed: detailsText( + t, + "settings.automation.details.legacy.payload.windowClosed", + "关闭", + ), + legacyPayloadStreamMode: (streamMode) => + detailsText( + t, + "settings.automation.details.legacy.payload.streamMode", + "流模式: {{streamMode}}", + { streamMode }, + ), + statusQueued: detailsText( + t, + "settings.automation.details.status.queued", + "排队中", + ), + statusSuccess: detailsText( + t, + "settings.automation.details.status.success", + "成功", + ), + statusRunning: detailsText( + t, + "settings.automation.details.status.running", + "运行中", + ), + statusWaitingForHuman: detailsText( + t, + "settings.automation.details.status.waitingForHuman", + "等待人工处理", + ), + statusHumanControlling: detailsText( + t, + "settings.automation.details.status.humanControlling", + "人工接管中", + ), + statusAgentResuming: detailsText( + t, + "settings.automation.details.status.agentResuming", + "恢复给 Agent", + ), + statusError: detailsText( + t, + "settings.automation.details.status.error", + "失败", + ), + statusTimeout: detailsText( + t, + "settings.automation.details.status.timeout", + "超时", + ), + statusPending: detailsText( + t, + "settings.automation.details.status.pending", + "待执行", + ), + deliveryModeAnnounce: detailsText( + t, + "settings.automation.details.delivery.mode.announce", + "运行完成后投递", + ), + deliveryModeNone: detailsText( + t, + "settings.automation.details.delivery.mode.none", + "未启用", + ), + deliveryChannelLocalFile: detailsText( + t, + "settings.automation.details.delivery.channel.localFile", + "本地文件", + ), + outputSchemaJson: detailsText( + t, + "settings.automation.details.delivery.schema.json", + "JSON 对象", + ), + outputSchemaTable: detailsText( + t, + "settings.automation.details.delivery.schema.table", + "表格", + ), + outputSchemaCsv: detailsText( + t, + "settings.automation.details.delivery.schema.csv", + "CSV", + ), + outputSchemaLinks: detailsText( + t, + "settings.automation.details.delivery.schema.links", + "链接列表", + ), + outputSchemaText: detailsText( + t, + "settings.automation.details.delivery.schema.text", + "文本摘要", + ), + outputFormatJson: detailsText( + t, + "settings.automation.details.delivery.format.json", + "JSON 编码", + ), + outputFormatText: detailsText( + t, + "settings.automation.details.delivery.format.text", + "文本编码", + ), + serviceSkillTaskLine: (title) => + detailsText( + t, + "settings.automation.details.serviceSkill.taskLine", + "技能:{{title}}", + { title }, + ), + serviceSkillMoreItems: (count) => + detailsText( + t, + "settings.automation.details.serviceSkill.moreItems", + " 等 {{count}} 项", + { count }, + ), + }; +} + function formatDetailsTime(value?: string | null, locale?: string): string { if (!value) { return "-"; @@ -169,27 +396,29 @@ function detailsAccessModeLabel( return "-"; } - switch (resolveAgentTurnAutomationAccessMode(payload)) { - case "read-only": - return detailsText( + return automationAccessModeLabelWithCopy( + resolveAgentTurnAutomationAccessMode(payload), + { + readOnly: detailsText( t, "settings.automation.details.accessMode.readOnly", "只读", - ); - case "current": - return detailsText( + ), + current: detailsText( t, "settings.automation.details.accessMode.current", "按需确认", - ); - case "full-access": - default: - return detailsText( + ), + fullAccess: detailsText( t, "settings.automation.details.accessMode.fullAccess", "完全访问", - ); - } + ), + policyReadOnly: "", + policyCurrent: "", + policyFullAccess: "", + }, + ); } function detailsScheduleLabel( @@ -431,11 +660,8 @@ function detailsServiceSkillTaskLine( t: SettingsTranslate, serviceSkillContext: AutomationServiceSkillContext, ): string { - return detailsText( - t, - "settings.automation.details.serviceSkill.taskLine", - "技能:{{title}}", - { title: serviceSkillContext.title }, + return buildDetailsPresentationCopy(t).serviceSkillTaskLine( + serviceSkillContext.title, ); } @@ -448,14 +674,10 @@ function detailsServiceSkillSlotPreview( .slice(0, limit) .map((item) => `${item.label}: ${item.value}`); if (preview.length > 0) { + const copy = buildDetailsPresentationCopy(t); const suffix = serviceSkillContext.slotSummary.length > limit - ? detailsText( - t, - "settings.automation.details.serviceSkill.moreItems", - " 等 {{count}} 项", - { count: serviceSkillContext.slotSummary.length }, - ) + ? copy.serviceSkillMoreItems(serviceSkillContext.slotSummary.length) : ""; return `${preview.join(" · ")}${suffix}`; } @@ -525,6 +747,17 @@ export function AutomationJobDetailsDialog({ onRefreshHistory, }: AutomationJobDetailsDialogProps) { const { i18n, t } = useTranslation("settings"); + const presentationCopy = buildDetailsPresentationCopy(t); + const serviceSkillExecutionCompatLabel = detailsText( + t, + "settings.automation.tasks.list.badge.serviceSkillLegacyCompat", + "旧目录兼容", + ); + const serviceSkillExecutionCompatNote = detailsText( + t, + "settings.automation.details.serviceSkill.executionCompatNote", + "沿用旧目录兼容标记,实际仍在客户端执行。", + ); const followupDestinations = sceneAppRunDetailView ? buildSceneAppExecutionFollowupDestinations(sceneAppRunDetailView) : []; @@ -808,7 +1041,7 @@ export function AutomationJobDetailsDialog({ {serviceSkillContext.executionLocationLegacyCompat ? ( - {LEGACY_SERVICE_SKILL_EXECUTION_COMPAT_LABEL} + {serviceSkillExecutionCompatLabel} ) : null}
@@ -849,7 +1082,7 @@ export function AutomationJobDetailsDialog({
{serviceSkillContext.executionLocationLegacyCompat ? (
- {LEGACY_SERVICE_SKILL_EXECUTION_COMPAT_NOTE} + {serviceSkillExecutionCompatNote}
) : null} {serviceSkillContext.slotSummary.length ? ( @@ -1441,7 +1674,11 @@ export function AutomationJobDetailsDialog({ const infoMessage = resolveRunInfoMessage(run); const delivery = resolveRunDelivery(run); const runServiceSkillContext = - resolveRunServiceSkillContext(run, serviceSkillContext); + resolveRunServiceSkillContext( + run, + serviceSkillContext, + presentationCopy.serviceSkillContextCopy, + ); const runServiceSkillTaskLine = runServiceSkillContext ? detailsServiceSkillTaskLine(t, runServiceSkillContext) : null; @@ -1520,7 +1757,7 @@ export function AutomationJobDetailsDialog({ {runServiceSkillContext.executionLocationLegacyCompat ? ( { - LEGACY_SERVICE_SKILL_EXECUTION_COMPAT_LABEL + serviceSkillExecutionCompatLabel } ) : null} @@ -1532,7 +1769,7 @@ export function AutomationJobDetailsDialog({ ) : null} {runServiceSkillContext.executionLocationLegacyCompat ? (
- {LEGACY_SERVICE_SKILL_EXECUTION_COMPAT_NOTE} + {serviceSkillExecutionCompatNote}
) : null} {runServiceSkillSlotPreview ? ( diff --git a/src/components/settings-v2/system/automation/AutomationJobDialog.tsx b/src/components/settings-v2/system/automation/AutomationJobDialog.tsx index f960c1b36..bf18101a7 100644 --- a/src/components/settings-v2/system/automation/AutomationJobDialog.tsx +++ b/src/components/settings-v2/system/automation/AutomationJobDialog.tsx @@ -41,7 +41,8 @@ import { type AgentAccessMode, } from "@/components/agent/chat/hooks/agentChatStorage"; import { - AUTOMATION_ACCESS_MODE_OPTIONS, + buildAutomationAccessModeOptions, + type AutomationAccessModeCopy, omitLegacyAutomationAccessModeMetadata, resolveAgentTurnAutomationAccessMode, } from "./automationAccessMode"; @@ -85,6 +86,37 @@ export type AutomationJobDialogInitialValues = Partial; const TEXT_ONLY_DELIVERY_CHANNEL = "telegram"; type SettingsTranslate = TFunction<"settings">; +function buildAutomationAccessModeCopy( + t: SettingsTranslate, +): AutomationAccessModeCopy { + return { + readOnly: t( + "settings.automation.jobDialog.accessMode.readOnly", + "只读", + ), + current: t( + "settings.automation.jobDialog.accessMode.current", + "按需确认", + ), + fullAccess: t( + "settings.automation.jobDialog.accessMode.fullAccess", + "完全访问", + ), + policyReadOnly: t( + "settings.automation.jobDialog.accessMode.policy.readOnly", + "正式策略会写成 on-request + read-only。", + ), + policyCurrent: t( + "settings.automation.jobDialog.accessMode.policy.current", + "正式策略会写成 on-request + workspace-write。", + ), + policyFullAccess: t( + "settings.automation.jobDialog.accessMode.policy.fullAccess", + "正式策略会写成 never + danger-full-access。", + ), + }; +} + function legacyBrowserAutomationMessage(t: SettingsTranslate): string { return t( "settings.automation.jobDialog.legacy.message", @@ -656,6 +688,11 @@ export function AutomationJobDialog({ ); const scheduleLabel = scheduleKindLabel(t, form.schedule_kind); const accessLabel = accessModeLabel(t, form.agent_access_mode); + const accessModeOptions = useMemo( + () => + buildAutomationAccessModeOptions(buildAutomationAccessModeCopy(t)) ?? [], + [t], + ); async function handleSubmit() { try { @@ -1419,7 +1456,7 @@ export function AutomationJobDialog({ - {AUTOMATION_ACCESS_MODE_OPTIONS.map((option) => ( + {accessModeOptions.map((option) => ( {accessModeLabel(t, option.value)} diff --git a/src/components/settings-v2/system/automation/automationAccessMode.ts b/src/components/settings-v2/system/automation/automationAccessMode.ts index f5eb53a3e..c85e12321 100644 --- a/src/components/settings-v2/system/automation/automationAccessMode.ts +++ b/src/components/settings-v2/system/automation/automationAccessMode.ts @@ -8,14 +8,23 @@ import type { AutomationRequestMetadata, } from "@/lib/api/automation"; -export const AUTOMATION_ACCESS_MODE_OPTIONS: Array<{ - value: AgentAccessMode; - label: string; -}> = [ - { value: "read-only", label: "只读" }, - { value: "current", label: "按需确认" }, - { value: "full-access", label: "完全访问" }, -]; +export interface AutomationAccessModeCopy { + readOnly: string; + current: string; + fullAccess: string; + policyReadOnly: string; + policyCurrent: string; + policyFullAccess: string; +} + +export const defaultAutomationAccessModeCopy: AutomationAccessModeCopy = { + readOnly: "只读", + current: "按需确认", + fullAccess: "完全访问", + policyReadOnly: "正式策略会写成 on-request + read-only。", + policyCurrent: "正式策略会写成 on-request + workspace-write。", + policyFullAccess: "正式策略会写成 never + danger-full-access。", +}; const LEGACY_ACCESS_MODE_KEYS = ["access_mode", "accessMode"] as const; @@ -134,27 +143,60 @@ export function omitLegacyAutomationAccessModeMetadata( } export function automationAccessModeLabel(accessMode: AgentAccessMode): string { + return automationAccessModeLabelWithCopy( + accessMode, + defaultAutomationAccessModeCopy, + ); +} + +export function automationAccessModeLabelWithCopy( + accessMode: AgentAccessMode, + copy: AutomationAccessModeCopy, +): string { switch (accessMode) { case "read-only": - return "只读"; + return copy.readOnly; case "current": - return "按需确认"; + return copy.current; case "full-access": default: - return "完全访问"; + return copy.fullAccess; } } export function automationAccessModePolicySummary( accessMode: AgentAccessMode, +): string { + return automationAccessModePolicySummaryWithCopy( + accessMode, + defaultAutomationAccessModeCopy, + ); +} + +export function automationAccessModePolicySummaryWithCopy( + accessMode: AgentAccessMode, + copy: AutomationAccessModeCopy, ): string { switch (accessMode) { case "read-only": - return "正式策略会写成 on-request + read-only。"; + return copy.policyReadOnly; case "current": - return "正式策略会写成 on-request + workspace-write。"; + return copy.policyCurrent; case "full-access": default: - return "正式策略会写成 never + danger-full-access。"; + return copy.policyFullAccess; } } + +export function buildAutomationAccessModeOptions( + copy: AutomationAccessModeCopy = defaultAutomationAccessModeCopy, +): Array<{ + value: AgentAccessMode; + label: string; +}> { + return [ + { value: "read-only", label: copy.readOnly }, + { value: "current", label: copy.current }, + { value: "full-access", label: copy.fullAccess }, + ]; +} diff --git a/src/components/settings-v2/system/automation/automationPresentation.ts b/src/components/settings-v2/system/automation/automationPresentation.ts index 94018f71a..76ea21447 100644 --- a/src/components/settings-v2/system/automation/automationPresentation.ts +++ b/src/components/settings-v2/system/automation/automationPresentation.ts @@ -6,104 +6,247 @@ import type { AutomationPayload, } from "@/lib/api/automation"; import type { AgentRun } from "@/lib/api/executionRun"; +import { formatDate } from "@/i18n/format"; import { + defaultAutomationServiceSkillContextCopy, mergeAutomationServiceSkillContexts, resolveServiceSkillContextFromMetadataRecord, + type AutomationServiceSkillContextCopy, type AutomationServiceSkillContext, } from "./serviceSkillContext"; import { - automationAccessModeLabel, + automationAccessModeLabelWithCopy, + type AutomationAccessModeCopy, resolveAgentTurnAutomationAccessMode, } from "./automationAccessMode"; -export const LEGACY_BROWSER_AUTOMATION_NOTICE = - "浏览器自动化已下线,系统不会再自动启动 Chrome。请删除旧流程,并改建为 Agent 对话持续流程。"; -export const LEGACY_BROWSER_AUTOMATION_STATUS = "已下线"; - type AutomationBadgeVariant = | "default" | "secondary" | "destructive" | "outline"; -export function formatTime(value?: string | null): string { +export interface AutomationPresentationCopy { + legacyBrowserAutomationNotice: string; + legacyBrowserAutomationStatus: string; + scheduleHours: (count: number) => string; + scheduleMinutes: (count: number) => string; + scheduleSeconds: (count: number) => string; + scheduleCron: (expr: string) => string; + scheduleAt: (time: string) => string; + executionModeIntelligent: string; + executionModeSkill: string; + executionModeLogOnly: string; + payloadBrowserSession: string; + payloadAgentTurn: string; + legacyPayloadProfile: (profile: string) => string; + legacyPayloadEnvironment: (environment: string) => string; + legacyPayloadUrl: (url: string) => string; + legacyPayloadTargetId: (targetId: string) => string; + legacyPayloadWindow: (status: string) => string; + legacyPayloadWindowOpen: string; + legacyPayloadWindowClosed: string; + legacyPayloadStreamMode: (streamMode: string) => string; + statusQueued: string; + statusSuccess: string; + statusRunning: string; + statusWaitingForHuman: string; + statusHumanControlling: string; + statusAgentResuming: string; + statusError: string; + statusTimeout: string; + statusPending: string; + statusDetailBlocking: string; + statusDetailResume: string; + statusDetailLastError: string; + statusDetailRunning: string; + deliveryModeAnnounce: string; + deliveryModeNone: string; + deliveryChannelLocalFile: string; + outputSchemaJson: string; + outputSchemaTable: string; + outputSchemaCsv: string; + outputSchemaLinks: string; + outputSchemaText: string; + outputFormatJson: string; + outputFormatText: string; + serviceSkillTaskLine: (title: string) => string; + serviceSkillMoreItems: (count: number) => string; + serviceSkillContextCopy: AutomationServiceSkillContextCopy; + accessModeCopy?: AutomationAccessModeCopy; +} + +export const defaultAutomationPresentationCopy: AutomationPresentationCopy = { + legacyBrowserAutomationNotice: + "浏览器自动化已下线,系统不会再自动启动 Chrome。请删除旧流程,并改建为 Agent 对话持续流程。", + legacyBrowserAutomationStatus: "已下线", + scheduleHours: (count) => `每 ${count} 小时`, + scheduleMinutes: (count) => `每 ${count} 分钟`, + scheduleSeconds: (count) => `每 ${count} 秒`, + scheduleCron: (expr) => `Cron: ${expr}`, + scheduleAt: (time) => `一次性: ${time}`, + executionModeIntelligent: "智能执行", + executionModeSkill: "技能执行", + executionModeLogOnly: "只记录", + payloadBrowserSession: "浏览器自动化", + payloadAgentTurn: "Agent 对话", + legacyPayloadProfile: (profile) => `资料: ${profile}`, + legacyPayloadEnvironment: (environment) => `环境预设: ${environment}`, + legacyPayloadUrl: (url) => `启动地址: ${url}`, + legacyPayloadTargetId: (targetId) => `Target ID: ${targetId}`, + legacyPayloadWindow: (status) => `调试窗口: ${status}`, + legacyPayloadWindowOpen: "打开", + legacyPayloadWindowClosed: "关闭", + legacyPayloadStreamMode: (streamMode) => `流模式: ${streamMode}`, + statusQueued: "排队中", + statusSuccess: "成功", + statusRunning: "运行中", + statusWaitingForHuman: "等待人工处理", + statusHumanControlling: "人工接管中", + statusAgentResuming: "恢复给 Agent", + statusError: "失败", + statusTimeout: "超时", + statusPending: "待执行", + statusDetailBlocking: "当前阻塞", + statusDetailResume: "恢复说明", + statusDetailLastError: "最近异常", + statusDetailRunning: "运行说明", + deliveryModeAnnounce: "运行完成后投递", + deliveryModeNone: "关闭", + deliveryChannelLocalFile: "本地文件", + outputSchemaJson: "JSON 对象", + outputSchemaTable: "表格", + outputSchemaCsv: "CSV", + outputSchemaLinks: "链接列表", + outputSchemaText: "文本摘要", + outputFormatJson: "JSON 编码", + outputFormatText: "文本编码", + serviceSkillTaskLine: (title) => `技能:${title}`, + serviceSkillMoreItems: (count) => ` 等 ${count} 项`, + serviceSkillContextCopy: defaultAutomationServiceSkillContextCopy, +}; + +export function formatTime( + value?: string | null, + locale?: string, +): string { if (!value) { return "-"; } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - return date.toLocaleString("zh-CN", { hour12: false }); + return ( + formatDate(value, { + locale, + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) || value + ); } -export function describeSchedule(job: AutomationJobRecord): string { +export function describeSchedule( + job: AutomationJobRecord, + copy: AutomationPresentationCopy = defaultAutomationPresentationCopy, + locale?: string, +): string { if (job.schedule.kind === "every") { const secs = job.schedule.every_secs; if (secs % 3600 === 0) { - return `每 ${secs / 3600} 小时`; + return copy.scheduleHours(secs / 3600); } if (secs % 60 === 0) { - return `每 ${secs / 60} 分钟`; + return copy.scheduleMinutes(secs / 60); } - return `每 ${secs} 秒`; + return copy.scheduleSeconds(secs); } if (job.schedule.kind === "cron") { - return `Cron: ${job.schedule.expr}`; + return copy.scheduleCron(job.schedule.expr); } - return `一次性: ${formatTime(job.schedule.at)}`; + return copy.scheduleAt(formatTime(job.schedule.at, locale)); } export function executionModeLabel( mode: AutomationJobRecord["execution_mode"], + copy: AutomationPresentationCopy = defaultAutomationPresentationCopy, ): string { switch (mode) { case "intelligent": - return "智能执行"; + return copy.executionModeIntelligent; case "skill": - return "技能执行"; + return copy.executionModeSkill; case "log_only": - return "只记录"; + return copy.executionModeLogOnly; default: return mode; } } -export function payloadKindLabel(kind: AutomationPayload["kind"]): string { - return kind === "browser_session" ? "浏览器自动化" : "Agent 对话"; +export function payloadKindLabel( + kind: AutomationPayload["kind"], + copy: AutomationPresentationCopy = defaultAutomationPresentationCopy, +): string { + return kind === "browser_session" + ? copy.payloadBrowserSession + : copy.payloadAgentTurn; } -export function describePayload(payload: AutomationPayload): string { +export function describePayload( + payload: AutomationPayload, + copy: AutomationPresentationCopy = defaultAutomationPresentationCopy, +): string { if (payload.kind === "agent_turn") { return payload.prompt; } - const lines = [LEGACY_BROWSER_AUTOMATION_NOTICE]; - lines.push(`资料: ${payload.profile_key ?? payload.profile_id}`); + const lines = [copy.legacyBrowserAutomationNotice]; + lines.push(copy.legacyPayloadProfile(payload.profile_key ?? payload.profile_id)); if (payload.environment_preset_id) { - lines.push(`环境预设: ${payload.environment_preset_id}`); + lines.push(copy.legacyPayloadEnvironment(payload.environment_preset_id)); } if (payload.url) { - lines.push(`启动地址: ${payload.url}`); + lines.push(copy.legacyPayloadUrl(payload.url)); } if (payload.target_id) { - lines.push(`Target ID: ${payload.target_id}`); + lines.push(copy.legacyPayloadTargetId(payload.target_id)); } - lines.push(`调试窗口: ${payload.open_window ? "打开" : "关闭"}`); - lines.push(`流模式: ${payload.stream_mode}`); + lines.push( + copy.legacyPayloadWindow( + payload.open_window + ? copy.legacyPayloadWindowOpen + : copy.legacyPayloadWindowClosed, + ), + ); + lines.push(copy.legacyPayloadStreamMode(payload.stream_mode)); return lines.join("\n"); } export function describeAgentTurnAccessMode( payload: AutomationPayload, + copy: AutomationPresentationCopy = defaultAutomationPresentationCopy, ): string { if (payload.kind !== "agent_turn") { return "-"; } - return automationAccessModeLabel( - resolveAgentTurnAutomationAccessMode(payload), - ); + return copy.accessModeCopy + ? automationAccessModeLabelWithCopy( + resolveAgentTurnAutomationAccessMode(payload), + copy.accessModeCopy, + ) + : automationAccessModeLabelWithCopy( + resolveAgentTurnAutomationAccessMode(payload), + { + readOnly: "只读", + current: "按需确认", + fullAccess: "完全访问", + policyReadOnly: "正式策略会写成 on-request + read-only。", + policyCurrent: "正式策略会写成 on-request + workspace-write。", + policyFullAccess: "正式策略会写成 never + danger-full-access。", + }, + ); } export function isLegacyBrowserAutomation( @@ -227,26 +370,29 @@ export function resolveRunDelivery( }; } -export function statusLabel(status?: string | null): string { +export function statusLabel( + status?: string | null, + copy: AutomationPresentationCopy = defaultAutomationPresentationCopy, +): string { switch (status) { case "queued": - return "排队中"; + return copy.statusQueued; case "success": - return "成功"; + return copy.statusSuccess; case "running": - return "运行中"; + return copy.statusRunning; case "waiting_for_human": - return "等待人工处理"; + return copy.statusWaitingForHuman; case "human_controlling": - return "人工接管中"; + return copy.statusHumanControlling; case "agent_resuming": - return "恢复给 Agent"; + return copy.statusAgentResuming; case "error": - return "失败"; + return copy.statusError; case "timeout": - return "超时"; + return copy.statusTimeout; default: - return "待执行"; + return copy.statusPending; } } @@ -313,18 +459,21 @@ export function statusDetailToneClass(status?: string | null): string { } } -export function statusDetailPrefix(status?: string | null): string { +export function statusDetailPrefix( + status?: string | null, + copy: AutomationPresentationCopy = defaultAutomationPresentationCopy, +): string { switch (status) { case "waiting_for_human": case "human_controlling": - return "当前阻塞"; + return copy.statusDetailBlocking; case "agent_resuming": - return "恢复说明"; + return copy.statusDetailResume; case "error": case "timeout": - return "最近异常"; + return copy.statusDetailLastError; default: - return "运行说明"; + return copy.statusDetailRunning; } } @@ -351,18 +500,26 @@ export function resolveDeliveryOutputSchema( } } -export function deliveryModeLabel(job: AutomationJobRecord): string { - return job.delivery.mode === "announce" ? "运行完成后投递" : "关闭"; +export function deliveryModeLabel( + job: AutomationJobRecord, + copy: AutomationPresentationCopy = defaultAutomationPresentationCopy, +): string { + return job.delivery.mode === "announce" + ? copy.deliveryModeAnnounce + : copy.deliveryModeNone; } -export function deliveryChannelLabel(channel?: string | null): string { +export function deliveryChannelLabel( + channel?: string | null, + copy: AutomationPresentationCopy = defaultAutomationPresentationCopy, +): string { switch (channel) { case "webhook": return "Webhook"; case "google_sheets": return "Google Sheets"; case "local_file": - return "本地文件"; + return copy.deliveryChannelLocalFile; case "telegram": return "Telegram"; default: @@ -370,23 +527,29 @@ export function deliveryChannelLabel(channel?: string | null): string { } } -export function outputSchemaLabel(schema: AutomationOutputSchema): string { +export function outputSchemaLabel( + schema: AutomationOutputSchema, + copy: AutomationPresentationCopy = defaultAutomationPresentationCopy, +): string { switch (schema) { case "json": - return "JSON 对象"; + return copy.outputSchemaJson; case "table": - return "表格"; + return copy.outputSchemaTable; case "csv": - return "CSV"; + return copy.outputSchemaCsv; case "links": - return "链接列表"; + return copy.outputSchemaLinks; default: - return "文本摘要"; + return copy.outputSchemaText; } } -export function outputFormatLabel(format: AutomationOutputFormat): string { - return format === "json" ? "JSON 编码" : "文本编码"; +export function outputFormatLabel( + format: AutomationOutputFormat, + copy: AutomationPresentationCopy = defaultAutomationPresentationCopy, +): string { + return format === "json" ? copy.outputFormatJson : copy.outputFormatText; } export function deliveryStatusVariant( @@ -408,13 +571,15 @@ export function deliveryToneClass( export function describeServiceSkillTaskLine( serviceSkillContext: AutomationServiceSkillContext, + copy: AutomationPresentationCopy = defaultAutomationPresentationCopy, ): string { - return `技能:${serviceSkillContext.title}`; + return copy.serviceSkillTaskLine(serviceSkillContext.title); } export function describeServiceSkillSlotPreview( serviceSkillContext: AutomationServiceSkillContext, limit: number = 2, + copy: AutomationPresentationCopy = defaultAutomationPresentationCopy, ): string | null { const preview = serviceSkillContext.slotSummary .slice(0, limit) @@ -422,7 +587,7 @@ export function describeServiceSkillSlotPreview( if (preview.length > 0) { const suffix = serviceSkillContext.slotSummary.length > limit - ? ` 等 ${serviceSkillContext.slotSummary.length} 项` + ? copy.serviceSkillMoreItems(serviceSkillContext.slotSummary.length) : ""; return `${preview.join(" · ")}${suffix}`; } @@ -437,10 +602,11 @@ export function describeServiceSkillSlotPreview( export function resolveRunServiceSkillContext( run: AgentRun, fallbackContext: AutomationServiceSkillContext | null, + copy: AutomationServiceSkillContextCopy = defaultAutomationServiceSkillContextCopy, ): AutomationServiceSkillContext | null { const metadata = parseRunMetadata(run); const runContext = metadata - ? resolveServiceSkillContextFromMetadataRecord(metadata) + ? resolveServiceSkillContextFromMetadataRecord(metadata, { copy }) : null; - return mergeAutomationServiceSkillContexts(runContext, fallbackContext); + return mergeAutomationServiceSkillContexts(runContext, fallbackContext, copy); } diff --git a/src/components/settings-v2/system/automation/index.test.tsx b/src/components/settings-v2/system/automation/index.test.tsx index ee9ecae98..2aea31371 100644 --- a/src/components/settings-v2/system/automation/index.test.tsx +++ b/src/components/settings-v2/system/automation/index.test.tsx @@ -42,6 +42,7 @@ const { return { mockUseTranslation: vi.fn((_namespace?: string) => ({ + i18n: { language: "zh-CN" }, t: mockTranslate, })), mockGetAutomationSchedulerConfig: vi.fn(), @@ -63,6 +64,10 @@ const { vi.mock("react-i18next", () => ({ useTranslation: mockUseTranslation, + initReactI18next: { + type: "3rdParty" as const, + init: vi.fn(), + }, })); vi.mock("@/lib/api/automation", () => ({ diff --git a/src/components/settings-v2/system/automation/index.tsx b/src/components/settings-v2/system/automation/index.tsx index d1cd70722..112f646ec 100644 --- a/src/components/settings-v2/system/automation/index.tsx +++ b/src/components/settings-v2/system/automation/index.tsx @@ -63,7 +63,7 @@ import { type AutomationJobDialogInitialValues, } from "./AutomationJobDialog"; import { - LEGACY_SERVICE_SKILL_EXECUTION_COMPAT_LABEL, + type AutomationServiceSkillContextCopy, resolveServiceSkillAutomationContext, type AutomationServiceSkillContext, } from "./serviceSkillContext"; @@ -74,8 +74,7 @@ import { recordAutomationStatusRefreshAgentUiProjection, } from "./automationAgentUiProjection"; import { - LEGACY_BROWSER_AUTOMATION_NOTICE, - LEGACY_BROWSER_AUTOMATION_STATUS, + type AutomationPresentationCopy, describeSchedule, describeServiceSkillSlotPreview, describeServiceSkillTaskLine, @@ -87,6 +86,7 @@ import { statusLabel, statusVariant, } from "./automationPresentation"; +import type { AutomationAccessModeCopy } from "./automationAccessMode"; import { useAutomationSceneAppRuntime } from "./useAutomationSceneAppRuntime"; import { backfillSceneAppExecutionSummaryViewModel, @@ -120,6 +120,253 @@ import type { const AUTOMATION_CORE_LOAD_TIMEOUT_MS = 8000; const AUTOMATION_AUXILIARY_LOAD_TIMEOUT_MS = 5000; +function buildAutomationServiceSkillContextCopy( + t: TFunction<"settings">, +): AutomationServiceSkillContextCopy { + return { + defaultTitle: t( + "settings.automation.tasks.list.badge.serviceSkill", + "技能流程", + ), + unknownLabel: t("settings.automation.serviceSkill.unknown", "未标记"), + runnerInstant: t( + "settings.automation.serviceSkill.runner.instant", + "一次性交付", + ), + runnerScheduled: t( + "settings.automation.serviceSkill.runner.scheduled", + "定时运行", + ), + runnerManaged: t( + "settings.automation.serviceSkill.runner.managed", + "持续跟踪", + ), + executionLocationClient: t( + "settings.automation.serviceSkill.executionLocation.client", + "客户端执行", + ), + sourceCloudCatalog: t( + "settings.automation.serviceSkill.source.cloudCatalog", + "云目录", + ), + sourceLocalCustom: t( + "settings.automation.serviceSkill.source.localCustom", + "本地自定义", + ), + slotFallbackLabel: (index) => + t("settings.automation.serviceSkill.slotFallback", { + index: index + 1, + defaultValue: "参数 {{index}}", + }), + }; +} + +function buildAutomationPresentationCopy( + t: TFunction<"settings">, + serviceSkillContextCopy: AutomationServiceSkillContextCopy, +): AutomationPresentationCopy { + const accessModeCopy: AutomationAccessModeCopy = { + readOnly: t("settings.automation.jobDialog.accessMode.readOnly", "只读"), + current: t("settings.automation.jobDialog.accessMode.current", "按需确认"), + fullAccess: t( + "settings.automation.jobDialog.accessMode.fullAccess", + "完全访问", + ), + policyReadOnly: t( + "settings.automation.jobDialog.accessMode.policy.readOnly", + "正式策略会写成 on-request + read-only。", + ), + policyCurrent: t( + "settings.automation.jobDialog.accessMode.policy.current", + "正式策略会写成 on-request + workspace-write。", + ), + policyFullAccess: t( + "settings.automation.jobDialog.accessMode.policy.fullAccess", + "正式策略会写成 never + danger-full-access。", + ), + }; + + return { + legacyBrowserAutomationNotice: t( + "settings.automation.details.legacy.message", + "浏览器自动化已下线,系统不会再自动启动 Chrome。请删除旧流程,并改建为 Agent 对话持续流程。", + ), + legacyBrowserAutomationStatus: t( + "settings.automation.details.status.offline", + "已下线", + ), + scheduleHours: (count) => + t("settings.automation.details.schedule.hours", { + count, + defaultValue: "每 {{count}} 小时", + }), + scheduleMinutes: (count) => + t("settings.automation.details.schedule.minutes", { + count, + defaultValue: "每 {{count}} 分钟", + }), + scheduleSeconds: (count) => + t("settings.automation.details.schedule.seconds", { + count, + defaultValue: "每 {{count}} 秒", + }), + scheduleCron: (expr) => + t("settings.automation.details.schedule.cron", { + expr, + defaultValue: "Cron: {{expr}}", + }), + scheduleAt: (time) => + t("settings.automation.details.schedule.at", { + time, + defaultValue: "一次性: {{time}}", + }), + executionModeIntelligent: t( + "settings.automation.jobDialog.executionMode.intelligent", + "智能执行", + ), + executionModeSkill: t( + "settings.automation.jobDialog.executionMode.skill", + "技能执行", + ), + executionModeLogOnly: t( + "settings.automation.jobDialog.executionMode.logOnly", + "只记录", + ), + payloadBrowserSession: t( + "settings.automation.details.payload.browserSession", + "浏览器自动化", + ), + payloadAgentTurn: t( + "settings.automation.details.payload.agentTurn", + "Agent 对话", + ), + legacyPayloadProfile: (profile) => + t("settings.automation.details.legacy.payload.profile", { + profile, + defaultValue: "资料: {{profile}}", + }), + legacyPayloadEnvironment: (environment) => + t("settings.automation.details.legacy.payload.environment", { + environment, + defaultValue: "环境预设: {{environment}}", + }), + legacyPayloadUrl: (url) => + t("settings.automation.details.legacy.payload.url", { + url, + defaultValue: "启动地址: {{url}}", + }), + legacyPayloadTargetId: (targetId) => + t("settings.automation.details.legacy.payload.targetId", { + targetId, + defaultValue: "Target ID: {{targetId}}", + }), + legacyPayloadWindow: (status) => + t("settings.automation.details.legacy.payload.window", { + status, + defaultValue: "调试窗口: {{status}}", + }), + legacyPayloadWindowOpen: t( + "settings.automation.details.legacy.payload.windowOpen", + "打开", + ), + legacyPayloadWindowClosed: t( + "settings.automation.details.legacy.payload.windowClosed", + "关闭", + ), + legacyPayloadStreamMode: (streamMode) => + t("settings.automation.details.legacy.payload.streamMode", { + streamMode, + defaultValue: "流模式: {{streamMode}}", + }), + statusQueued: t("settings.automation.details.status.queued", "排队中"), + statusSuccess: t("settings.automation.details.status.success", "成功"), + statusRunning: t("settings.automation.details.status.running", "运行中"), + statusWaitingForHuman: t( + "settings.automation.details.status.waitingForHuman", + "等待人工处理", + ), + statusHumanControlling: t( + "settings.automation.details.status.humanControlling", + "人工接管中", + ), + statusAgentResuming: t( + "settings.automation.details.status.agentResuming", + "恢复给 Agent", + ), + statusError: t("settings.automation.details.status.error", "失败"), + statusTimeout: t("settings.automation.details.status.timeout", "超时"), + statusPending: t("settings.automation.details.status.pending", "待执行"), + statusDetailBlocking: t( + "settings.automation.details.statusDetail.blocking", + "当前阻塞", + ), + statusDetailResume: t( + "settings.automation.details.statusDetail.resume", + "恢复说明", + ), + statusDetailLastError: t( + "settings.automation.details.statusDetail.lastError", + "最近异常", + ), + statusDetailRunning: t( + "settings.automation.details.statusDetail.running", + "运行说明", + ), + deliveryModeAnnounce: t( + "settings.automation.details.delivery.mode.announce", + "运行完成后投递", + ), + deliveryModeNone: t( + "settings.automation.jobDialog.delivery.mode.none", + "关闭", + ), + deliveryChannelLocalFile: t( + "settings.automation.details.delivery.channel.localFile", + "本地文件", + ), + outputSchemaJson: t( + "settings.automation.details.delivery.schema.json", + "JSON 对象", + ), + outputSchemaTable: t( + "settings.automation.details.delivery.schema.table", + "表格", + ), + outputSchemaCsv: t( + "settings.automation.details.delivery.schema.csv", + "CSV", + ), + outputSchemaLinks: t( + "settings.automation.details.delivery.schema.links", + "链接列表", + ), + outputSchemaText: t( + "settings.automation.details.delivery.schema.text", + "文本摘要", + ), + outputFormatJson: t( + "settings.automation.details.delivery.format.json", + "JSON 编码", + ), + outputFormatText: t( + "settings.automation.details.delivery.format.text", + "文本编码", + ), + serviceSkillTaskLine: (title) => + t("settings.automation.details.serviceSkill.taskLine", { + title, + defaultValue: "技能:{{title}}", + }), + serviceSkillMoreItems: (count) => + t("settings.automation.details.serviceSkill.moreItems", { + count, + defaultValue: " 等 {{count}} 项", + }), + serviceSkillContextCopy, + accessModeCopy, + }; +} + function isAutomationJobAtRisk( job: AutomationJobRecord, riskyJobMessageMap: Map, @@ -358,7 +605,24 @@ export function AutomationSettings({ onOpenWorkspace, onNavigate, }: AutomationSettingsProps) { - const { t } = useTranslation("settings"); + const { i18n, t } = useTranslation("settings"); + const serviceSkillContextCopy = useMemo( + () => buildAutomationServiceSkillContextCopy(t), + [t], + ); + const automationPresentationCopy = useMemo( + () => + buildAutomationPresentationCopy(t, serviceSkillContextCopy), + [serviceSkillContextCopy, t], + ); + const legacyBrowserAutomationNotice = + automationPresentationCopy.legacyBrowserAutomationNotice; + const legacyBrowserAutomationStatus = + automationPresentationCopy.legacyBrowserAutomationStatus; + const serviceSkillExecutionCompatLabel = t( + "settings.automation.tasks.list.badge.serviceSkillLegacyCompat", + "旧目录兼容", + ); const workspaceTemplates = useMemo(() => createWorkspaceTemplates(t), [t]); const workspaceOnly = mode === "workspace"; const settingsOnly = mode === "settings"; @@ -424,7 +688,7 @@ export function AutomationSettings({ useEffect(() => { jobsRef.current = jobs; - }, [jobs]); + }, [jobs, serviceSkillContextCopy]); const selectedJob = useMemo( () => jobs.find((job) => job.id === selectedJobId) ?? null, @@ -433,13 +697,16 @@ export function AutomationSettings({ const serviceSkillContextByJobId = useMemo(() => { const mapping = new Map(); jobs.forEach((job) => { - const context = resolveServiceSkillAutomationContext(job.payload); + const context = resolveServiceSkillAutomationContext( + job.payload, + serviceSkillContextCopy, + ); if (context) { mapping.set(job.id, context); } }); return mapping; - }, [jobs]); + }, [jobs, serviceSkillContextCopy]); const selectedServiceSkillContext = useMemo( () => selectedJobId @@ -554,9 +821,13 @@ export function AutomationSettings({ enabledJobCount: selectedJob.enabled ? 1 : 0, riskyJobCount: selectedJobRiskyCount, latestJobName: selectedJob.name, - latestJobStatusLabel: statusLabel(selectedJob.last_status), + latestJobStatusLabel: statusLabel( + selectedJob.last_status, + automationPresentationCopy, + ), }); }, [ + automationPresentationCopy, selectedJob, selectedJobRiskyCount, selectedSceneAppRuntime.descriptor, @@ -660,9 +931,13 @@ export function AutomationSettings({ ? 1 : 0, latestJobName: overviewFocusJob.name, - latestJobStatusLabel: statusLabel(overviewFocusJob.last_status), + latestJobStatusLabel: statusLabel( + overviewFocusJob.last_status, + automationPresentationCopy, + ), }); }, [ + automationPresentationCopy, effectiveOverviewSceneAppRuntime.descriptor, effectiveOverviewSceneAppRuntime.linkedRun, effectiveOverviewSceneAppRuntime.scorecard, @@ -1187,7 +1462,7 @@ export function AutomationSettings({ async function handleRunNow(job: AutomationJobRecord) { if (isLegacyBrowserAutomation(job)) { - toast.error(LEGACY_BROWSER_AUTOMATION_NOTICE); + toast.error(legacyBrowserAutomationNotice); return; } @@ -1975,8 +2250,8 @@ export function AutomationSettings({
{t("settings.automation.scheduler.pollWindow", { - last: formatTime(status?.last_polled_at), - next: formatTime(status?.next_poll_at), + last: formatTime(status?.last_polled_at, i18n.language), + next: formatTime(status?.next_poll_at, i18n.language), defaultValue: "最近轮询 {{last}},下次轮询 {{next}}", })}
@@ -2183,11 +2458,16 @@ export function AutomationSettings({ const serviceSkillContext = serviceSkillContextByJobId.get(job.id) ?? null; const serviceSkillTaskLine = serviceSkillContext - ? describeServiceSkillTaskLine(serviceSkillContext) + ? describeServiceSkillTaskLine( + serviceSkillContext, + automationPresentationCopy, + ) : null; const serviceSkillSlotPreview = serviceSkillContext ? describeServiceSkillSlotPreview( serviceSkillContext, + 2, + automationPresentationCopy, ) : null; const legacyBrowserJob = @@ -2245,7 +2525,7 @@ export function AutomationSettings({ {serviceSkillContext.executionLocationLegacyCompat ? ( { - LEGACY_SERVICE_SKILL_EXECUTION_COMPAT_LABEL + serviceSkillExecutionCompatLabel } ) : null} @@ -2302,10 +2582,17 @@ export function AutomationSettings({ job.workspace_id} - {describeSchedule(job)} + {describeSchedule( + job, + automationPresentationCopy, + i18n.language, + )} - {executionModeLabel(job.execution_mode)} + {executionModeLabel( + job.execution_mode, + automationPresentationCopy, + )}
@@ -2313,11 +2600,14 @@ export function AutomationSettings({ - {statusLabel(job.last_status)} + {statusLabel( + job.last_status, + automationPresentationCopy, + )} {legacyBrowserJob ? ( - {LEGACY_BROWSER_AUTOMATION_STATUS} + {legacyBrowserAutomationStatus} ) : null} {!job.enabled ? ( @@ -2354,7 +2644,11 @@ export function AutomationSettings({ job.last_status, )}`} > - {statusDetailPrefix(job.last_status)}:{" "} + {statusDetailPrefix( + job.last_status, + automationPresentationCopy, + )} + :{" "} {jobDetailMessage}
) : null} @@ -2369,7 +2663,10 @@ export function AutomationSettings({ {t( "settings.automation.tasks.list.nextRun", { - time: formatTime(job.next_run_at), + time: formatTime( + job.next_run_at, + i18n.language, + ), defaultValue: "下次: {{time}}", }, )} @@ -2378,7 +2675,10 @@ export function AutomationSettings({ {t( "settings.automation.tasks.list.recentRun", { - time: formatTime(job.last_run_at), + time: formatTime( + job.last_run_at, + i18n.language, + ), defaultValue: "最近: {{time}}", }, )} @@ -2401,7 +2701,7 @@ export function AutomationSettings({ > {legacyBrowserJob - ? LEGACY_BROWSER_AUTOMATION_STATUS + ? legacyBrowserAutomationStatus : runningJobId === job.id ? t( "settings.automation.tasks.list.action.running", @@ -2523,7 +2823,7 @@ export function AutomationSettings({ 最近轮询
- {formatTime(status?.last_polled_at)} + {formatTime(status?.last_polled_at, i18n.language)}
@@ -2531,7 +2831,7 @@ export function AutomationSettings({ 下次轮询
- {formatTime(status?.next_poll_at)} + {formatTime(status?.next_poll_at, i18n.language)}
diff --git a/src/components/settings-v2/system/automation/serviceSkillContext.ts b/src/components/settings-v2/system/automation/serviceSkillContext.ts index 7dfb14aca..0339ad618 100644 --- a/src/components/settings-v2/system/automation/serviceSkillContext.ts +++ b/src/components/settings-v2/system/automation/serviceSkillContext.ts @@ -1,11 +1,6 @@ import type { AutomationPayload } from "@/lib/api/automation"; import { resolveServiceSkillExecutionLocationPresentation } from "@/lib/api/serviceSkills"; -export { - LEGACY_SERVICE_SKILL_EXECUTION_COMPAT_LABEL, - LEGACY_SERVICE_SKILL_EXECUTION_COMPAT_NOTE, -} from "@/lib/api/serviceSkills"; - export interface AutomationServiceSkillSummaryItem { key: string; label: string; @@ -25,8 +20,30 @@ export interface AutomationServiceSkillContext { userInput: string | null; } -const DEFAULT_SERVICE_SKILL_TITLE = "技能流程"; -const UNKNOWN_SERVICE_SKILL_LABEL = "未标记"; +export interface AutomationServiceSkillContextCopy { + defaultTitle: string; + unknownLabel: string; + runnerInstant: string; + runnerScheduled: string; + runnerManaged: string; + executionLocationClient: string; + sourceCloudCatalog: string; + sourceLocalCustom: string; + slotFallbackLabel: (index: number) => string; +} + +export const defaultAutomationServiceSkillContextCopy: AutomationServiceSkillContextCopy = + { + defaultTitle: "技能流程", + unknownLabel: "未标记", + runnerInstant: "一次性交付", + runnerScheduled: "定时运行", + runnerManaged: "持续跟踪", + executionLocationClient: "客户端执行", + sourceCloudCatalog: "云目录", + sourceLocalCustom: "本地自定义", + slotFallbackLabel: (index) => `参数 ${index + 1}`, + }; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); @@ -41,24 +58,29 @@ function normalizeOptionalText(value: unknown): string | null { return normalized ? normalized : null; } -function resolveRunnerLabel(value: unknown): string { +function resolveRunnerLabel( + value: unknown, + copy: AutomationServiceSkillContextCopy, +): string { switch (value) { case "instant": - return "一次性交付"; + return copy.runnerInstant; case "scheduled": - return "定时运行"; + return copy.runnerScheduled; case "managed": - return "持续跟踪"; + return copy.runnerManaged; default: - return UNKNOWN_SERVICE_SKILL_LABEL; + return copy.unknownLabel; } } -function resolveExecutionLocationLabel(value: unknown): string { - return ( - resolveServiceSkillExecutionLocationPresentation(value)?.label ?? - UNKNOWN_SERVICE_SKILL_LABEL - ); +function resolveExecutionLocationLabel( + value: unknown, + copy: AutomationServiceSkillContextCopy, +): string { + return resolveServiceSkillExecutionLocationPresentation(value) + ? copy.executionLocationClient + : copy.unknownLabel; } function resolveExecutionLocationLegacyCompat(value: unknown): boolean { @@ -68,19 +90,23 @@ function resolveExecutionLocationLegacyCompat(value: unknown): boolean { ); } -function resolveSourceLabel(value: unknown): string { +function resolveSourceLabel( + value: unknown, + copy: AutomationServiceSkillContextCopy, +): string { switch (value) { case "cloud_catalog": - return "云目录"; + return copy.sourceCloudCatalog; case "local_custom": - return "本地自定义"; + return copy.sourceLocalCustom; default: - return UNKNOWN_SERVICE_SKILL_LABEL; + return copy.unknownLabel; } } function parseSlotSummaryEntries( value: unknown, + copy: AutomationServiceSkillContextCopy, ): AutomationServiceSkillSummaryItem[] { if (Array.isArray(value)) { const structured = value @@ -126,7 +152,7 @@ function parseSlotSummaryEntries( if (separatorIndex <= 0) { return { key: `slot-${index + 1}`, - label: `参数 ${index + 1}`, + label: copy.slotFallbackLabel(index), value: summaryLine, }; } @@ -142,6 +168,7 @@ function parseSlotSummaryEntries( function resolveServiceSkillContextFromRecord( record: Record, + copy: AutomationServiceSkillContextCopy, explicitContentId?: string | null, ): AutomationServiceSkillContext | null { const serviceSkillValue = record.service_skill ?? record.serviceSkill; @@ -152,21 +179,20 @@ function resolveServiceSkillContextFromRecord( const harnessValue = isRecord(record.harness) ? record.harness : null; const id = normalizeOptionalText(serviceSkillValue.id); const title = - normalizeOptionalText(serviceSkillValue.title) || - id || - DEFAULT_SERVICE_SKILL_TITLE; + normalizeOptionalText(serviceSkillValue.title) || id || copy.defaultTitle; return { id, title, - runnerLabel: resolveRunnerLabel(serviceSkillValue.runner_type), + runnerLabel: resolveRunnerLabel(serviceSkillValue.runner_type, copy), executionLocationLabel: resolveExecutionLocationLabel( serviceSkillValue.execution_location, + copy, ), executionLocationLegacyCompat: resolveExecutionLocationLegacyCompat( serviceSkillValue.execution_location, ), - sourceLabel: resolveSourceLabel(serviceSkillValue.source), + sourceLabel: resolveSourceLabel(serviceSkillValue.source, copy), theme: normalizeOptionalText(harnessValue?.theme), contentId: normalizeOptionalText(explicitContentId) || @@ -174,6 +200,7 @@ function resolveServiceSkillContextFromRecord( normalizeOptionalText(harnessValue?.content_id), slotSummary: parseSlotSummaryEntries( serviceSkillValue.slot_values ?? serviceSkillValue.slot_summary, + copy, ), userInput: normalizeOptionalText(serviceSkillValue.user_input) || @@ -181,17 +208,24 @@ function resolveServiceSkillContextFromRecord( }; } -function shouldUseFallbackLabel(value: string): boolean { - return value === UNKNOWN_SERVICE_SKILL_LABEL; +function shouldUseFallbackLabel( + value: string, + copy: AutomationServiceSkillContextCopy, +): boolean { + return value === copy.unknownLabel; } -function shouldUseFallbackTitle(value: string): boolean { - return value === DEFAULT_SERVICE_SKILL_TITLE; +function shouldUseFallbackTitle( + value: string, + copy: AutomationServiceSkillContextCopy, +): boolean { + return value === copy.defaultTitle; } export function mergeAutomationServiceSkillContexts( primary: AutomationServiceSkillContext | null, fallback: AutomationServiceSkillContext | null, + copy: AutomationServiceSkillContextCopy = defaultAutomationServiceSkillContextCopy, ): AutomationServiceSkillContext | null { if (!primary) { return fallback; @@ -202,21 +236,22 @@ export function mergeAutomationServiceSkillContexts( return { id: primary.id || fallback.id, - title: shouldUseFallbackTitle(primary.title) + title: shouldUseFallbackTitle(primary.title, copy) ? fallback.title : primary.title, - runnerLabel: shouldUseFallbackLabel(primary.runnerLabel) + runnerLabel: shouldUseFallbackLabel(primary.runnerLabel, copy) ? fallback.runnerLabel : primary.runnerLabel, executionLocationLabel: shouldUseFallbackLabel( primary.executionLocationLabel, + copy, ) ? fallback.executionLocationLabel : primary.executionLocationLabel, executionLocationLegacyCompat: primary.executionLocationLegacyCompat || fallback.executionLocationLegacyCompat, - sourceLabel: shouldUseFallbackLabel(primary.sourceLabel) + sourceLabel: shouldUseFallbackLabel(primary.sourceLabel, copy) ? fallback.sourceLabel : primary.sourceLabel, theme: primary.theme || fallback.theme, @@ -232,18 +267,21 @@ export function resolveServiceSkillContextFromMetadataRecord( metadata: Record, options?: { contentId?: string | null; + copy?: AutomationServiceSkillContextCopy; }, ): AutomationServiceSkillContext | null { const nestedRequestMetadata = isRecord(metadata.request_metadata) ? metadata.request_metadata : null; const explicitContentId = normalizeOptionalText(options?.contentId); + const copy = options?.copy ?? defaultAutomationServiceSkillContextCopy; return ( - resolveServiceSkillContextFromRecord(metadata, explicitContentId) || + resolveServiceSkillContextFromRecord(metadata, copy, explicitContentId) || (nestedRequestMetadata ? resolveServiceSkillContextFromRecord( nestedRequestMetadata, + copy, explicitContentId, ) : null) @@ -252,12 +290,14 @@ export function resolveServiceSkillContextFromMetadataRecord( export function resolveServiceSkillAutomationContext( payload: AutomationPayload, + copy: AutomationServiceSkillContextCopy = defaultAutomationServiceSkillContextCopy, ): AutomationServiceSkillContext | null { if (payload.kind !== "agent_turn" || !isRecord(payload.request_metadata)) { return null; } return resolveServiceSkillContextFromRecord( payload.request_metadata, + copy, payload.content_id, ); } diff --git a/src/components/settings-v2/system/experimental/UpdateCheckSettings.test.tsx b/src/components/settings-v2/system/experimental/UpdateCheckSettings.test.tsx new file mode 100644 index 000000000..cb72af5a6 --- /dev/null +++ b/src/components/settings-v2/system/experimental/UpdateCheckSettings.test.tsx @@ -0,0 +1,234 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockGetUpdateCheckSettings, + mockGetUpdateNotificationMetrics, + mockSetUpdateCheckSettings, + mockTestUpdateWindow, +} = vi.hoisted(() => ({ + mockGetUpdateCheckSettings: vi.fn(), + mockGetUpdateNotificationMetrics: vi.fn(), + mockSetUpdateCheckSettings: vi.fn(), + mockTestUpdateWindow: vi.fn(), +})); + +const { mockUseTranslation } = vi.hoisted(() => { + const translations = { + "settings.experimental.updateCheck.action.testWindow": + "Test update prompt from i18n", + "settings.experimental.updateCheck.autoCheck.aria": + "Toggle auto check from i18n", + "settings.experimental.updateCheck.autoCheck.title": + "Automatic check title from i18n", + "settings.experimental.updateCheck.interval.weekly": "Weekly from i18n", + "settings.experimental.updateCheck.metrics.title": + "Metrics title from i18n", + "settings.experimental.updateCheck.metrics.updateNow": + "Shown {{shown}} times and update now {{updateNow}} times from i18n", + "settings.experimental.updateCheck.skippedVersion.title": + "Skipped version from i18n", + "settings.experimental.updateCheck.title": "Auto update checks from i18n", + } as Record; + + const mockTranslate = vi.fn((key: string, options?: unknown) => { + if (typeof options === "string") { + return translations[key] ?? options; + } + + const template = + translations[key] ?? + (options && typeof options === "object" + ? (options as Record).defaultValue + : undefined) ?? + key; + + return String(template).replace(/\{\{(\w+)\}\}/g, (_, name: string) => + String( + options && typeof options === "object" + ? ((options as Record)[name] ?? "") + : "", + ), + ); + }); + + return { + mockUseTranslation: vi.fn((_namespace?: string) => ({ + i18n: { language: "en-US" }, + t: mockTranslate, + })), + }; +}); + +vi.mock("@/lib/api/appUpdate", () => ({ + getUpdateCheckSettings: mockGetUpdateCheckSettings, + getUpdateNotificationMetrics: mockGetUpdateNotificationMetrics, + setUpdateCheckSettings: mockSetUpdateCheckSettings, + testUpdateWindow: mockTestUpdateWindow, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: mockUseTranslation, +})); + +import { UpdateCheckSettings } from "./UpdateCheckSettings"; + +interface Mounted { + container: HTMLDivElement; + root: Root; +} + +const mounted: Mounted[] = []; + +function renderComponent(): HTMLDivElement { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + mounted.push({ container, root }); + return container; +} + +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + }); +} + +async function waitForLoad() { + await flushEffects(); + await flushEffects(); +} + +function getText(container: HTMLElement) { + return (container.textContent ?? "").replace(/\s+/g, " ").trim(); +} + +function findButtonByLabel( + container: HTMLElement, + label: string, +): HTMLButtonElement { + const button = container.querySelector( + `button[aria-label="${label}"]`, + ); + if (!button) { + throw new Error(`未找到按钮: ${label}`); + } + return button; +} + +function findButtonByText( + container: HTMLElement, + text: string, +): HTMLButtonElement { + const button = Array.from(container.querySelectorAll("button")).find( + (item) => item.textContent?.includes(text), + ); + if (!button) { + throw new Error(`未找到按钮文本: ${text}`); + } + return button as HTMLButtonElement; +} + +async function clickButton(button: HTMLButtonElement) { + await act(async () => { + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushEffects(); + }); +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + vi.clearAllMocks(); + + mockGetUpdateCheckSettings.mockResolvedValue({ + check_interval_hours: 168, + enabled: true, + last_check_timestamp: 1760000000, + remind_later_until: 1893456000, + show_notification: true, + skipped_version: "1.2.3", + }); + mockGetUpdateNotificationMetrics.mockResolvedValue({ + dismiss_count: 4, + dismiss_rate: 20, + remind_later_count: 2, + remind_later_rate: 10, + shown_count: 20, + skip_version_count: 1, + skip_version_rate: 5, + update_now_count: 3, + update_now_rate: 15, + }); + mockSetUpdateCheckSettings.mockResolvedValue(undefined); + mockTestUpdateWindow.mockResolvedValue(undefined); +}); + +afterEach(() => { + while (mounted.length > 0) { + const target = mounted.pop(); + if (!target) { + break; + } + + act(() => { + target.root.unmount(); + }); + target.container.remove(); + } + + vi.clearAllMocks(); +}); + +describe("UpdateCheckSettings", () => { + it("应从 settings namespace 渲染自动更新检查与指标文案", async () => { + const container = renderComponent(); + await waitForLoad(); + + const text = getText(container); + expect(mockUseTranslation).toHaveBeenCalledWith("settings"); + expect(text).toContain("Auto update checks from i18n"); + expect(text).toContain("Automatic check title from i18n"); + expect(text).toContain("Weekly from i18n"); + expect(text).toContain("Skipped version from i18n"); + expect(text).toContain("Metrics title from i18n"); + expect(text).toContain( + "Shown 20 times and update now 3 times from i18n", + ); + }); + + it("关闭自动检查时应保存完整更新配置", async () => { + const container = renderComponent(); + await waitForLoad(); + + await clickButton(findButtonByLabel(container, "Toggle auto check from i18n")); + + expect(mockSetUpdateCheckSettings).toHaveBeenCalledWith({ + check_interval_hours: 168, + enabled: false, + last_check_timestamp: 1760000000, + remind_later_until: 1893456000, + show_notification: true, + skipped_version: "1.2.3", + }); + }); + + it("开发环境测试按钮应调用更新弹窗测试入口", async () => { + const container = renderComponent(); + await waitForLoad(); + + await clickButton(findButtonByText(container, "Test update prompt from i18n")); + + expect(mockTestUpdateWindow).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx b/src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx index 5a1c00e21..381f977f8 100644 --- a/src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx +++ b/src/components/settings-v2/system/experimental/UpdateCheckSettings.tsx @@ -12,6 +12,8 @@ import { useState, useEffect } from "react"; import { Bug } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { formatDate, formatNumber } from "@/i18n/format"; import { getUpdateCheckSettings, getUpdateNotificationMetrics, @@ -26,6 +28,7 @@ import { * 用于在设置页面中配置自动更新检查行为 */ export function UpdateCheckSettings() { + const { t, i18n } = useTranslation("settings"); const [settings, setSettings] = useState({ enabled: true, check_interval_hours: 24, @@ -110,21 +113,49 @@ export function UpdateCheckSettings() { "bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)]"; const selectedIntervalClassName = "border border-emerald-200 bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)] text-white shadow-sm shadow-emerald-950/15"; + const locale = i18n.language; + const intervalLabel = (hours: number) => + hours === 168 + ? t("settings.experimental.updateCheck.interval.weekly", "每周") + : t("settings.experimental.updateCheck.interval.hours", { + hours: formatNumber(hours, { locale }), + defaultValue: "{{hours}}小时", + }); + const formatTimestamp = (timestamp: number) => + formatDate(timestamp * 1000, { + locale, + dateStyle: "medium", + timeStyle: "short", + }); return (
-

自动更新检查

+

+ {t("settings.experimental.updateCheck.title", "自动更新检查")} +

- {/* 启用自动检查 */}
-
自动检查更新
+
+ {t( + "settings.experimental.updateCheck.autoCheck.title", + "自动检查更新", + )} +
- 定期检查是否有新版本可用 + {t( + "settings.experimental.updateCheck.autoCheck.description", + "定期检查是否有新版本可用", + )}
- {/* 显示通知 */}
-
显示更新通知
+
+ {t( + "settings.experimental.updateCheck.notification.title", + "显示更新通知", + )} +
- 发现新版本时显示弹窗提醒 + {t( + "settings.experimental.updateCheck.notification.description", + "发现新版本时显示弹窗提醒", + )}
- {/* 检查间隔 */}
-
检查间隔
+
+ {t( + "settings.experimental.updateCheck.interval.title", + "检查间隔", + )} +
{[12, 24, 48, 168].map((hours) => ( ))}
- {/* 已跳过的版本 */} {settings.skipped_version && (
-
已跳过版本
+
+ {t( + "settings.experimental.updateCheck.skippedVersion.title", + "已跳过版本", + )} +
{settings.skipped_version}
)} - {/* 上次检查时间 */} {settings.last_check_timestamp > 0 && (
- 上次检查:{" "} - {new Date(settings.last_check_timestamp * 1000).toLocaleString()} + {t("settings.experimental.updateCheck.lastCheck", { + time: formatTimestamp(settings.last_check_timestamp), + defaultValue: "上次检查: {{time}}", + })}
)} - {/* 稍后提醒状态 */} {settings.remind_later_until && settings.remind_later_until > Date.now() / 1000 && (
- 已设置稍后提醒至:{" "} - {new Date(settings.remind_later_until * 1000).toLocaleString()} + {t("settings.experimental.updateCheck.remindLaterUntil", { + time: formatTimestamp(settings.remind_later_until), + defaultValue: "已设置稍后提醒至: {{time}}", + })}
)} - {/* 更新提醒埋点 */}
-
提醒转化指标
-
- 展示 {metrics.shown_count} 次,立即更新 {metrics.update_now_count}{" "} - 次( - {metrics.update_now_rate}%) +
+ {t("settings.experimental.updateCheck.metrics.title", "提醒转化指标")}
- 稍后 {metrics.remind_later_count} 次({metrics.remind_later_rate} - %),跳过 {metrics.skip_version_count} 次( - {metrics.skip_version_rate}%) + {t("settings.experimental.updateCheck.metrics.updateNow", { + shown: formatNumber(metrics.shown_count, { locale }), + updateNow: formatNumber(metrics.update_now_count, { locale }), + rate: formatNumber(metrics.update_now_rate, { locale }), + defaultValue: "展示 {{shown}} 次,立即更新 {{updateNow}} 次({{rate}}%)", + })}
- 关闭 {metrics.dismiss_count} 次({metrics.dismiss_rate}%) + {t("settings.experimental.updateCheck.metrics.remindAndSkip", { + remindLater: formatNumber(metrics.remind_later_count, { locale }), + remindLaterRate: formatNumber(metrics.remind_later_rate, { + locale, + }), + skipVersion: formatNumber(metrics.skip_version_count, { locale }), + skipVersionRate: formatNumber(metrics.skip_version_rate, { + locale, + }), + defaultValue: + "稍后 {{remindLater}} 次({{remindLaterRate}}%),跳过 {{skipVersion}} 次({{skipVersionRate}}%)", + })} +
+
+ {t("settings.experimental.updateCheck.metrics.dismiss", { + dismiss: formatNumber(metrics.dismiss_count, { locale }), + rate: formatNumber(metrics.dismiss_rate, { locale }), + defaultValue: "关闭 {{dismiss}} 次({{rate}}%)", + })}
- {/* 测试按钮(仅开发环境) */} {import.meta.env.DEV && ( )}
diff --git a/src/components/settings-v2/system/web-search/index.tsx b/src/components/settings-v2/system/web-search/index.tsx index ca2e00e84..6a8963f07 100644 --- a/src/components/settings-v2/system/web-search/index.tsx +++ b/src/components/settings-v2/system/web-search/index.tsx @@ -71,8 +71,7 @@ const PIXABAY_APPLY_URL = "https://pixabay.com/accounts/register/"; const PIXABAY_DOC_URL = "https://pixabay.com/api/docs/"; const TAVILY_APPLY_URL = "https://app.tavily.com/"; const TAVILY_DOC_URL = "https://docs.tavily.com/"; -const MSE_DOC_URL = - "https://openclaw.ai/blog/openclaw-multi-search-engine-enhanced"; +const MSE_DOC_URL = "https://docs.tavily.com/"; const BING_SEARCH_APPLY_URL = "https://portal.azure.com/#create/Microsoft.CognitiveServicesBingSearch-v7"; const BING_SEARCH_DOC_URL = @@ -529,6 +528,43 @@ export function WebSearchSettings() { "settings.webSearch.status.notConfigured", "未配置", ); + const googleEngineLabel = t("settings.webSearch.engine.google", "Google"); + const xiaohongshuEngineLabel = t( + "settings.webSearch.engine.xiaohongshu", + "小红书", + ); + const tavilyProviderLabel = t( + "settings.webSearch.provider.tavily", + "Tavily Search API", + ); + const multiSearchEngineProviderLabel = t( + "settings.webSearch.provider.multiSearchEngine", + "Multi Search Engine v2.0.1", + ); + const duckduckgoInstantProviderLabel = t( + "settings.webSearch.provider.duckduckgoInstant", + "DuckDuckGo Instant Answer (免费)", + ); + const bingSearchApiProviderLabel = t( + "settings.webSearch.provider.bingSearchApi", + "Bing Search API", + ); + const googleCustomSearchProviderLabel = t( + "settings.webSearch.provider.googleCustomSearch", + "Google Custom Search API", + ); + const googleCseProviderLabel = t( + "settings.webSearch.provider.googleCse", + "Google CSE", + ); + const pexelsProviderLabel = t( + "settings.webSearch.provider.pexels", + "Pexels", + ); + const pixabayProviderLabel = t( + "settings.webSearch.provider.pixabay", + "Pixabay", + ); const credentialStatusLabel = (name: string, configured: boolean) => t("settings.webSearch.status.credential", { name, @@ -706,7 +742,10 @@ export function WebSearchSettings() {
{t("settings.webSearch.summary.engine", { - engine: draftEngine === "google" ? "Google" : "小红书", + engine: + draftEngine === "google" + ? googleEngineLabel + : xiaohongshuEngineLabel, defaultValue: "搜索引擎:{{engine}}", })} @@ -838,7 +877,9 @@ export function WebSearchSettings() { } className={`${INPUT_CLASS_NAME} h-11`} > - + @@ -860,19 +901,20 @@ export function WebSearchSettings() { } className={`${INPUT_CLASS_NAME} h-11`} > - + + - @@ -896,7 +938,10 @@ export function WebSearchSettings() { id="web-search-provider-priority" value={draftProviderPriority} onChange={(e) => setDraftProviderPriority(e.target.value)} - placeholder="tavily, multi_search_engine, bing_search_api, google_custom_search, duckduckgo_instant" + placeholder={t( + "settings.webSearch.searchChain.providerPriority.placeholder", + "tavily, multi_search_engine, bing_search_api, google_custom_search, duckduckgo_instant", + )} className={INPUT_CLASS_NAME} /> @@ -913,31 +958,31 @@ export function WebSearchSettings() { )}

- + @@ -1012,7 +1057,10 @@ export function WebSearchSettings() {
setDraftMsePriority(e.target.value)} - placeholder="google, bing, duckduckgo, brave" + placeholder={t( + "settings.webSearch.mse.priority.placeholder", + "google, bing, duckduckgo, brave", + )} className={INPUT_CLASS_NAME} /> @@ -1355,7 +1415,10 @@ export function WebSearchSettings() { onChange={(e) => setDraftMseCustomEngineTemplate(e.target.value) } - placeholder="https://example.com/search?q={query}" + placeholder={t( + "settings.webSearch.mse.customTemplate.placeholder", + "https://example.com/search?q={query}", + )} className={INPUT_CLASS_NAME} /> @@ -1426,11 +1489,17 @@ export function WebSearchSettings() { <> } @@ -1438,7 +1507,10 @@ export function WebSearchSettings() {
diff --git a/src/components/skills/SkillExecutionDialog.test.tsx b/src/components/skills/SkillExecutionDialog.test.tsx new file mode 100644 index 000000000..b15c275b3 --- /dev/null +++ b/src/components/skills/SkillExecutionDialog.test.tsx @@ -0,0 +1,202 @@ +import { act } from "react"; +import type React from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SkillExecutionDialog } from "./SkillExecutionDialog"; +import type { SkillDetailInfo } from "@/lib/api/skill-execution"; + +const mockGetSkillDetail = vi.fn(); +const mockUseSkillExecution = vi.fn(); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: ( + key: string, + fallbackOrOptions?: string | { defaultValue?: string }, + options?: Record, + ) => { + const values: Record & { defaultValue?: string } = + typeof fallbackOrOptions === "object" && fallbackOrOptions !== null + ? fallbackOrOptions + : (options ?? {}); + const template = + typeof fallbackOrOptions === "string" + ? fallbackOrOptions + : (values.defaultValue ?? key); + + return template.replace(/\{\{(\w+)\}\}/g, (_match: string, name: string) => + String(values[name] ?? ""), + ); + }, + }), +})); + +vi.mock("@/lib/api/skill-execution", () => ({ + skillExecutionApi: { + getSkillDetail: (...args: unknown[]) => mockGetSkillDetail(...args), + }, +})); + +vi.mock("@/hooks/useSkillExecution", () => ({ + useSkillExecution: (...args: unknown[]) => mockUseSkillExecution(...args), +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ + open, + children, + }: { + open?: boolean; + children: React.ReactNode; + }) => (open ?
{children}
: null), + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + ...props + }: React.ButtonHTMLAttributes) => ( + + ), +})); + +vi.mock("@/components/ui/textarea", () => ({ + Textarea: (props: React.TextareaHTMLAttributes) => ( +