mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.34.0
This commit is contained in:
@@ -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"
|
||||
@@ -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
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
|
||||
@@ -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围绕参考账号和目标平台先整理一版增长打法,再持续跟踪内容节奏、关键指标和提醒条件。"
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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 模块 |
|
||||
|
||||
@@ -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 入口语言打断。
|
||||
|
||||
@@ -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
|
||||
|
||||
可复用现有脚本:
|
||||
|
||||
|
||||
@@ -18,4 +18,3 @@
|
||||
|
||||
## 当前条目
|
||||
|
||||
- `openclaw-windows-detection-followup.md`:OpenClaw 在 Windows 下“已安装但未检测到”的后续迭代记录
|
||||
|
||||
@@ -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`
|
||||
+162
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -13,7 +13,7 @@ Lime 当前是一个本地优先的 Tauri 桌面应用,而不是单一前端
|
||||
- Rust 服务层与业务逻辑
|
||||
- 数据库、文件系统与工作区状态
|
||||
- Provider、协议转换与本地 HTTP Server
|
||||
- 浏览器运行时、终端、OpenClaw 等桌面能力
|
||||
- 浏览器运行时等桌面能力
|
||||
- Agent Runtime 与真实模型行为
|
||||
- macOS / Windows 平台差异
|
||||
|
||||
|
||||
@@ -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>`,其中 `<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>`,其中 `<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=<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",
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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 处理。
|
||||
|
||||
@@ -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. 与其他文档的关系
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 确认。
|
||||
|
||||
Generated
+65
-64
@@ -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": {
|
||||
|
||||
+8
-6
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@limecloud/lime-cli",
|
||||
"version": "1.33.0",
|
||||
"version": "1.34.0",
|
||||
"description": "Lime 官方任务 CLI",
|
||||
"bin": {
|
||||
"lime": "scripts/run.js"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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<redacted>")
|
||||
.replace(/(api[_-]?key(?:=|:|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1<redacted>")
|
||||
.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);
|
||||
|
||||
@@ -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: "<deterministic-test-workspace>",
|
||||
source: "manual_session_enable",
|
||||
approval: "manual",
|
||||
authorizationScope: "session",
|
||||
directory: "capability-report",
|
||||
registeredSkillDirectory:
|
||||
"<deterministic-test-workspace>/.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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 占位符",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<redacted>")
|
||||
.replace(/(api[_-]?key(?:=|:|\s+))(?:"[^"]+"|'[^']+'|\S+)/gi, "$1<redacted>")
|
||||
.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,
|
||||
};
|
||||
@@ -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 <redacted>",
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
Generated
+312
-124
File diff suppressed because it is too large
Load Diff
+11
-11
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//!
|
||||
//! ## 设计参考
|
||||
//!
|
||||
//! 类型设计参考 OpenClaw 的 Cron/Scheduler 系统
|
||||
//! 类型设计参考通用 Cron/Scheduler 系统
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono_tz::Tz;
|
||||
|
||||
@@ -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<String> {
|
||||
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::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -59,7 +59,6 @@ pub mod event_emit;
|
||||
|
||||
// 网络工具
|
||||
pub mod network;
|
||||
pub mod openclaw_install;
|
||||
|
||||
// 凭证清理(敏感信息过滤)
|
||||
pub mod sanitizer;
|
||||
|
||||
@@ -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<String> {
|
||||
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<PathBuf>) -> Option<PathBuf> {
|
||||
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<SemVer>)>,
|
||||
min_version: SemVer,
|
||||
) -> Option<PathBuf> {
|
||||
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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SyncModelEntry>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_check_installed(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<BinaryInstallStatus, String> {
|
||||
let service = service.0.lock().await;
|
||||
service.check_installed().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_get_environment_status(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<EnvironmentStatus, String> {
|
||||
let service = service.0.lock().await;
|
||||
service.get_environment_status().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_check_node_version(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<NodeCheckResult, String> {
|
||||
let service = service.0.lock().await;
|
||||
service.check_node_version().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_check_git_available(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<BinaryAvailabilityStatus, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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<u16>,
|
||||
) -> Result<CommandPreview, String> {
|
||||
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<ActionResult, String> {
|
||||
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<ActionResult, String> {
|
||||
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<ActionResult, String> {
|
||||
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<UpdateInfo, String> {
|
||||
let service = service.0.lock().await;
|
||||
service.check_update().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_uninstall(
|
||||
app: AppHandle,
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<ActionResult, String> {
|
||||
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<ActionResult, String> {
|
||||
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<u16>,
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<ActionResult, String> {
|
||||
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<ActionResult, String> {
|
||||
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<ActionResult, String> {
|
||||
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<Vec<InstallProgressEvent>, 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<Vec<OpenClawRuntimeCandidate>, String> {
|
||||
let service = service.0.lock().await;
|
||||
service.list_runtime_candidates().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_set_preferred_runtime(
|
||||
runtime_id: Option<String>,
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<ActionResult, String> {
|
||||
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<GatewayStatusInfo, String> {
|
||||
let mut service = service.0.lock().await;
|
||||
service.get_status().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_check_health(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<HealthInfo, String> {
|
||||
let mut service = service.0.lock().await;
|
||||
service.check_health().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_get_dashboard_url(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<String, String> {
|
||||
let mut service = service.0.lock().await;
|
||||
Ok(service.get_dashboard_url())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn openclaw_get_channels(
|
||||
service: State<'_, OpenClawServiceState>,
|
||||
) -> Result<Vec<ChannelInfo>, 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<ActionResult, String> {
|
||||
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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<dyn std::error::Error>;
|
||||
type OpenClawServiceHandle = Arc<Mutex<crate::services::openclaw_service::OpenClawService>>;
|
||||
|
||||
fn openclaw_service_handle(app_handle: &AppHandle) -> OpenClawServiceHandle {
|
||||
app_handle
|
||||
.state::<crate::services::openclaw_service::OpenClawServiceState>()
|
||||
.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<Option<JsonValue>, DynError> {
|
||||
if let Some(result) = queries::try_handle(state, cmd, args).await? {
|
||||
return Ok(Some(result));
|
||||
}
|
||||
|
||||
operations::try_handle(state, cmd, args).await
|
||||
}
|
||||
@@ -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<Option<JsonValue>, 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))
|
||||
}
|
||||
@@ -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<Option<JsonValue>, 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))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ActionResult, String> {
|
||||
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::<Vec<_>>()
|
||||
}),
|
||||
)),
|
||||
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<String>,
|
||||
) -> 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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<String, Value> {
|
||||
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<String, Value> {
|
||||
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<String> {
|
||||
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<String, String> {
|
||||
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<Regex> = OnceLock::new();
|
||||
VERSION_RE
|
||||
.get_or_init(|| Regex::new(r"/v\d+(?:[./]|$)").expect("regex should compile"))
|
||||
.is_match(url)
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
impl OpenClawService {
|
||||
pub async fn get_environment_status(&self) -> Result<EnvironmentStatus, String> {
|
||||
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<BinaryInstallStatus, String> {
|
||||
let openclaw = inspect_openclaw_dependency_status().await?;
|
||||
Ok(BinaryInstallStatus {
|
||||
installed: openclaw.status == "ok",
|
||||
path: openclaw.path,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn check_git_available(&self) -> Result<BinaryAvailabilityStatus, String> {
|
||||
let git = inspect_git_dependency_status().await?;
|
||||
Ok(BinaryAvailabilityStatus {
|
||||
available: git.status == "ok",
|
||||
path: git.path,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn check_node_version(&self) -> Result<NodeCheckResult, String> {
|
||||
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<ActionResult> {
|
||||
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<DependencyStatus, String> {
|
||||
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<DependencyStatus, String> {
|
||||
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<DependencyStatus, String> {
|
||||
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<Option<DependencyStatus>, 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<bool, String> {
|
||||
#[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<String, String> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return collect_existing_unique_dirs(windows_known_git_command_dirs_from_env());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -1,871 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
impl OpenClawService {
|
||||
pub async fn start_gateway(
|
||||
&mut self,
|
||||
app: Option<&AppHandle>,
|
||||
port: Option<u16>,
|
||||
) -> Result<ActionResult, String> {
|
||||
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::<String>::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<ActionResult, String> {
|
||||
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<ActionResult, String> {
|
||||
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<GatewayStatusInfo, String> {
|
||||
self.refresh_process_state().await?;
|
||||
Ok(GatewayStatusInfo {
|
||||
status: self.gateway_status.clone(),
|
||||
port: self.gateway_port,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn check_health(&mut self) -> Result<HealthInfo, String> {
|
||||
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<Vec<ChannelInfo>, 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<bool, String> {
|
||||
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<Vec<ResolvedOpenClawCommand>, 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<bool, String> {
|
||||
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::<Vec<_>>()
|
||||
.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<bool, String> {
|
||||
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<Option<String>, 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<Value> {
|
||||
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::<Value>(&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<String> {
|
||||
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<String>, 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(),
|
||||
}
|
||||
}
|
||||
@@ -1,810 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
impl OpenClawService {
|
||||
pub async fn install(&mut self, app: &AppHandle) -> Result<ActionResult, String> {
|
||||
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<ActionResult, String> {
|
||||
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<ActionResult, String> {
|
||||
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<ActionResult, String> {
|
||||
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<ActionResult, String> {
|
||||
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<DependencyStatus, String> {
|
||||
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<ActionResult, String> {
|
||||
#[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<ActionResult, String> {
|
||||
#[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<ActionResult, String> {
|
||||
// 在 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<ActionResult, String> {
|
||||
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), 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), 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<PathBuf, String> {
|
||||
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<PathBuf> {
|
||||
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<InstallerAsset, String> {
|
||||
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<Value> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析 Node.js 版本列表失败: {e}"))?;
|
||||
|
||||
let select_version = |only_lts: bool| -> Option<String> {
|
||||
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<PathBuf, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
Err("当前平台不支持拉起 macOS 开发者工具安装器。".to_string())
|
||||
}
|
||||
@@ -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<StdMutex<Option<PathBuf>>> = 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<Child>,
|
||||
gateway_status: GatewayStatus,
|
||||
gateway_port: u16,
|
||||
gateway_auth_token: String,
|
||||
gateway_started_at: Option<SystemTime>,
|
||||
progress_logs: VecDeque<InstallProgressEvent>,
|
||||
}
|
||||
|
||||
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<Mutex<OpenClawService>>);
|
||||
|
||||
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<InstallProgressEvent> {
|
||||
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
|
||||
}
|
||||
@@ -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::<AppState>() {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<OsString> {
|
||||
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<String, String> {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::ptr;
|
||||
|
||||
let subkey: Vec<u16> = OsStr::new("Environment")
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
let value_name: Vec<u16> = 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<u16> = 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(())
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
impl OpenClawService {
|
||||
pub async fn get_command_preview(
|
||||
&mut self,
|
||||
app: &AppHandle,
|
||||
operation: &str,
|
||||
port: Option<u16>,
|
||||
) -> Result<CommandPreview, String> {
|
||||
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<CommandPreview, String> {
|
||||
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<CommandPreview, String> {
|
||||
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<u16>,
|
||||
) -> Result<CommandPreview, String> {
|
||||
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::<Vec<_>>()
|
||||
.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<u16>,
|
||||
) -> Result<CommandPreview, String> {
|
||||
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<u16>,
|
||||
) -> Result<CommandPreview, String> {
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn command_output_lines(output: &[u8]) -> Vec<String> {
|
||||
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::<Vec<_>>()
|
||||
.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<Pid> {
|
||||
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::<Vec<_>>();
|
||||
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<u32> {
|
||||
let mut pids = output
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().parse::<u32>().ok())
|
||||
.collect::<Vec<_>>();
|
||||
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<u32> {
|
||||
let mut pids = output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let columns = line.split_whitespace().collect::<Vec<_>>();
|
||||
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::<u32>().ok())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
pids.sort_unstable();
|
||||
pids.dedup();
|
||||
pids
|
||||
}
|
||||
|
||||
pub(crate) async fn collect_listening_port_pids(port: u16) -> Vec<u32> {
|
||||
#[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<u32> {
|
||||
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<u32> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) async fn run_shell_command_with_progress(
|
||||
app: &AppHandle,
|
||||
command_line: &str,
|
||||
) -> Result<ActionResult, String> {
|
||||
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<Child, String> {
|
||||
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<R>(
|
||||
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::<OpenClawServiceState>() {
|
||||
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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,299 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BinaryInstallStatus {
|
||||
pub installed: bool,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BinaryAvailabilityStatus {
|
||||
pub available: bool,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NodeCheckResult {
|
||||
pub status: String,
|
||||
pub version: Option<String>,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub path: Option<String>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EnvironmentDiagnostics {
|
||||
pub npm_path: Option<String>,
|
||||
pub npm_global_prefix: Option<String>,
|
||||
pub openclaw_package_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub where_candidates: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub supplemental_search_dirs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub supplemental_command_candidates: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub git_where_candidates: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub git_supplemental_search_dirs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub git_supplemental_command_candidates: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<u64>,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateInfo {
|
||||
pub has_update: bool,
|
||||
pub current_version: Option<String>,
|
||||
pub latest_version: Option<String>,
|
||||
pub channel: Option<String>,
|
||||
pub install_kind: Option<String>,
|
||||
pub package_manager: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub npm_path: Option<String>,
|
||||
pub npm_global_prefix: Option<String>,
|
||||
pub openclaw_path: Option<String>,
|
||||
pub openclaw_version: Option<String>,
|
||||
pub openclaw_package_path: Option<String>,
|
||||
pub is_active: bool,
|
||||
pub is_preferred: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub(crate) struct OpenClawUpdateExecutionContext {
|
||||
pub(crate) root: Option<PathBuf>,
|
||||
pub(crate) install_kind: Option<String>,
|
||||
pub(crate) package_manager: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ResolvedOpenClawCommand {
|
||||
pub(crate) fn build_command_with_args<I, S>(&self, args: I) -> Command
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
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<String> {
|
||||
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<u64>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
@@ -1,747 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
impl OpenClawService {
|
||||
pub async fn check_update(&self) -> Result<UpdateInfo, String> {
|
||||
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<ActionResult, String> {
|
||||
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::<Value>(&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<String>,
|
||||
) -> Result<ActionResult, String> {
|
||||
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<String> {
|
||||
static VERSION_RE: OnceLock<Regex> = 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<Value, String> {
|
||||
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::<Value>(&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<String> {
|
||||
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<OpenClawDirectUpgradeResult, String> {
|
||||
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<Option<OpenClawDirectUpgradePlan>, 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<String, String> {
|
||||
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))
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+39
-31
@@ -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 = (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
页面加载中...
|
||||
</div>
|
||||
);
|
||||
|
||||
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<string, string> = {
|
||||
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 = (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
{t("common.app.loadingPage")}
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleSplashComplete = useCallback(() => {
|
||||
startupTracker.mark("SplashScreen: complete");
|
||||
|
||||
@@ -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 = (
|
||||
<div
|
||||
style={{
|
||||
...columnPageStyle,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<OpenClawPage
|
||||
onNavigate={onNavigate}
|
||||
pageParams={activePageParams as OpenClawPageParams}
|
||||
isActive={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return wrapWithSceneApps(content);
|
||||
}
|
||||
|
||||
if (activePage === "skills") {
|
||||
const content = (
|
||||
<div style={columnPageStyle}>
|
||||
|
||||
@@ -277,6 +277,33 @@ function buildMockReferralDashboard() {
|
||||
};
|
||||
}
|
||||
|
||||
function seedCloudSessionWithReferral(options?: {
|
||||
referralEnabled?: boolean;
|
||||
referral?: ReturnType<typeof buildMockReferralDashboard> | 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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'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);
|
||||
|
||||
|
||||
+368
-62
@@ -196,7 +196,6 @@ const SIDEBAR_NAV_LABEL_KEYS: Record<string, string> = {
|
||||
"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<LimeThemeMode, { label: string; description: string }>;
|
||||
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<LimeColorSchemeId, { label: string; description: string }>;
|
||||
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"
|
||||
>
|
||||
<Gift />
|
||||
<span>邀请好友</span>
|
||||
<span>{inviteEntryLabel}</span>
|
||||
</HeaderInviteButton>,
|
||||
"邀请好友",
|
||||
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({
|
||||
)}
|
||||
</IconActionButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">快速切换外观</TooltipContent>
|
||||
<TooltipContent side="right">
|
||||
{appearanceEntryLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<IconActionButton
|
||||
@@ -4612,8 +4905,8 @@ export function AppSidebar({
|
||||
setLanguageMenuOpen(false);
|
||||
setAppearancePopoverOpen((current) => !current);
|
||||
}}
|
||||
title="快速切换外观"
|
||||
aria-label="快速切换外观"
|
||||
title={appearanceEntryLabel}
|
||||
aria-label={appearanceEntryLabel}
|
||||
aria-expanded={appearancePopoverOpen}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
@@ -4629,22 +4922,24 @@ export function AppSidebar({
|
||||
<AppearancePopover
|
||||
data-testid="app-sidebar-appearance-popover"
|
||||
role="dialog"
|
||||
aria-label="快速切换外观"
|
||||
aria-label={appearanceEntryLabel}
|
||||
>
|
||||
<AppearancePopoverHeader>
|
||||
<AppearancePopoverTitle>
|
||||
<Palette />
|
||||
外观
|
||||
{appearanceTitleLabel}
|
||||
</AppearancePopoverTitle>
|
||||
<AppearancePopoverSummary>
|
||||
{currentThemeLabel} · {currentColorScheme.label}
|
||||
{appearanceSummaryLabel}
|
||||
</AppearancePopoverSummary>
|
||||
</AppearancePopoverHeader>
|
||||
|
||||
<AppearanceGroup>
|
||||
<AppearanceGroupLabel>主题</AppearanceGroupLabel>
|
||||
<AppearanceGroupLabel>
|
||||
{appearanceThemeGroupLabel}
|
||||
</AppearanceGroupLabel>
|
||||
<ThemeModeGrid>
|
||||
{LIME_THEME_MODE_OPTIONS.map((option) => {
|
||||
{appearanceThemeOptions.map((option) => {
|
||||
const active = option.id === themeState.themeMode;
|
||||
return (
|
||||
<ThemeModeButton
|
||||
@@ -4652,7 +4947,13 @@ export function AppSidebar({
|
||||
$active={active}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
aria-label={`切换主题为${option.label}`}
|
||||
aria-label={t(
|
||||
"navigation.sidebar.appearance.theme.switchAria",
|
||||
{
|
||||
theme: option.label,
|
||||
defaultValue: "切换主题为{{theme}}",
|
||||
},
|
||||
)}
|
||||
title={option.description}
|
||||
onClick={() => handleThemeModeChange(option.id)}
|
||||
>
|
||||
@@ -4665,18 +4966,20 @@ export function AppSidebar({
|
||||
</AppearanceGroup>
|
||||
|
||||
<AppearanceGroup>
|
||||
<AppearanceGroupLabel>配色</AppearanceGroupLabel>
|
||||
<AppearanceGroupLabel>
|
||||
{appearanceColorSchemeGroupLabel}
|
||||
</AppearanceGroupLabel>
|
||||
<ColorSchemeList>
|
||||
<RandomColorSchemeButton
|
||||
type="button"
|
||||
aria-label="随机切换配色"
|
||||
title="随机切换一个颜色主题"
|
||||
aria-label={appearanceRandomColorSchemeAriaLabel}
|
||||
title={appearanceRandomColorSchemeTitle}
|
||||
onClick={handleRandomColorScheme}
|
||||
>
|
||||
<Shuffle />
|
||||
<span>随机</span>
|
||||
<span>{appearanceRandomColorSchemeLabel}</span>
|
||||
</RandomColorSchemeButton>
|
||||
{LIME_COLOR_SCHEMES.map((scheme) => {
|
||||
{appearanceColorSchemes.map((scheme) => {
|
||||
const active = scheme.id === colorSchemeId;
|
||||
return (
|
||||
<ColorSchemeButton
|
||||
@@ -4684,7 +4987,13 @@ export function AppSidebar({
|
||||
$active={active}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
aria-label={`切换配色为${scheme.label}`}
|
||||
aria-label={t(
|
||||
"navigation.sidebar.appearance.colorScheme.switchAria",
|
||||
{
|
||||
colorScheme: scheme.label,
|
||||
defaultValue: "切换配色为{{colorScheme}}",
|
||||
},
|
||||
)}
|
||||
title={scheme.description}
|
||||
onClick={() => handleColorSchemeChange(scheme.id)}
|
||||
>
|
||||
@@ -5168,26 +5477,23 @@ export function AppSidebar({
|
||||
<InviteDialogSurface data-testid="app-sidebar-invite-dialog">
|
||||
<InviteDialogCloseButton
|
||||
type="button"
|
||||
aria-label="关闭邀请弹窗"
|
||||
aria-label={inviteCloseDialogLabel}
|
||||
onClick={() => setInviteDialogOpen(false)}
|
||||
>
|
||||
<X />
|
||||
</InviteDialogCloseButton>
|
||||
<InviteDialogHeader>
|
||||
<InviteDialogEyebrow>
|
||||
{inviteShare?.brandName ?? accountTenantLabel ?? "Lime"} 邀请
|
||||
</InviteDialogEyebrow>
|
||||
<InviteDialogTitle>邀请好友</InviteDialogTitle>
|
||||
<InviteDialogEyebrow>{inviteEyebrowLabel}</InviteDialogEyebrow>
|
||||
<InviteDialogTitle>{inviteDialogTitleLabel}</InviteDialogTitle>
|
||||
<InviteDialogDescription>
|
||||
{inviteHeadline}
|
||||
{!inviteLoading && !inviteError ? `。${inviteRules}` : ""}
|
||||
{inviteDescriptionLabel}
|
||||
</InviteDialogDescription>
|
||||
</InviteDialogHeader>
|
||||
|
||||
<InviteDialogBody>
|
||||
{!hasCloudAccount ? (
|
||||
<InviteStatusCard>
|
||||
{`连接 ${cloudBrandLabel} 后会生成专属邀请码,并自动读取当前品牌云端的域名和奖励策略。`}
|
||||
{inviteDisconnectedLabel}
|
||||
<InviteActionBar style={{ marginTop: 10 }}>
|
||||
<InviteDialogActionButton
|
||||
type="button"
|
||||
@@ -5198,14 +5504,14 @@ export function AppSidebar({
|
||||
}}
|
||||
>
|
||||
<Cloud />
|
||||
连接云端账号
|
||||
{inviteConnectAccountLabel}
|
||||
</InviteDialogActionButton>
|
||||
</InviteActionBar>
|
||||
</InviteStatusCard>
|
||||
) : null}
|
||||
|
||||
{hasCloudAccount && inviteLoading ? (
|
||||
<InviteStatusCard>正在从云端同步邀请信息...</InviteStatusCard>
|
||||
<InviteStatusCard>{inviteLoadingLabel}</InviteStatusCard>
|
||||
) : null}
|
||||
|
||||
{hasCloudAccount && inviteError ? (
|
||||
@@ -5217,7 +5523,7 @@ export function AppSidebar({
|
||||
onClick={() => setInviteReloadKey((value) => value + 1)}
|
||||
>
|
||||
<RefreshCw />
|
||||
重试
|
||||
{inviteRetryLabel}
|
||||
</InviteDialogActionButton>
|
||||
</InviteActionBar>
|
||||
</InviteStatusCard>
|
||||
@@ -5230,7 +5536,7 @@ export function AppSidebar({
|
||||
<InviteShareCard>
|
||||
<InviteCodeBlock>
|
||||
<InviteCodeMeta>
|
||||
<InviteCodeLabel>邀请码</InviteCodeLabel>
|
||||
<InviteCodeLabel>{inviteCodeLabel}</InviteCodeLabel>
|
||||
<InviteCodeValue>{inviteShare?.code}</InviteCodeValue>
|
||||
</InviteCodeMeta>
|
||||
<InviteDialogActionButton
|
||||
@@ -5238,36 +5544,36 @@ export function AppSidebar({
|
||||
onClick={() =>
|
||||
void handleCopyInviteText(
|
||||
inviteShare?.code,
|
||||
"已复制邀请码",
|
||||
inviteCopyCodeSuccessLabel,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy />
|
||||
复制
|
||||
{inviteCopyLabel}
|
||||
</InviteDialogActionButton>
|
||||
</InviteCodeBlock>
|
||||
|
||||
<InviteMetaGrid>
|
||||
<InviteMetaItem>
|
||||
<span>下载地址</span>
|
||||
<span>{inviteDownloadUrlLabel}</span>
|
||||
<strong>{inviteShare?.downloadUrl}</strong>
|
||||
</InviteMetaItem>
|
||||
<InviteMetaItem>
|
||||
<span>邀请链接</span>
|
||||
<span>{inviteLandingUrlLabel}</span>
|
||||
<strong>{inviteShare?.landingUrl}</strong>
|
||||
</InviteMetaItem>
|
||||
<InviteMetaItem>
|
||||
<span>邀请人奖励</span>
|
||||
<span>{inviteReferrerRewardLabel}</span>
|
||||
<strong>
|
||||
{formatReferralCredits(
|
||||
{formatInviteReferralCredits(
|
||||
invitePolicy?.referrerRewardCredits,
|
||||
)}
|
||||
</strong>
|
||||
</InviteMetaItem>
|
||||
<InviteMetaItem>
|
||||
<span>被邀请人奖励</span>
|
||||
<span>{inviteInviteeRewardLabel}</span>
|
||||
<strong>
|
||||
{formatReferralCredits(
|
||||
{formatInviteReferralCredits(
|
||||
invitePolicy?.inviteeRewardCredits,
|
||||
)}
|
||||
</strong>
|
||||
@@ -5281,24 +5587,24 @@ export function AppSidebar({
|
||||
onClick={() =>
|
||||
void handleCopyInviteText(
|
||||
inviteShare?.shareText,
|
||||
"已复制邀请文案",
|
||||
inviteCopyShareTextSuccessLabel,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy />
|
||||
复制邀请文案
|
||||
{inviteCopyShareTextLabel}
|
||||
</InviteDialogActionButton>
|
||||
<InviteDialogActionButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void handleCopyInviteText(
|
||||
inviteShare?.landingUrl,
|
||||
"已复制邀请链接",
|
||||
inviteCopyLandingUrlSuccessLabel,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ExternalLink />
|
||||
复制邀请链接
|
||||
{inviteCopyLandingUrlLabel}
|
||||
</InviteDialogActionButton>
|
||||
</InviteActionBar>
|
||||
</InviteShareCard>
|
||||
|
||||
@@ -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<string, string>
|
||||
)[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(
|
||||
<ConfirmDialog
|
||||
isOpen={true}
|
||||
message="This cannot be undone."
|
||||
onCancel={() => {}}
|
||||
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.");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Modal
|
||||
@@ -52,7 +69,7 @@ export function ConfirmDialog({
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<h3 className="text-lg font-semibold">{resolvedTitle}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,13 +78,13 @@ export function ConfirmDialog({
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border px-4 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
{cancelText}
|
||||
{resolvedCancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`rounded-lg px-4 py-2 text-sm ${styles.button}`}
|
||||
>
|
||||
{confirmText}
|
||||
{resolvedConfirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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随后补齐自动续提。";
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 产物", () => {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -73,13 +73,12 @@ export function AgentUiTeamWorkbenchSurfaceView({
|
||||
工作台操作视图
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] leading-4 text-slate-500">
|
||||
按 Agent UI v0.6 surface
|
||||
展示可操作目标,避免在组件内重新推断队友状态。
|
||||
按 Agent UI v0.6 工作区展示可操作目标,避免在组件内重新推断队友状态。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
|
||||
{model.total} items
|
||||
{model.total} 项
|
||||
</span>
|
||||
{model.attentionCount > 0 ? (
|
||||
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
|
||||
|
||||
@@ -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<CuratedTaskInputValues>({});
|
||||
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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<EmptyStateProps> = ({
|
||||
fileManagerOpen = false,
|
||||
onToggleFileManager,
|
||||
}) => {
|
||||
const { t } = useTranslation("agent");
|
||||
const curatedTaskTemplateCopy = useMemo(
|
||||
() =>
|
||||
buildCuratedTaskTemplateCopy((key, defaultValue, values) =>
|
||||
t(key, { defaultValue, ...values }),
|
||||
),
|
||||
[t],
|
||||
);
|
||||
const pageContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [activeCapability, setActiveCapability] =
|
||||
useState<InputCapabilitySelection | null>(null);
|
||||
@@ -759,8 +767,12 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
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<EmptyStateProps> = ({
|
||||
}
|
||||
|
||||
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<EmptyStateProps> = ({
|
||||
[
|
||||
activeCuratedTask,
|
||||
appendSelectedTextToRecommendation,
|
||||
curatedTaskTemplateCopy,
|
||||
handleThemeChange,
|
||||
input,
|
||||
onLaunchBrowserAssist,
|
||||
@@ -1294,7 +1308,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
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<EmptyStateProps> = ({
|
||||
}
|
||||
},
|
||||
[
|
||||
curatedTaskTemplateCopy,
|
||||
effectiveDefaultCuratedTaskReferenceEntries,
|
||||
effectiveDefaultCuratedTaskReferenceMemoryIds,
|
||||
handleCuratedTaskLauncherRequest,
|
||||
@@ -1399,7 +1417,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
: 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<EmptyStateProps> = ({
|
||||
}
|
||||
},
|
||||
[
|
||||
curatedTaskTemplateCopy,
|
||||
effectiveDefaultCuratedTaskReferenceEntries,
|
||||
effectiveDefaultCuratedTaskReferenceMemoryIds,
|
||||
handleSelectHomeSkillItem,
|
||||
|
||||
@@ -44,6 +44,31 @@ vi.mock("sonner", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "zh-CN" },
|
||||
t: (
|
||||
key: string,
|
||||
defaultValueOrOptions?:
|
||||
| string
|
||||
| ({ defaultValue?: string } & Record<string, unknown>),
|
||||
) => {
|
||||
if (typeof defaultValueOrOptions === "string") {
|
||||
return defaultValueOrOptions;
|
||||
}
|
||||
|
||||
if (
|
||||
defaultValueOrOptions &&
|
||||
typeof defaultValueOrOptions.defaultValue === "string"
|
||||
) {
|
||||
return defaultValueOrOptions.defaultValue;
|
||||
}
|
||||
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border border-emerald-200 bg-white px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||
围绕最近判断
|
||||
{t("reviewFeedback.badge", "围绕最近判断")}
|
||||
</span>
|
||||
{!projection.matchedCurrentTask &&
|
||||
projection.suggestedTaskTitles.length > 0 ? (
|
||||
@@ -613,7 +617,13 @@ function ReviewFeedbackProjectionCard({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] font-medium leading-5 text-slate-900">
|
||||
最近判断已更新:{projection.signal.title}
|
||||
{formatReviewFeedbackTemplate(
|
||||
t("reviewFeedback.title", {
|
||||
defaultValue: "最近判断已更新:{{title}}",
|
||||
title: projection.signal.title,
|
||||
}),
|
||||
{ title: projection.signal.title },
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-5 text-slate-500">
|
||||
{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 },
|
||||
)}
|
||||
</button>
|
||||
<span className="text-[10px] leading-5 text-slate-500">
|
||||
会继续带着这轮结果与参考对象,不用重新整理一遍。
|
||||
{t(
|
||||
"reviewFeedback.helper.generalWorkbench",
|
||||
"会继续带着这轮结果与参考对象,不用重新整理一遍。",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : 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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ import {
|
||||
formatAgentUiProjectionEventDetail,
|
||||
formatAgentUiProjectionEventType,
|
||||
formatAgentUiProjectionPhase,
|
||||
formatAgentUiProjectionSourceType,
|
||||
summarizeAgentUiProjectionEvents,
|
||||
} from "../projection/agentUiProjectionSummary";
|
||||
import {
|
||||
@@ -5855,8 +5856,10 @@ export function HarnessStatusPanel({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono text-foreground">
|
||||
{event.sourceType}
|
||||
<span className="text-foreground">
|
||||
{formatAgentUiProjectionSourceType(
|
||||
event.sourceType,
|
||||
)}
|
||||
</span>
|
||||
<span className="mx-1">·</span>
|
||||
{formatAgentUiProjectionEventDetail(event)}
|
||||
|
||||
@@ -151,6 +151,7 @@ const mockAgentThreadTimeline = vi.fn(
|
||||
actionRequests,
|
||||
onOpenSavedSiteContent,
|
||||
placement,
|
||||
turn,
|
||||
}: {
|
||||
actionRequests?: Array<Record<string, unknown>>;
|
||||
onOpenSavedSiteContent?: (target: {
|
||||
@@ -160,10 +161,12 @@ const mockAgentThreadTimeline = vi.fn(
|
||||
}) => void;
|
||||
deferCompletedSingleDetails?: boolean;
|
||||
placement?: "leading" | "trailing" | "default";
|
||||
turn?: { id?: string } | null;
|
||||
}) => (
|
||||
<div
|
||||
data-testid={`agent-thread-timeline:${placement || "default"}`}
|
||||
data-has-open-saved-site-content={onOpenSavedSiteContent ? "yes" : "no"}
|
||||
data-turn-id={turn?.id || ""}
|
||||
>
|
||||
执行轨迹{actionRequests?.length ? `:${actionRequests.length}` : ""}
|
||||
</div>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1429,11 +1429,13 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
lastAssistantMessageId,
|
||||
timelineByMessageId,
|
||||
renderedThreadItems,
|
||||
renderedMessages,
|
||||
});
|
||||
}, [
|
||||
activeCurrentTurn,
|
||||
activeCurrentTurnId,
|
||||
lastAssistantMessageId,
|
||||
renderedMessages,
|
||||
renderedThreadItems,
|
||||
timelineByMessageId,
|
||||
]);
|
||||
@@ -2404,8 +2406,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
const shouldRenderPrimaryTimelineOutsideBubble =
|
||||
msg.role === "assistant" &&
|
||||
Boolean(primaryTimelineNode) &&
|
||||
hasVisibleAssistantText &&
|
||||
!msg.isThinking;
|
||||
hasVisibleAssistantText;
|
||||
|
||||
return (
|
||||
<MessageWrapper
|
||||
|
||||
@@ -402,30 +402,30 @@ describe("TeamWorkbenchSummaryPanel", () => {
|
||||
});
|
||||
|
||||
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<HTMLButtonElement>(
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string>,
|
||||
@@ -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({
|
||||
<Workflow className="h-3.5 w-3.5" />
|
||||
<span>Agent UI v0.6</span>
|
||||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-emerald-700 normal-case">
|
||||
{teamWorkbenchProjectionSummary.total} events
|
||||
{teamWorkbenchProjectionSummary.total} 条事件
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
@@ -1288,7 +1341,9 @@ export function TeamWorkbenchSummaryPanel({
|
||||
<div className="mt-1 text-lg font-semibold text-slate-900">
|
||||
{card.value}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500">{card.hint}</div>
|
||||
<div className="text-[10px] text-slate-500">
|
||||
{formatAgentUiSurfaceLabel(card.hint)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1322,7 +1377,11 @@ export function TeamWorkbenchSummaryPanel({
|
||||
<span className="font-semibold text-slate-700">
|
||||
{formatAgentUiProjectionEventType(event.type)}
|
||||
</span>
|
||||
{event.surface ? <span>{event.surface}</span> : null}
|
||||
{event.surface ? (
|
||||
<span>
|
||||
{formatAgentUiSurfaceLabel(event.surface)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-slate-500">
|
||||
{formatAgentUiProjectionEventDetail(event)}
|
||||
@@ -1356,14 +1415,14 @@ export function TeamWorkbenchSummaryPanel({
|
||||
</span>
|
||||
{selectedWorkbenchItem.event.surface ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[10px] text-slate-500">
|
||||
{selectedWorkbenchItem.event.surface}
|
||||
{formatAgentUiSurfaceLabel(
|
||||
selectedWorkbenchItem.event.surface,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-4 text-slate-600">
|
||||
这里只定位 Agent UI 标准 surface
|
||||
中的目标,不从文本推断状态,也不伪造 remote / review / handoff
|
||||
运行时调用。
|
||||
这里只定位 Agent UI 标准工作区中的目标,不从文本推断状态,也不伪造远端、评审或交接运行时调用。
|
||||
</p>
|
||||
{selectedWorkbenchActionRouteStatus ? (
|
||||
<div
|
||||
@@ -1409,13 +1468,12 @@ export function TeamWorkbenchSummaryPanel({
|
||||
负责人重指派
|
||||
</span>
|
||||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10px] text-emerald-700">
|
||||
TaskUpdate owner_change
|
||||
负责人更新
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-4 text-slate-600">
|
||||
选择目标负责人后只回填 TaskUpdate 指令;发送并执行后,Agent
|
||||
UI 仍以 TaskUpdate 返回的 owner_change / ownerChange
|
||||
metadata 作为唯一负责人变化 source。
|
||||
选择目标负责人后只回填负责人更新指令;发送并执行后,Agent
|
||||
UI 仍以运行时返回的负责人变化作为唯一事实。
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
@@ -1465,13 +1523,11 @@ export function TeamWorkbenchSummaryPanel({
|
||||
相关队友链路
|
||||
</span>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-500">
|
||||
{selectedWorkbenchRelatedEvents.length} events
|
||||
{selectedWorkbenchRelatedEvents.length} 条事件
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-4 text-slate-600">
|
||||
按 agent / task / work item / transcript / artifact ref
|
||||
精确匹配同一目标的 projection
|
||||
事件;这里只串联队友状态,不合成新运行时事实。
|
||||
按成员、任务、工作项、队友记录与产物引用精确匹配同一目标的事件;这里只串联队友状态,不合成新运行时事实。
|
||||
</p>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{selectedWorkbenchRelatedEvents.map((event) => {
|
||||
@@ -1490,7 +1546,9 @@ export function TeamWorkbenchSummaryPanel({
|
||||
{formatAgentUiProjectionPhase(event.phase)}
|
||||
</span>
|
||||
{event.surface ? (
|
||||
<span>{event.surface}</span>
|
||||
<span>
|
||||
{formatAgentUiSurfaceLabel(event.surface)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-slate-500">
|
||||
@@ -1511,20 +1569,18 @@ export function TeamWorkbenchSummaryPanel({
|
||||
<div className="mt-3 rounded-2xl border border-slate-200 bg-white px-3 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="font-semibold text-slate-900">
|
||||
Transcript Zoom
|
||||
队友记录详情
|
||||
</span>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-500">
|
||||
teammate_transcript
|
||||
队友记录
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-4 text-slate-600">
|
||||
正文由子会话 / Team board 选中成员 activity preview
|
||||
读取;这里仅展示标准 ref 与焦点,不把队友输出混进主 final
|
||||
answer。
|
||||
正文由子会话 / 团队任务板选中成员活动预览读取;这里仅展示标准引用与焦点,不把队友输出混进主回复。
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-600">
|
||||
Transcript Ref:
|
||||
记录引用:
|
||||
{selectedWorkbenchTranscriptZoom.transcriptRef}
|
||||
</span>
|
||||
{selectedWorkbenchTranscriptZoom.parentSessionId ? (
|
||||
@@ -1598,9 +1654,7 @@ export function TeamWorkbenchSummaryPanel({
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-4 text-slate-600">
|
||||
这里只消费子会话详情里的 queued_turns 与 thread item
|
||||
类型;没有结构化 source 时不合成 pending / tool /
|
||||
message 状态。
|
||||
这里只消费子会话详情里的输入队列与历史正文类型;没有结构化记录时不合成等待、工具或消息状态。
|
||||
</p>
|
||||
{selectedTranscriptQueuedTurns.length > 0 ? (
|
||||
<div className="mt-2 rounded-xl border border-amber-200 bg-amber-50 px-2 py-2">
|
||||
@@ -1654,7 +1708,9 @@ export function TeamWorkbenchSummaryPanel({
|
||||
</span>
|
||||
{entry.sourceType ? (
|
||||
<span className="rounded-full border border-slate-200 bg-white px-1.5 py-0.5 text-slate-500">
|
||||
{entry.sourceType}
|
||||
{formatTranscriptEntrySourceLabel(
|
||||
entry.sourceType,
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1726,7 +1782,7 @@ export function TeamWorkbenchSummaryPanel({
|
||||
) : selectedTranscriptHistoryStatus === "loading" ? (
|
||||
<p className="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-[10px] leading-4 text-amber-700">
|
||||
正在从子会话读取历史正文;读取结果仍只作为
|
||||
teammate_transcript preview,不进入主 final answer。
|
||||
队友记录预览,不进入主回复。
|
||||
</p>
|
||||
) : selectedTranscriptHistoryStatus === "error" ? (
|
||||
<p className="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-[10px] leading-4 text-rose-700">
|
||||
@@ -1748,7 +1804,7 @@ export function TeamWorkbenchSummaryPanel({
|
||||
{agentUiSurfaceSummaries.length > 0 ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
|
||||
Surface 专门视图
|
||||
工作区专门视图
|
||||
</div>
|
||||
{agentUiSurfaceSummaries.map((surface, index) => (
|
||||
<details
|
||||
@@ -1762,7 +1818,7 @@ export function TeamWorkbenchSummaryPanel({
|
||||
{surface.label}
|
||||
</span>
|
||||
<span className="ml-2 text-[10px] text-slate-500">
|
||||
{surface.surface}
|
||||
{formatAgentUiSurfaceLabel(surface.surface)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-500">
|
||||
@@ -1821,7 +1877,7 @@ export function TeamWorkbenchSummaryPanel({
|
||||
</span>
|
||||
{event.surface ? (
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] text-slate-500">
|
||||
{event.surface}
|
||||
{formatAgentUiSurfaceLabel(event.surface)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,14 @@ export const upsertAssistantActionRequest = ({
|
||||
setPendingActions,
|
||||
setMessages,
|
||||
}: UpsertAssistantActionRequestOptions) => {
|
||||
const promptKey = replaceByPrompt ? resolveActionPromptKey(actionData) : null;
|
||||
const scopedActionData: ActionRequired = {
|
||||
...actionData,
|
||||
sourceMessageId: actionData.sourceMessageId || assistantMsgId,
|
||||
status: actionData.status || "pending",
|
||||
};
|
||||
const promptKey = replaceByPrompt
|
||||
? resolveActionPromptKey(scopedActionData)
|
||||
: null;
|
||||
|
||||
setPendingActions((prev) => {
|
||||
let next = [...prev];
|
||||
@@ -29,15 +36,15 @@ export const upsertAssistantActionRequest = ({
|
||||
next = next.filter((item) => {
|
||||
const itemKey = resolveActionPromptKey(item);
|
||||
return !(
|
||||
item.requestId !== actionData.requestId &&
|
||||
item.requestId !== scopedActionData.requestId &&
|
||||
itemKey &&
|
||||
itemKey === promptKey
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
next = next.filter((item) => item.requestId !== actionData.requestId);
|
||||
next.push(actionData);
|
||||
next = next.filter((item) => item.requestId !== scopedActionData.requestId);
|
||||
next.push(scopedActionData);
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -52,7 +59,7 @@ export const upsertAssistantActionRequest = ({
|
||||
nextRequests = nextRequests.filter((item) => {
|
||||
const itemKey = resolveActionPromptKey(item);
|
||||
return !(
|
||||
item.requestId !== actionData.requestId &&
|
||||
item.requestId !== scopedActionData.requestId &&
|
||||
itemKey &&
|
||||
itemKey === promptKey
|
||||
);
|
||||
@@ -61,24 +68,24 @@ export const upsertAssistantActionRequest = ({
|
||||
(part) =>
|
||||
!(
|
||||
part.type === "action_required" &&
|
||||
part.actionRequired.requestId !== actionData.requestId &&
|
||||
part.actionRequired.requestId !== scopedActionData.requestId &&
|
||||
resolveActionPromptKey(part.actionRequired) === promptKey
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
nextRequests = nextRequests.filter(
|
||||
(item) => item.requestId !== actionData.requestId,
|
||||
(item) => item.requestId !== scopedActionData.requestId,
|
||||
);
|
||||
nextParts = nextParts.filter(
|
||||
(part) =>
|
||||
!(
|
||||
part.type === "action_required" &&
|
||||
part.actionRequired.requestId === actionData.requestId
|
||||
part.actionRequired.requestId === scopedActionData.requestId
|
||||
),
|
||||
);
|
||||
nextRequests.push(actionData);
|
||||
nextParts = appendActionRequiredToParts(nextParts, actionData);
|
||||
nextRequests.push(scopedActionData);
|
||||
nextParts = appendActionRequiredToParts(nextParts, scopedActionData);
|
||||
|
||||
return {
|
||||
...msg,
|
||||
|
||||
@@ -962,7 +962,7 @@ describe("agentStreamRuntimeHandler", () => {
|
||||
expect(messages[0]?.thinkingContent).toBe("先想第一段。再想第二段。");
|
||||
});
|
||||
|
||||
it("高频 reasoning item_updated 事件不应持续刷新时间线状态", () => {
|
||||
it("reasoning item_updated 应持续刷新时间线思考内容", () => {
|
||||
const setThreadItems = vi.fn();
|
||||
const activateStream = vi.fn();
|
||||
|
||||
@@ -1026,7 +1026,20 @@ describe("agentStreamRuntimeHandler", () => {
|
||||
});
|
||||
|
||||
expect(activateStream).toHaveBeenCalledTimes(1);
|
||||
expect(setThreadItems).not.toHaveBeenCalled();
|
||||
expect(setThreadItems).toHaveBeenCalledTimes(1);
|
||||
|
||||
const updater = setThreadItems.mock.calls[0]?.[0];
|
||||
expect(typeof updater).toBe("function");
|
||||
const nextItems =
|
||||
typeof updater === "function" ? updater([]) : updater;
|
||||
expect(nextItems).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "reasoning-1",
|
||||
type: "reasoning",
|
||||
text: "正在持续追加推理文本",
|
||||
status: "in_progress",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("收到 final_done 时应剥离 assistant 正文中的工具协议残留", () => {
|
||||
|
||||
@@ -260,6 +260,29 @@ interface HandleTurnStreamEventOptions {
|
||||
setIsSending: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
function bindAssistantMessageToRuntimeTurn(
|
||||
setMessages: Dispatch<SetStateAction<Message[]>>,
|
||||
assistantMsgId: string,
|
||||
turnId?: string | null,
|
||||
) {
|
||||
const normalizedTurnId = turnId?.trim();
|
||||
if (!normalizedTurnId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === assistantMsgId &&
|
||||
message.runtimeTurnId !== normalizedTurnId
|
||||
? {
|
||||
...message,
|
||||
runtimeTurnId: normalizedTurnId,
|
||||
}
|
||||
: message,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function finishRequestLog(
|
||||
requestState: StreamRequestState,
|
||||
payload: AgentStreamRequestLogFinishPayload,
|
||||
@@ -721,6 +744,11 @@ export function handleTurnStreamEvent({
|
||||
case "turn_started":
|
||||
clearQueuedDraftCleanupTimer();
|
||||
activateStream();
|
||||
bindAssistantMessageToRuntimeTurn(
|
||||
setMessages,
|
||||
assistantMsgId,
|
||||
data.turn.id,
|
||||
);
|
||||
setCurrentTurnId(data.turn.id);
|
||||
setThreadTurns((prev) =>
|
||||
upsertThreadTurnState(
|
||||
@@ -750,6 +778,11 @@ export function handleTurnStreamEvent({
|
||||
case "item_started":
|
||||
case "item_completed":
|
||||
activateStream();
|
||||
bindAssistantMessageToRuntimeTurn(
|
||||
setMessages,
|
||||
assistantMsgId,
|
||||
data.item.turn_id,
|
||||
);
|
||||
setThreadItems((prev) =>
|
||||
upsertThreadItemState(
|
||||
removeThreadItemState(prev, pendingItemKey),
|
||||
@@ -760,6 +793,11 @@ export function handleTurnStreamEvent({
|
||||
|
||||
case "item_updated":
|
||||
activateStream();
|
||||
bindAssistantMessageToRuntimeTurn(
|
||||
setMessages,
|
||||
assistantMsgId,
|
||||
data.item.turn_id,
|
||||
);
|
||||
if (shouldDeferAgentStreamThreadItemUpdate(data.item)) {
|
||||
break;
|
||||
}
|
||||
@@ -775,6 +813,11 @@ export function handleTurnStreamEvent({
|
||||
case "turn_failed":
|
||||
clearQueuedDraftCleanupTimer();
|
||||
activateStream();
|
||||
bindAssistantMessageToRuntimeTurn(
|
||||
setMessages,
|
||||
assistantMsgId,
|
||||
data.turn.id,
|
||||
);
|
||||
clearOptimisticItem();
|
||||
setThreadTurns((prev) =>
|
||||
upsertThreadTurnState(
|
||||
@@ -1132,6 +1175,11 @@ export function handleTurnStreamEvent({
|
||||
clearOptimisticItem();
|
||||
}
|
||||
}
|
||||
bindAssistantMessageToRuntimeTurn(
|
||||
setMessages,
|
||||
assistantMsgId,
|
||||
data.scope?.turn_id,
|
||||
);
|
||||
handleActionRequiredEvent({
|
||||
data,
|
||||
eventName,
|
||||
|
||||
@@ -257,6 +257,7 @@ export function createAgentStreamSubmissionLifecycle(
|
||||
? {
|
||||
...msg,
|
||||
runtimeStatus: effectiveWaitingRuntimeStatus,
|
||||
runtimeTurnId: msg.runtimeTurnId || pendingTurnKey,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
@@ -299,6 +300,16 @@ export function createAgentStreamSubmissionLifecycle(
|
||||
};
|
||||
|
||||
if (!expectingQueue) {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? {
|
||||
...msg,
|
||||
runtimeTurnId: msg.runtimeTurnId || pendingTurnKey,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
setThreadTurns((prev) =>
|
||||
upsertThreadTurnState(prev, {
|
||||
id: pendingTurnKey,
|
||||
|
||||
@@ -31,8 +31,11 @@ const turn: AgentThreadTurn = {
|
||||
};
|
||||
|
||||
describe("agentStreamThreadItemController", () => {
|
||||
it("应延后 in-progress reasoning 与 agent_message 高频更新", () => {
|
||||
expect(shouldDeferAgentStreamThreadItemUpdate(threadItem())).toBe(true);
|
||||
it("应保留运行中 reasoning 更新,避免思考区只停在首个片段", () => {
|
||||
expect(shouldDeferAgentStreamThreadItemUpdate(threadItem())).toBe(false);
|
||||
});
|
||||
|
||||
it("仍应延后 in-progress agent_message 高频正文快照", () => {
|
||||
expect(
|
||||
shouldDeferAgentStreamThreadItemUpdate(
|
||||
threadItem({ type: "agent_message", text: "hello" }),
|
||||
|
||||
@@ -3,10 +3,7 @@ import type { AgentThreadItem, AgentThreadTurn } from "@/lib/api/agentProtocol";
|
||||
export function shouldDeferAgentStreamThreadItemUpdate(
|
||||
item: AgentThreadItem,
|
||||
): boolean {
|
||||
return (
|
||||
item.status === "in_progress" &&
|
||||
(item.type === "reasoning" || item.type === "agent_message")
|
||||
);
|
||||
return item.status === "in_progress" && item.type === "agent_message";
|
||||
}
|
||||
|
||||
export function buildAgentStreamTurnStartedPendingItemUpdate(params: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
@@ -24,6 +25,10 @@ import { markThreadActionItemSubmitted } from "./agentThreadState";
|
||||
import { buildActionRequestSubmissionContext } from "../utils/actionRequestA2UI";
|
||||
import { buildActionResumeRuntimeStatus } from "../utils/agentRuntimeStatus";
|
||||
import { governActionRequest } from "../utils/actionRequestGovernance";
|
||||
import {
|
||||
filterActionsForCurrentAssistantTail,
|
||||
findActionRequestSourceMessageId,
|
||||
} from "../utils/currentTurnActionRequests";
|
||||
|
||||
interface UseAgentToolsOptions {
|
||||
runtime: AgentRuntimeAdapter;
|
||||
@@ -47,6 +52,45 @@ function upsertSubmittedAction(
|
||||
return next;
|
||||
}
|
||||
|
||||
function findActionRequestInMessages(
|
||||
messages: Message[],
|
||||
requestId: string,
|
||||
): ActionRequired | undefined {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.role !== "assistant" || !message.actionRequests?.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const action = message.actionRequests.find(
|
||||
(item) => item.requestId === requestId,
|
||||
);
|
||||
if (action) {
|
||||
return {
|
||||
...action,
|
||||
sourceMessageId: action.sourceMessageId || message.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function attachActionSourceFromMessages(
|
||||
action: ActionRequired,
|
||||
messages: Message[],
|
||||
): ActionRequired {
|
||||
if (action.sourceMessageId) {
|
||||
return action;
|
||||
}
|
||||
|
||||
const sourceMessageId = findActionRequestSourceMessageId(
|
||||
messages,
|
||||
action.requestId,
|
||||
);
|
||||
return sourceMessageId ? { ...action, sourceMessageId } : action;
|
||||
}
|
||||
|
||||
export function useAgentTools(options: UseAgentToolsOptions) {
|
||||
const {
|
||||
runtime,
|
||||
@@ -69,6 +113,7 @@ export function useAgentTools(options: UseAgentToolsOptions) {
|
||||
string,
|
||||
Omit<ConfirmResponse, "requestId"> & {
|
||||
requestId: string;
|
||||
sourceMessageId?: string;
|
||||
}
|
||||
>
|
||||
>(new Map());
|
||||
@@ -80,11 +125,9 @@ export function useAgentTools(options: UseAgentToolsOptions) {
|
||||
const pendingAction = pendingActions.find(
|
||||
(item) => item.requestId === response.requestId,
|
||||
);
|
||||
const persistedActionRaw =
|
||||
pendingAction ||
|
||||
messages
|
||||
.flatMap((message) => message.actionRequests || [])
|
||||
.find((item) => item.requestId === response.requestId);
|
||||
const persistedActionRaw = pendingAction
|
||||
? attachActionSourceFromMessages(pendingAction, messages)
|
||||
: findActionRequestInMessages(messages, response.requestId);
|
||||
const persistedAction = persistedActionRaw
|
||||
? governActionRequest(persistedActionRaw)
|
||||
: undefined;
|
||||
@@ -133,11 +176,18 @@ export function useAgentTools(options: UseAgentToolsOptions) {
|
||||
if (persistedAction?.isFallback) {
|
||||
const fallbackPromptKey = resolveActionPromptKey(persistedAction);
|
||||
if (fallbackPromptKey) {
|
||||
const fallbackSourceMessageId = persistedAction.sourceMessageId;
|
||||
const resolvedAction = pendingActions.find((item) => {
|
||||
if (item.requestId === persistedAction.requestId) return false;
|
||||
if (item.isFallback) return false;
|
||||
if (item.actionType !== persistedAction.actionType)
|
||||
return false;
|
||||
if (
|
||||
fallbackSourceMessageId &&
|
||||
item.sourceMessageId !== fallbackSourceMessageId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return resolveActionPromptKey(item) === fallbackPromptKey;
|
||||
});
|
||||
|
||||
@@ -147,6 +197,7 @@ export function useAgentTools(options: UseAgentToolsOptions) {
|
||||
actionType,
|
||||
requestId: persistedAction.requestId,
|
||||
userData,
|
||||
sourceMessageId: fallbackSourceMessageId,
|
||||
});
|
||||
setPendingActions((prev) =>
|
||||
prev.map((item) =>
|
||||
@@ -353,8 +404,20 @@ export function useAgentTools(options: UseAgentToolsOptions) {
|
||||
],
|
||||
);
|
||||
|
||||
const visiblePendingActions = useMemo(
|
||||
() => filterActionsForCurrentAssistantTail(pendingActions, messages),
|
||||
[messages, pendingActions],
|
||||
);
|
||||
const visibleSubmittedActionsInFlight = useMemo(
|
||||
() =>
|
||||
filterActionsForCurrentAssistantTail(submittedActionsInFlight, messages, {
|
||||
keepUnscoped: true,
|
||||
}),
|
||||
[messages, submittedActionsInFlight],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
for (const pendingAction of pendingActions) {
|
||||
for (const pendingAction of visiblePendingActions) {
|
||||
if (
|
||||
pendingAction.isFallback ||
|
||||
pendingAction.status === "submitted" ||
|
||||
@@ -373,6 +436,12 @@ export function useAgentTools(options: UseAgentToolsOptions) {
|
||||
if (!queuedResponse) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
queuedResponse.sourceMessageId &&
|
||||
pendingAction.sourceMessageId !== queuedResponse.sourceMessageId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
queuedFallbackResponsesRef.current.delete(promptKey);
|
||||
void confirmAction({
|
||||
@@ -382,7 +451,7 @@ export function useAgentTools(options: UseAgentToolsOptions) {
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, [confirmAction, pendingActions]);
|
||||
}, [confirmAction, visiblePendingActions]);
|
||||
|
||||
const handlePermissionResponse = useCallback(
|
||||
async (response: ConfirmResponse) => {
|
||||
@@ -470,8 +539,8 @@ export function useAgentTools(options: UseAgentToolsOptions) {
|
||||
);
|
||||
|
||||
return {
|
||||
pendingActions,
|
||||
submittedActionsInFlight,
|
||||
pendingActions: visiblePendingActions,
|
||||
submittedActionsInFlight: visibleSubmittedActionsInFlight,
|
||||
setPendingActions,
|
||||
warnedKeysRef,
|
||||
confirmAction,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user