diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5e7d957b..d097f7533 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,12 +39,19 @@ jobs: - platform: macos-latest target: aarch64-apple-darwin name: macOS-arm64 + build_cli: true - platform: macos-15-intel target: x86_64-apple-darwin name: macOS-x64 + build_cli: true - platform: windows-2022 target: x86_64-pc-windows-msvc name: Windows-x64 + build_cli: true + - platform: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + name: Linux-x64-cli + build_cli: true runs-on: ${{ matrix.platform }} env: @@ -594,6 +601,60 @@ jobs: --repo "$GITHUB_REPOSITORY" \ --clobber + - name: Build lime-cli release binary + if: matrix.build_cli && (matrix.platform == 'windows-2022' || matrix.platform == 'ubuntu-22.04' || (startsWith(matrix.platform, 'macos') && (steps.build_macos_primary.outcome == 'success' || steps.build_macos_retry.outcome == 'success' || steps.build_macos_fallback.outcome == 'success'))) + shell: bash + env: + CARGO_PROFILE_RELEASE_LTO: "off" + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: 32 + CARGO_INCREMENTAL: 0 + CARGO_TARGET_DIR: src-tauri/target + SCCACHE_GHA_ENABLED: "true" + RUSTC_WRAPPER: sccache + run: | + set -euxo pipefail + cargo build --manifest-path src-tauri/Cargo.toml -p lime-cli --release --target "${{ matrix.target }}" + + - name: Package lime-cli release asset + id: package_lime_cli + if: matrix.build_cli && (matrix.platform == 'windows-2022' || matrix.platform == 'ubuntu-22.04' || (startsWith(matrix.platform, 'macos') && (steps.build_macos_primary.outcome == 'success' || steps.build_macos_retry.outcome == 'success' || steps.build_macos_fallback.outcome == 'success'))) + shell: bash + run: | + set -euxo pipefail + VERSION="${{ github.event.inputs.version || github.ref_name }}" + VERSION="${VERSION#v}" + + metadata="$( + node packages/lime-cli-npm/scripts/build-release.js \ + --target-triple "${{ matrix.target }}" \ + --version "$VERSION" \ + --out-dir "packages/lime-cli-npm/dist" \ + --json + )" + + echo "$metadata" + asset_path="$(node -e 'const data = JSON.parse(process.argv[1]); process.stdout.write(data.archivePath);' "$metadata")" + echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT" + + - name: Upload lime-cli release asset + if: matrix.build_cli && (matrix.platform == 'windows-2022' || matrix.platform == 'ubuntu-22.04' || (startsWith(matrix.platform, 'macos') && (steps.build_macos_primary.outcome == 'success' || steps.build_macos_retry.outcome == 'success' || steps.build_macos_fallback.outcome == 'success'))) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euxo pipefail + TAG="${{ github.event.inputs.version || github.ref_name }}" + ASSET_PATH="${{ steps.package_lime_cli.outputs.asset_path }}" + + if [ -z "$ASSET_PATH" ] || [ ! -f "$ASSET_PATH" ]; then + echo "lime-cli asset missing: $ASSET_PATH" >&2 + exit 1 + fi + + gh release upload "$TAG" "$ASSET_PATH" \ + --repo "$GITHUB_REPOSITORY" \ + --clobber + # 注意:移除了 Post-build cleanup 步骤 # 之前的清理会删除 deps/build/incremental 目录,导致缓存无法复用 # 保留这些文件可以让 rust-cache 更好地工作 diff --git a/.gitignore b/.gitignore index 7ca90c1e3..fecf4fadc 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ __pycache__/ docs/prd/ !docs/prd/ docs/prd/* +!docs/prd/skills/ +!docs/prd/skills/*.md !docs/prd/tools/ !docs/prd/tools/*.md @@ -89,6 +91,8 @@ lime.db .codex-* target-codex* +.lime/ + tmp/ governance/ diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7f5f55219..4de061520 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,48 +1,50 @@ -## Lime v1.1.0 +## Lime v1.2.0 ### ✨ 主要更新 -- **Lime 版本切换到 `v1.1.0` 稳定版**:应用版本与发布说明统一升级,发布产物、Tauri 配置和前端包版本保持一致 -- **Aster Agent Framework 对齐 `aster-rust v0.25.0`**:Lime 声明的远端 `aster-core` / `aster-models` 依赖 tag 已同步提升,和当前本地联调的 `aster-rust` 版本保持一致 -- **设置页与 Provider Pool 体验收口**:设置页重复标题移除,背景氛围层与小屏 Provider 排版整理,让设置主路径更接近当前设计语言 -- **运行时与桥接稳定性补强**:补了浏览器运行时审计测试隔离、图片资源入库测试边界和相关工作台发送/模型列表回归,减少统一校验里的不稳定因素 +- **Team Runtime 与 Agent 工具面继续收敛**:`Agent / SendMessage / TeamCreate / TeamDelete / ListPeers` 继续作为 current 协作主链,Team 选择、角色信息、蓝图角色锚点与子代理结构化字段说明保持一致,Tool inventory / ToolSearch / MCP runtime 可审计性进一步补强 +- **Lime CLI 与媒体任务主链落地**:新增 `lime-cli` Rust crate 与 `@limecloud/lime-cli` npm 包,统一图片、封面、视频、播报、链接解析、排版、素材检索等任务的创建、状态、attempts 与 retry 语义,`.lime/tasks` 与任务日志协议同步进入文档 +- **GUI 主路径与旧表面继续清理**:Agent Chat Workspace、Empty State、Workbench 画布和 MCP tools browser 按当前主路径整理,旧 `Claw Home / Claw Solutions` 表面继续退出,Provider / companion 偏好入口与工作台状态保持一致 +- **版本与依赖对齐**:Lime 应用版本升级到 `1.2.0`,`aster-core` / `aster-models` 依赖 tag 对齐到 `v0.26.0`,发布工作流、Tauri 配置和 npm 包版本一起收口 ### ⚠️ 发布与兼容性说明 -- 本次发布 tag 为 `v1.1.0`,应用内版本号保持为 `1.1.0`;`.github/workflows/release.yml` 会按稳定版语义创建 GitHub Release -- Homebrew Tap 更新工作流不会再把本次发布视为 prerelease;Release 发布后会继续走稳定通道同步 -- 当前仓库声明的 `aster-rust` 依赖已提升到 `v0.25.0` -- 本地如果启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.25.0` 仓库;GitHub Release runner 不会带本地绝对路径覆盖 +- 本次发布 tag 为 `v1.2.0`,应用内版本号保持为 `1.2.0` +- `@limecloud/lime-cli@1.2.0` 要求 `Node >= 18`,支持 `darwin / linux / win32` 与 `x64 / arm64` +- 当前仓库声明的 `aster-rust` 依赖已提升到 `v0.26.0` +- 本地若启用了 `.cargo/config.toml` 的 Aster 覆盖,请确认它指向干净的 `v0.26.0` 仓库;GitHub Release runner 不会携带本地绝对路径覆盖 +- `SubAgentTask` 仍只保留 compat 读取边界;当前协作主链是 `Agent` tool 配合 Team runtime ### 🔗 依赖与版本同步 -- 应用版本已同步提升到 `1.1.0`,覆盖 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` -- `src-tauri/Cargo.lock` 会随本次 Rust 校验刷新,确保工作区 crate 的版本快照与 `1.1.0` 对齐 -- `aster-core` / `aster-models` 的 git tag 已同步切换到 `v0.25.0` +- 应用版本已同步提升到 `1.2.0`,覆盖 `package.json`、`package-lock.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` +- `packages/lime-cli-npm/package.json` 与配套 README 示例已同步更新到 `1.2.0` +- `src-tauri/Cargo.lock` 会随本次 Rust 校验刷新,确保工作区 crate 版本快照与 `1.2.0` 对齐 +- `aster-core` / `aster-models` 的 git tag 已同步切换到 `v0.26.0` ### 🧪 发布前校验 -- `cargo fmt --manifest-path src-tauri/Cargo.toml --all` -- `cargo test --manifest-path src-tauri/Cargo.toml` -- `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` +- `cargo fmt --manifest-path src-tauri/Cargo.toml --all --check` +- `CARGO_TARGET_DIR=target-release-1.2.0 cargo test --manifest-path src-tauri/Cargo.toml` +- `CARGO_TARGET_DIR=target-release-1.2.0 cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` - `npm run lint` - `npm run verify:app-version` - `npm run test:contracts` -- `npm run verify:gui-smoke -- --reuse-running` +- `npm run verify:gui-smoke` - 当前结果: - - `cargo fmt --manifest-path src-tauri/Cargo.toml --all`:通过 - - `CARGO_TARGET_DIR=target-version-check cargo test --manifest-path src-tauri/Cargo.toml`:通过,`745 passed`,额外集成测试 `2 passed`,真实联网测试 `2 ignored` - - `CARGO_TARGET_DIR=target-version-check cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings`:通过 + - `cargo fmt --manifest-path src-tauri/Cargo.toml --all --check`:通过 + - `CARGO_TARGET_DIR=target-release-1.2.0 cargo test --manifest-path src-tauri/Cargo.toml`:通过,`758 passed`,额外集成测试 `2 passed`,真实联网测试 `2 ignored` + - `CARGO_TARGET_DIR=target-release-1.2.0 cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings`:通过 - `npm run lint`:通过 - `npm run verify:app-version`:通过 - `npm run test:contracts`:通过 - - `npm run verify:gui-smoke -- --reuse-running`:通过 + - `npm run verify:gui-smoke`:通过 ### 📝 文档同步 -- 发布说明已切换到当前这次 `v1.1.0` 稳定版发布内容,供 GitHub Release 直接读取 -- 命令边界、质量流程、路线图与工作区相关文档会随当前主线收敛继续同步 +- 发布说明已切换到当前这次 `v1.2.0` 稳定版发布内容,供 GitHub Release 直接读取 +- 工具治理、质量流程、命令边界与任务协议文档已随当前主线更新,和本次发布版本保持一致 --- -**完整变更**: `v1.0.1` -> `v1.1.0` +**完整变更**: `v1.1.0` -> `v1.2.0` diff --git a/docs/TECH_SPEC.md b/docs/TECH_SPEC.md deleted file mode 100644 index 431869cc2..000000000 --- a/docs/TECH_SPEC.md +++ /dev/null @@ -1,199 +0,0 @@ -# Lime 技术规格文档 - -## 项目概述 - -Lime 是一个桌面端 AI API 代理工具,将各种大模型客户端 API(Gemini CLI、Qwen Code、Kiro Claude 等)统一转换为本地 OpenAI 兼容接口。 - -## 技术栈 - -基于 pubcast 项目技术栈: - -| 类别 | 技术 | -| ---- | ----------------------- | -| 框架 | Tauri 2.0 (Rust + Web) | -| 前端 | React 18 + TypeScript | -| 构建 | Vite 5 | -| UI | Tailwind CSS + Radix UI | -| 图标 | Lucide React | - -### 核心依赖 - -```json -{ - "@tauri-apps/api": "^2.0.0", - "@tauri-apps/plugin-shell": "^2.0.0", - "@radix-ui/react-*": "UI 组件", - "react": "^18.3.1", - "tailwindcss": "^3.4.x" -} -``` - -## 支持的 Provider 渠道 - -参考 AIClient-2-API,需支持以下渠道: - -| Provider | 协议 | 说明 | -| ------------------------ | -------------------- | --------------------------------- | -| `claude-kiro-oauth` | OpenAI/Claude | Kiro OAuth 访问 Claude Sonnet 4.5 | -| `gemini-cli-oauth` | OpenAI/Claude/Gemini | Gemini CLI OAuth | -| `openai-qwen-oauth` | OpenAI/Claude | 通义千问 OAuth | -| `openai-custom` | OpenAI | 自定义 OpenAI 兼容 API | -| `claude-custom` | Claude | 自定义 Claude API | -| `gemini-antigravity` | Gemini | Antigravity 协议 | -| `openaiResponses-custom` | OpenAI Responses | 结构化对话 | - -## 核心功能模块 - -### 1. API 代理服务 (Rust 后端) - -``` -src-tauri/ -├── src/ -│ ├── main.rs # 入口 -│ ├── server.rs # HTTP 服务器 (axum/actix-web) -│ ├── providers/ # Provider 实现 -│ │ ├── mod.rs -│ │ ├── kiro.rs # Kiro Claude -│ │ ├── gemini.rs # Gemini CLI -│ │ ├── qwen.rs # Qwen Code -│ │ ├── openai.rs # OpenAI Custom -│ │ └── claude.rs # Claude Custom -│ ├── converter.rs # 协议转换 (OpenAI <-> Claude <-> Gemini) -│ ├── token.rs # Token 管理/刷新 -│ └── config.rs # 配置管理 -``` - -### 2. 前端 UI (React) - -``` -src/ -├── App.tsx -├── components/ -│ ├── Dashboard.tsx # 仪表盘 -│ ├── ProviderConfig.tsx # Provider 配置 -│ ├── TokenManager.tsx # Token 管理 -│ ├── LogViewer.tsx # 日志查看 -│ └── ui/ # 通用 UI 组件 -├── hooks/ # 领域 Hook(不再承载统一 Tauri 聚合层) -└── lib/ - ├── api/ # 前端 API 网关 - └── utils.ts -``` - -## API 端点设计 - -### 路由模式 - -``` -http://localhost:8999/{provider}/v1/chat/completions -http://localhost:8999/{provider}/v1/messages -``` - -### 支持的端点 - -| 端点 | 协议 | 说明 | -| ---------------------- | ------ | -------------- | -| `/v1/chat/completions` | OpenAI | 聊天补全 | -| `/v1/messages` | Claude | Anthropic 消息 | -| `/v1/models` | OpenAI | 模型列表 | -| `/health` | - | 健康检查 | - -## 配置文件结构 - -```json -{ - "server": { - "host": "127.0.0.1", - "port": 8999, - "apiKey": "your-api-key" - }, - "providers": { - "kiro": { - "enabled": true, - "credentialsPath": "~/.aws/sso/cache/kiro-auth-token.json", - "region": "us-east-1" - }, - "gemini": { - "enabled": false, - "credentialsPath": "~/.gemini/oauth_creds.json", - "projectId": "" - }, - "qwen": { - "enabled": false, - "credentialsPath": "~/.qwen/oauth_creds.json" - }, - "openai": { - "enabled": false, - "apiKey": "", - "baseUrl": "https://api.openai.com/v1" - }, - "claude": { - "enabled": false, - "apiKey": "", - "baseUrl": "https://api.anthropic.com" - } - }, - "defaultProvider": "kiro" -} -``` - -## Token 凭证路径 - -| 服务 | 默认路径 | -| ----------- | --------------------------------------- | -| Kiro | `~/.aws/sso/cache/kiro-auth-token.json` | -| Gemini | `~/.gemini/oauth_creds.json` | -| Qwen | `~/.qwen/oauth_creds.json` | -| Antigravity | `~/.antigravity/oauth_creds.json` | - -## 协议转换 - -支持三种协议互转: - -``` -OpenAI <---> Claude <---> Gemini -``` - -### 转换矩阵 - -| 输入协议 | 输出 Provider | 说明 | -| -------- | ------------- | ----------------------- | -| OpenAI | kiro | OpenAI -> CodeWhisperer | -| OpenAI | gemini | OpenAI -> Gemini | -| Claude | kiro | Claude -> CodeWhisperer | -| Claude | gemini | Claude -> Gemini | -| Claude | openai | Claude -> OpenAI | - -## UI 功能 - -1. **仪表盘** - 服务状态、请求统计 -2. **Provider 管理** - 启用/禁用、配置凭证 -3. **Token 管理** - 查看/刷新 OAuth Token -4. **日志查看** - 实时请求日志 -5. **设置** - 端口、API Key 等 - -## 开发计划 - -### Phase 1: 基础框架 - -- [ ] Tauri 项目初始化 -- [ ] 基础 UI 布局 -- [ ] 配置管理 - -### Phase 2: Kiro Provider - -- [ ] Kiro OAuth Token 读取 -- [ ] CodeWhisperer API 调用 -- [ ] OpenAI/Claude 协议支持 - -### Phase 3: 其他 Provider - -- [ ] Gemini CLI OAuth -- [ ] Qwen OAuth -- [ ] OpenAI/Claude Custom - -### Phase 4: 高级功能 - -- [ ] Provider Pool 管理 -- [ ] 自动 Token 刷新 -- [ ] 请求日志/统计 diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 69dcac35a..3bc2bb5c3 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -64,6 +64,34 @@ `Artifact Workbench`、文档工作台与其他导出入口如需把内容落到用户选择的本地路径,应继续复用这条主链,不要在业务组件里重新扩散 `Blob + a.download` 式浏览器旁路。 +媒体生成任务链路同样需要单一事实源。当前对外公开契约应优先收敛到 `lime media ... generate --json` 这条 CLI 主链,至少覆盖: + +- `lime media image generate` +- `lime media cover generate` +- `lime media video generate` + +这些命令统一产出 `.lime/tasks//*.json` artifact 与稳定 JSON 输出。仓库内现有 `lime_create_*_generation_task`、`social_generate_cover_image` 与相关 Tauri / agent tool 入口在兼容期内允许保留,但应继续委托同一套任务文件与输出契约,不要再长出第三套“媒体任务协议”。 + +`Claw` 的 `@配图` 当前前端网关为 `src/lib/api/mediaTasks.ts`,统一承接: + +- `create_image_generation_task_artifact` +- `get_media_task_artifact` +- `list_media_task_artifacts` +- `retry_media_task_artifact` +- `cancel_media_task_artifact` + +这条命令只负责在当前项目根目录创建标准 `image_generate` task file,并写入 `session_id / project_id / content_id / entry_source / mode` 等上下文。聊天区动态占位、结果回填、刷新恢复都必须继续以 `.lime/tasks` 为唯一事实源,不允许重新回到前端直连图片服务。 + +Workspace `Bash` 运行时在当前主链中应优先解析同名 `lime` 入口:开发态优先回落到 `cargo run -p lime-cli`,打包态优先使用随应用提供的 CLI 二进制。默认 skill 若已经切到 `Bash -> lime media ...`,仍应保留 compat tool 作为兜底,避免在 CLI 暂不可用时把用户流量打断。 + +Skill 执行链路同样遵循单一命令边界。当前前端入口为 `src/lib/api/skill-execution.ts`,统一承接: + +- `execute_skill` +- `list_executable_skills` +- `get_skill_detail` + +这三条命令除了 Tauri `generate_handler!` 之外,也必须继续保持 DevBridge dispatcher 已桥接,避免浏览器模式、headless smoke 或 Playwright 续测时回退成 unknown command。 + 自动化设置链路同样遵循这条路径。当前主入口为 `src/lib/api/automation.ts`,统一承接: - `get_automation_scheduler_config` @@ -90,8 +118,7 @@ Companion 桌宠链路同样遵循这条路径。当前主入口为 `src/lib/api Lime 主应用会在本地维护 `ws://127.0.0.1:45554/companion/pet` 的桌宠 companion 入口。前端如需感知桌宠连接状态,应继续通过 `companion-pet-status` 事件监听统一状态,不要在页面或 Hook 里自行直连本地 `WebSocket`。 -如果 companion 协议继续扩展,也应优先延续“Lime 做宿主、桌宠只收脱敏派生状态”的边界。例如 provider 凭证池相关能力,允许 Lime 通过 `companion_send_pet_command` 下发诸如 `pet.provider_overview` 这类脱敏摘要,并允许桌宠通过 `pet.open_provider_settings` 之类事件请求 Lime 聚焦主窗口并跳到 `设置 -> AI 服务商`;但不允许桌宠直接读取凭证文件、数据库或内部 `/v1/credentials/*` 完整凭证接口。 - +如果 companion 协议继续扩展,也应优先延续“Lime 做宿主、桌宠只收脱敏派生状态”的边界。例如 provider 凭证池相关能力,允许 Lime 通过 `companion_send_pet_command` 下发诸如 `pet.provider_overview` 这类脱敏摘要,并允许桌宠通过 `pet.open_provider_settings` 请求 Lime 聚焦主窗口并跳到 `设置 -> AI 服务商`,或通过 `pet.request_provider_overview_sync` 请求 Lime 立即重发最新的脱敏摘要;桌宠交互增强能力也应继续走这条主链,例如双击 / 三击桌宠后发出 `pet.request_pet_cheer`、`pet.request_pet_next_step`,或通过 `pet.request_chat_reply` 携带用户输入文本,请求 Lime 代为调用当前可聊天模型,再统一回写 `pet.show_bubble`;但不允许桌宠直接读取凭证文件、数据库或内部 `/v1/credentials/*` 完整凭证接口。 ## 命令契约的五个事实源 @@ -114,6 +141,20 @@ Lime 主应用会在本地维护 `ws://127.0.0.1:45554/companion/pet` 的桌宠 只看其中一侧都不够。只要能力仍然依赖命令边界,就至少要同时核对前端调用、Rust 注册、治理目录册、mock 集合这几面。 +## MCP 工具命名主链 + +MCP bridge 当前唯一继续演进的工具命名事实源是: + +- 工具全名:`mcp____` +- extension surface key:`mcp__` +- UI 展示名:继续优先显示 server 原名,例如 `lime-browser` + +不要再新增或恢复以下旧命名心智: + +- 裸 `server__tool` +- 只在重名时才临时拼 `server_tool` +- inventory / mock / GUI 面板里 extension key 与工具前缀各自一套 + ## 命令分类语言 命令治理统一沿用 `governance.md` 的分类语言: @@ -218,7 +259,7 @@ npm run verify:local - `agent_runtime_submit_turn.turn_config` 新增或调整 `approval_policy / sandbox_policy` - `agent_runtime_submit_turn.request_metadata.harness.team_memory_shadow` 新增或调整 repo-scoped Team 协作记忆注入 -- `agent_runtime_spawn_subagent` 的 current request 字段新增或调整 `name / teamName / cwd`,或修改 spawn 后的 Team 成员写回、child `working_dir` 与父子会话上下文投影 +- `agent_runtime_spawn_subagent` 的 current request 字段新增或调整 `name / teamName / runInBackground / mode / isolation / cwd`,或修改 spawn 后的 Team 成员写回、child `working_dir` 与父子会话上下文投影 - `agent_runtime_update_session` 新增或调整 `provider_name / model_name / execution_strategy / recent_access_mode / recent_preferences / recent_team_selection` - `getSession/listSessions` 的 `execution_runtime` 新增或调整 `recent_access_mode / recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` - 话题切换时的 provider/model、权限 accessMode、工具偏好、Team 选择,或 `theme / session_mode / gate_key / run_title / content_id` 恢复从本地 fallback 向 `execution_runtime` 收敛 @@ -263,13 +304,15 @@ npm run verify:local 以下是仓库当前已经明确收敛的几个方向: - **Agent / Codex 主命令**:继续收敛到 `agent_runtime_*` -- **子代理运行时主链**:继续收敛到 `agent_runtime_spawn_subagent`;当前 request surface 使用 `name / teamName / cwd` 等字段,其中 `teamName` 需要与 `name` 搭配并依附现有 Team 上下文,`cwd` 必须是绝对目录,并稳定投影到 child session 的 `working_dir` 与 Team 成员展示 +- **子代理运行时主链**:继续收敛到 `agent_runtime_spawn_subagent`;当前 request surface 使用 `name / teamName / runInBackground / mode / isolation / cwd` 等字段,其中 `teamName` 需要与 `name` 搭配并依附现有 Team 上下文,`cwd` 必须是绝对目录,并稳定投影到 child session 的 `working_dir` 与 Team 成员展示;当前 runtime 仍会明确拒绝非空 `mode / isolation` +- **Team runtime 工具主链**:当前协作工具面继续收敛到 `Agent / TeamCreate / TeamDelete / SendMessage / ListPeers`;`SubAgentTask` 仅保留兼容入口,不再作为新的多代理主路径 +- **用户可见消息工具主链**:继续收敛到 `SendUserMessage`,用于把回复、进度同步、主动提醒和附件送到用户主可见消息面;不要再把这类能力拆到其它平行工具名或旁路协议里 - **会话状态回写主链**:继续收敛到 `agent_runtime_update_session`,用于名称、执行策略、session provider/model、`recent_access_mode`、`recent_preferences` 以及 `recent_team_selection` 的轻量持久化回写 - **会话权限主链**:`agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy` 是正式 turn context 权限协议;`getSession` 返回的 `execution_runtime.recent_access_mode` 负责承接会话最近一次 accessMode。当前端已命中同一 steady-state 权限时,不应继续依赖 `harness.access_mode` 作为唯一事实源 - **运行时交接导出主链**:继续收敛到 `agent_runtime_export_handoff_bundle`;前端统一通过 `src/lib/api/agentRuntime.ts` 网关进入,当前 GUI 入口位于 `HarnessStatusPanel` - **运行时证据导出主链**:继续收敛到 `agent_runtime_export_evidence_pack`,用于把 runtime / timeline / artifacts 打包成最小问题证据 - **运行时 replay 样本主链**:继续收敛到 `agent_runtime_export_replay_case`,复用 handoff bundle + evidence pack 生成 `input / expected / grader / evidence-links` -- **运行时外部分析交接主链**:继续收敛到 `agent_runtime_export_analysis_handoff`,复用 handoff bundle + evidence pack + replay case 生成 `analysis-brief.md / analysis-context.json / copy_prompt`,供外部 Claude Code / Codex 直接诊断与最小修复;当前 GUI 入口位于 `HarnessStatusPanel` +- **运行时外部分析交接主链**:继续收敛到 `agent_runtime_export_analysis_handoff`,复用 handoff bundle + evidence pack + replay case 生成 `analysis-brief.md / analysis-context.json / copy_prompt`,供外部诊断代理直接诊断与最小修复;当前 GUI 入口位于 `HarnessStatusPanel` - **运行时人工审核记录主链**:继续收敛到 `agent_runtime_export_review_decision_template` + `agent_runtime_save_review_decision`;前者复用 `analysis handoff` 生成 `review-decision.md / review-decision.json` 模板,后者把开发者的接受 / 延后 / 拒绝与回归要求回写到同一份工作区制品;当前 GUI 入口位于 `HarnessStatusPanel` - **会话主题上下文主链**:`getSession` 返回的 `execution_runtime.recent_theme / recent_session_mode` 负责承接最近一次运行态主题上下文;当前端已命中同一 steady-state theme/workbench mode 时,不应继续每回合重复携带 `harness.theme / harness.session_mode` - **会话运行阶段上下文主链**:`getSession` 返回的 `execution_runtime.recent_gate_key / recent_run_title` 负责承接最近一次 Theme Workbench 运行阶段上下文;当前端已命中同一 steady-state gate/run 时,不应继续每回合重复携带 `harness.gate_key / harness.run_title` diff --git a/docs/aiprompts/playwright-e2e.md b/docs/aiprompts/playwright-e2e.md index b9e8a8fab..83490e566 100644 --- a/docs/aiprompts/playwright-e2e.md +++ b/docs/aiprompts/playwright-e2e.md @@ -27,7 +27,7 @@ - 能走真实后端就走真实后端;浏览器模式暂不支持或尚未桥接的能力,允许走 mock - `verify:gui-smoke` 内部的 browser runtime 校验默认走无界面浏览器会话;它只证明主链可启动,不替代后续真实页面交互验证 - `lime-pet` 原生桌宠属于独立仓库与原生窗口壳,不纳入当前 WebView Playwright 的直接操控范围;在 Lime 主仓里只验证 `companion_*` API、状态事件与主窗口唤起链路,桌宠窗口移动、点击命中与原生层动画仍需额外手工 smoke -- 如果 companion 协议新增了 provider 摘要或桌宠回跳设置等事件,Playwright 续测只覆盖 Lime 主仓内的“状态事件是否触发”“是否跳到 `设置 -> AI 服务商`”和“主窗口是否被唤起”,不在 WebView 层尝试直接操控原生桌宠 UI +- 如果 companion 协议新增了 provider 摘要、桌宠回跳设置、桌宠主动请求同步,或双击 / 三击 / 文本对话触发的桌宠 LLM 交互事件,Playwright 续测只覆盖 Lime 主仓内的“状态事件是否触发”“是否跳到 `设置 -> AI 服务商`”“是否重发脱敏摘要”“是否调用宿主侧 LLM 代理逻辑”和“主窗口是否被唤起”,不在 WebView 层尝试直接操控原生桌宠 UI - 共享网关控制页已下线,托盘也不再展示网关状态或地址;共享网关 `/v1/routes` 与 selector HTTP 路由也已下线,不再对“启动/停止网关、复制网关地址、路由/curl 示例、selector 路由、托盘运行态文案”做 GUI 续测;server 验证只关注标准 `/v1/messages` 与 `/v1/chat/completions` 主链,如需看运行时状态,走开发者页或实验页的诊断面板 - 项目排版模板与品牌人设扩展旧链路已下线,不再对相关弹窗、模板列表、默认模板、人设扩展表单做 GUI 续测;项目与工作台回归只围绕当前 `Claw` / `workspace` / 现役 `persona` 主链 - 如果只是模块级代码修改、并不需要真实页面交互,优先跑最小单测或 `verify:local` @@ -157,6 +157,16 @@ npm run test:contracts 7. 如工作台模式开启自动保存,再确认执行成功后保存态文案与打开入口正常 8. 打开控制台并确认浏览器资料 / 环境预设读取没有落回 web mock,尤其不应出现 `[Mock] invoke: list_browser_profiles_cmd` 或 `[Mock] invoke: list_browser_environment_presets_cmd` +### Team runtime 工具面验证 + +1. 进入 `Claw` 或带有 `HarnessStatusPanel` 的运行时页面 +2. 打开工具库存 / runtime inventory 面板 +3. 确认 current 协作工具面至少包含 `Agent`、`TeamCreate`、`TeamDelete`、`SendMessage`、`ListPeers` +4. 同时确认主线程 current 工具面包含 `SendUserMessage`,且 tool display 不会退回通用图标或泛化文案 +5. 如果页面当前走的是浏览器 fallback mock,也要确认 fallback inventory 与 tool display 仍显示同一组工具,而不是只出现一部分协作工具或退回通用图标 +6. 如果页面同时展示 MCP bridge 工具,确认 current 命名为 `mcp____`,对应 extension surface key 为 `mcp__`;若仍出现裸 `server__tool`、混合前缀或 extension/tool 各自一套命名,判定为协议漂移 +7. 如出现缺失、重复图标或文案回退,优先检查 Rust catalog、runtime 注册、`src/lib/tauri-mock/core.ts` 与 `toolDisplayInfo.ts` 是否同步 + ### Claw 站点技能直跑门禁验证 1. 在 `Claw` 首页打开一个站点型技能弹窗 @@ -168,6 +178,23 @@ npm run test:contracts 7. 如果已有附着会话,确认 `Claw` 会通过 `lime_site_run` 执行并把结果写回当前主稿或项目资源 8. 如果没有附着会话,确认不会再向 `Claw` 对话流注入“我已完成登录,继续执行”之类的确认卡;阻断必须停留在技能入口层 +### Claw `@配图` 异步任务验证 + +1. 在 `Claw` 对话框输入 `@配图 生成 ...` +2. 确认聊天区先出现运行中占位卡,不会自动展开图片画布 +3. 等待 task file 回流后,确认同一条卡片被替换为成功或失败状态,而不是额外再插一条前端本地伪造结果 +4. 刷新页面或切换会话再返回原话题,确认最近图片任务会从 `.lime/tasks` 恢复 +5. 如手动打开图片画布,确认任务卡状态与聊天区一致 +6. 如当前界面已暴露任务控制入口,确认 `get/list/retry/cancel` 仍然只经由 `src/lib/api/mediaTasks.ts -> task file` 主链,不会回流前端直连图片服务 + +### Slash Skill / Skill 执行验证 + +1. 进入 `Claw` 或任一支持 slash skill 的聊天入口 +2. 输入一个已安装技能,例如 `/image_generate 画一张春日海报` +3. 确认前端不会回退普通 `chat_stream`,而是进入 skill 执行态 +4. 打开控制台,确认浏览器模式接通 DevBridge 时不再出现 `execute_skill`、`list_executable_skills` 或 `get_skill_detail` 的 unknown command 报错 +5. 如当前 skill 设计为走 `Bash -> lime ...`,继续确认最终反馈的是任务提交摘要或任务状态,而不是前端本地伪造成功态 + ### 开发者页站点来源导入验证 1. 进入 `设置 -> 开发者` @@ -237,6 +264,7 @@ npm run test:contracts 3. 验证 child session / Team Workbench 优先显示显式 `name`,而不是退回 `agent_type`、profile label 或 task summary fallback 4. 如果本轮涉及 `teamName`,确认 child 会回挂到当前 Team,上下文里能按该名字识别,不会出现重复成员或错挂到其它 Team 5. 验证 child 的 `working_dir` 与详情展示反映请求的绝对 `cwd`;如果请求非法相对路径,前端应看到明确失败,而不是静默回退父目录 +6. 如果当前入口或调试面板暴露 `mode / isolation`,传入非空值时前端应看到明确 unsupported,而不是静默创建 child session ### 上下文压缩链路验证 diff --git a/docs/aiprompts/quality-workflow.md b/docs/aiprompts/quality-workflow.md index 31f811051..9558ecd94 100644 --- a/docs/aiprompts/quality-workflow.md +++ b/docs/aiprompts/quality-workflow.md @@ -74,6 +74,10 @@ 如果本轮涉及 `companion_*` 桌宠命令族,还要同步检查本地 companion `WebSocket` 入口、前端 `src/lib/api/companion.ts` 网关、Rust 注册、治理目录册以及浏览器模式 mock 返回形态;浏览器模式下这组命令默认也要保持可 mock,不要让桌宠接入把默认页面渲染链路卡死。 +如果本轮涉及 team runtime 工具面或主线程用户消息工具,还要同步检查 Rust catalog / inventory、runtime 注册、浏览器 fallback mock 与前端 tool display;`Agent / TeamCreate / TeamDelete / SendMessage / ListPeers` 必须保持同一组 current surface,`SendUserMessage` 也必须继续停留在 current 主线程工具面,`SubAgentTask` 只能继续停留在 compat 读取边界。 + +如果本轮涉及 MCP bridge runtime tool surface、inventory 或 ToolSearch,还要同步检查 Rust extension 注入、inventory 快照、浏览器 fallback mock 与 GUI 面板命名;当前唯一命名事实源是 `mcp____`,对应 extension surface key 为 `mcp__`,不要让 mock 或 UI 退回裸 `server__tool`。 + ### 3. 用户可见 UI 改动必须补稳定回归 - 优先补现有 `*.test.tsx` 的关键文案、状态与交互断言 @@ -171,28 +175,38 @@ npm run bridge:health -- --timeout-ms 120000 - 检查 harness metadata / execution runtime / 后端 request metadata 的关键字段是否漂移 - 检查浏览器桥接 / mock 优先路径是否同步 - 检查 `DevBridge` 是否可用 +- 检查 `Claw @配图` 是否仍然只创建 task file,而不是回流前端直连图片服务 高频场景: - 修改 `safeInvoke` / `invoke` +- 修改 `execute_skill`、`list_executable_skills`、`get_skill_detail` 或它们在 DevBridge / mock 中的分流 - 修改 `src/lib/api/document-export.ts`、`save_exported_document`,或把新的 GUI 导出入口接到本地文件保存主链 - 修改 `agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy` - 修改 `agent_runtime_submit_turn.request_metadata.harness.team_memory_shadow` - 修改 `agent_runtime_spawn_subagent` 的 `name / teamName / cwd`、spawn 后 Team 成员写回,或 child `working_dir` / 父子上下文投影语义 +- 修改 team runtime tool surface、tool inventory、主线程用户消息工具或协作工具展示,例如 `SendUserMessage`、`Agent / TeamCreate / TeamDelete / SendMessage / ListPeers` - 修改 `agent_runtime_update_session` 或会话 provider/model / recent_access_mode / recent_preferences / recent_team_selection 恢复语义 - 修改 `execution_runtime.recent_access_mode / recent_theme / recent_session_mode / recent_gate_key / recent_run_title / recent_content_id` 恢复语义,或前端 `harness.access_mode / harness.theme / harness.session_mode / harness.gate_key / harness.run_title / harness.content_id` steady-state 去重逻辑 - 修改首页 / 工作区进入 `Claw` 时的首条自动发送上下文,例如 `initialUserPrompt`、`initialAutoSendRequestMetadata`、`harness.service_skill_launch` - 修改 `site_*` 站点适配器命令族,例如 `site_recommend_adapters`、`site_get_adapter_launch_readiness`、`site_import_adapter_yaml_bundle`、`site_run_adapter` -- 修改 `companion_get_pet_status`、`companion_launch_pet`、`companion_send_pet_command`,或调整 Lime 与独立桌宠之间的本地 companion 协议 +- 修改 `companion_get_pet_status`、`companion_launch_pet`、`companion_send_pet_command`,或调整 Lime 与独立桌宠之间的本地 companion 协议(例如 `pet.provider_overview`、`pet.open_provider_settings`、`pet.request_provider_overview_sync`、`pet.request_pet_cheer`、`pet.request_pet_next_step`、`pet.request_chat_reply`) - 修改自动化设置命令族,例如 `get_automation_jobs`、`create_automation_job`、`update_automation_job`、`get_automation_health` 或 `get_automation_run_history`,尤其是它们在浏览器模式 DevBridge 与 mock 间的分流 - 修改浏览器资料 / 环境预设命令族,或调整它们在 `mockPriorityCommands` 里的优先级 - 修改浏览器连接器命令族,例如安装目录、启用状态、系统连接器、浏览器动作配置、扩展安装状态、打开 Chrome 扩展 / 远程调试页,或主动断开扩展连接 - 修改 `get_model_registry_provider_ids`、Provider 模型映射或 `src-tauri/resources/models/index.json` 真相源读取语义 +- 修改 `create_image_generation_task_artifact`、`get_media_task_artifact`、`list_media_task_artifacts`、`retry_media_task_artifact`、`cancel_media_task_artifact`、`src/lib/api/mediaTasks.ts`,或调整 `Claw @配图 -> task file` 的异步图片任务主链 - 修改 `src/lib/dev-bridge/` - 修改 `src/lib/tauri-mock/` - 修改 `src-tauri/src/app/runner.rs` - 修改 `src-tauri/src/dev_bridge/` +如果本轮修改了 `Claw @配图` 或图片任务 artifact 回填语义,最低校验至少包含: + +- `npm run test:contracts` +- 受影响的 `image task` / `image workbench` Hook 单测 +- `npm run verify:gui-smoke` + 如果本轮修改了 Provider 模型真相源或设置页中的“支持的模型”展示逻辑,还应额外确认: - 资源索引损坏时,GUI 会明确提示“模型真相源异常” @@ -229,6 +243,7 @@ npm run bridge:health -- --timeout-ms 120000 补充说明: +- 如果这次改动新增或调整公开 CLI(例如 `@lime/cli`、`lime media ...`),至少补受影响 crate 的定向测试;媒体 CLI 主链当前最低建议为 `cargo test --manifest-path src-tauri/Cargo.toml -p lime-media-runtime -p lime-cli`。如果 CLI 结果会回流 Workbench/Agent,再补对应 Rust 或前端定向回归。 - 如果这次改动把 `ServiceSkill -> automation_job -> agent_turn` 接到 Artifact 主线,除了常规 `verify:local` / `test:contracts` 之外,还应至少补一条稳定回归,证明 `content_id + request_metadata.artifact` 没在表单编辑或执行链路里丢失。 - 如果这次改动影响 `Claw` 与站点技能的直跑门禁,还应补回归证明:阻断停留在技能入口层,不再把浏览器准备态注入成对话里的继续执行确认。 - 如果这次改动把 `content_id` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明: @@ -236,7 +251,7 @@ npm run bridge:health -- --timeout-ms 120000 - 如果这次改动涉及上下文压缩语义,至少要同时验证两条运行时链路: - 普通 `agent_runtime_submit_turn` 发消息链路 - `agent_runtime_respond_action` 的 ask-user / elicitation 恢复链路 - 二者在 `workspace.settings.auto_compact=false` 时都不应再偷偷触发自动压缩,而应把“请手动压缩或新建会话”的错误显式投影到前端。 + 二者在 `workspace.settings.auto_compact=false` 时都不应再偷偷触发自动压缩,而应把“请手动压缩或新建会话”的错误显式投影到前端。 - 切换到新 content 但 runtime 尚未同步时,前端仍会保留显式 `content_id` - 如果这次改动把 `theme / session_mode` steady-state 从“每回合显式提交”后移到 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明: - session 已有 `execution_runtime.recent_theme / recent_session_mode` 时,前端不会重复提交相同 `harness.theme / harness.session_mode` @@ -247,6 +262,7 @@ npm run bridge:health -- --timeout-ms 120000 - 如果这次改动影响 `agent_runtime_spawn_subagent` 的 current request surface,除了契约检查之外,还应补: - Rust 定向测试,确认显式 `name` 会覆盖 child session 展示名 / role hint 的 fallback - Rust 或前端回归,确认 `teamName` 必须与 `name` 搭配,并且只在现有 Team 上下文内写回成员关系 + - Rust 定向测试,确认当前 runtime 对非空 `mode / isolation` 会返回明确 unsupported,而不是静默忽略 - 定向验证,确认绝对 `cwd` 会投影到 child `working_dir`,相对路径会在边界被拒绝 - 如果这次改动把 `accessMode` steady-state 从“只写 harness metadata”收敛到正式 turn context 与 `session/runtime`,除了契约检查之外,还应补 Hook/UI 回归,证明: - turn 提交始终携带正式 `approval_policy / sandbox_policy` diff --git a/docs/i18n-implementation-summary.md b/docs/i18n-implementation-summary.md deleted file mode 100644 index 8aeea81e4..000000000 --- a/docs/i18n-implementation-summary.md +++ /dev/null @@ -1,166 +0,0 @@ -# Multi-Language Support Implementation Summary - -## ✅ Implementation Complete - -**Date:** 2026-01-07 -**Status:** Production Ready -**Coverage:** 100% (3,568/3,568 entries translated) - -## What Was Implemented - -### 1. Patch Layer Architecture -- **HOC + MutationObserver** - Runtime DOM text replacement -- **Zero source modifications** - Original components untouched (except GeneralSettings for language selector) -- **Merge-conflict free** - All i18n code isolated in `src/i18n/` - -### 2. Translation Files -- **Location:** `src/i18n/patches/` -- **Files:** - - `zh.json` - Chinese (identity mapping) - - `en.json` - English (3,568 entries, 100% complete) -- **Translation Method:** Full sentence/phrase (context-aware) - -### 3. Configuration Integration -- **Rust Backend:** Added `language` field to `Config` struct -- **TypeScript Frontend:** Added `language` to Config interface -- **Storage:** Persisted in Tauri config (YAML/JSON) -- **Default:** "zh" (Chinese) - -### 4. Language Selector UI -- **Location:** Settings → General -- **Component:** `src/components/settings/LanguageSelector.tsx` -- **Options:** 中文 (zh) / English (en) -- **Behavior:** Real-time switching via DOM replacement - -### 5. Files Modified (Minimal) -``` -src-tauri/src/config/types.rs - Add language field -src/hooks/useTauri.ts - Add language to Config interface -src/App.tsx - Wrap with I18nPatchProvider -src/main.tsx - Import i18n config -src/components/settings/GeneralSettings.tsx - Language selector integration -``` - -### 6. New Files Created -``` -src/i18n/patches/zh.json - Chinese translations (identity) -src/i18n/patches/en.json - English translations (100%) -src/i18n/text-map.ts - Text map registry -src/i18n/config.ts - i18next configuration -src/i18n/dom-replacer.ts - DOM text replacement utility -src/i18n/I18nPatchProvider.tsx - Patch provider component -src/i18n/withI18nPatch.tsx - HOC wrapper -src/components/settings/LanguageSelector.tsx - Language selector UI -``` - -## Translation Statistics - -| Metric | Value | -|--------|-------| -| Total Entries | 3,568 | -| Translated | 3,568 | -| Coverage | 100% | -| File Size | 188 KB | -| Translation Method | Full sentence/phrase | -| Quality | Context-aware, natural English | - -## How It Works - -1. **App Startup:** - - Load language from Tauri config - - Initialize I18nPatchProvider with saved language - - Apply initial DOM text replacement - -2. **Language Switch:** - - User selects language in Settings - - Save to Tauri config - - Update I18nPatchProvider context - - MutationObserver triggers DOM replacement - - All UI text updates instantly - -3. **Dynamic Content:** - - MutationObserver watches for DOM changes - - New content automatically patched - - Works with modals, tooltips, lazy-loaded components - -## Maintenance - -### Adding New Translations - -When upstream adds new Chinese text: - -1. **Extract TODOs:** - ```bash - python scripts/extract_remaining_todos.py - ``` - -2. **Translate:** - Edit `translations-remaining.json` with English translations - -3. **Import:** - ```bash - python scripts/import_translations.py - ``` - -### Translation Guidelines - -- ✅ Use full sentences/phrases (not word-by-word) -- ✅ Context-aware (consider UI location) -- ✅ Natural English (translate meaning, not literal) -- ✅ Consistent terminology - -## Testing - -### Manual Testing Checklist -- [ ] Settings page displays in both languages -- [ ] Sidebar menu items translate correctly -- [ ] Language selector works (Settings → General) -- [ ] Language persists after app restart -- [ ] Dynamic content (modals, tooltips) translates -- [ ] No Chinese text visible in English mode - -### Test Command -```bash -npm run dev -``` - -Then: -1. Go to Settings → General -2. Change language to English -3. Verify all UI text is in English -4. Restart app -5. Verify language persists - -## Known Limitations - -1. **Plugin UI** - Not translated (plugins loaded dynamically) -2. **Rust Backend Errors** - Remain in Chinese (out of scope) -3. **System Locale Detection** - Not implemented (manual selection only) -4. **Formatted Strings** - May not work if using variable interpolation - -## Future Enhancements - -- [ ] Add more languages (Japanese, Korean, etc.) -- [ ] System locale detection -- [ ] Plugin UI translation support -- [ ] RTL language support (Arabic, Hebrew) -- [ ] Build-time optimization (if performance issues) - -## Architecture Benefits - -✅ **Zero Merge Conflicts** - Original components untouched -✅ **Easy to Disable** - Remove `src/i18n/` folder to revert -✅ **Testable** - Patch layer can be tested independently -✅ **Maintainable** - All i18n code isolated in one directory -✅ **Scalable** - Easy to add more languages - -## Production Readiness - -✅ All UI text translated (100%) -✅ Language selector integrated -✅ Config persistence working -✅ No merge conflict risk -✅ Minimal source modifications -✅ Clean architecture - -**Status: READY FOR PRODUCTION** 🚀 diff --git a/docs/i18n-translation-fixes.md b/docs/i18n-translation-fixes.md deleted file mode 100644 index a9329b4df..000000000 --- a/docs/i18n-translation-fixes.md +++ /dev/null @@ -1,110 +0,0 @@ -# Translation Fixes Applied - -## Summary -Fixed 20 translation issues in `lime/src/i18n/patches/en.json` where English translations had concatenated words or incorrect formatting, plus fixed a critical bug in the DOM replacement algorithm that caused partial translations. - -## Critical Bug Fix: DOM Replacement Order - -### Problem -The DOM replacer was applying translations in an arbitrary order (based on `Object.entries()` iteration), which caused partial replacements when shorter strings were replaced before longer strings containing them. - -**Example of the bug:** -- Text: "初次设置向导" -- If "初次" was replaced first → "First-time设置向导" -- Then "设置" was replaced → "First-timeSettings向导" -- Result: Broken translation like "初timesSettings向导" - -### Solution -Modified `lime/src/i18n/dom-replacer.ts` to sort translation entries by length (longest first) before applying replacements. This ensures that longer, more specific phrases are translated before their component parts. - -```typescript -// Sort patches by length (longest first) to avoid partial replacements -const sortedPatches = Object.entries(patches) - .filter(([zh]) => !zh.startsWith('//')) - .sort(([a], [b]) => b.length - a.length); -``` - -This fix ensures: -- "初次设置向导" is replaced as a complete phrase before "初次" or "设置" individually -- No partial translations or broken text -- Consistent and accurate translations throughout the UI - -## Issues Fixed - -### 1. Concatenated Words in Translations -These translations had words incorrectly concatenated without spaces: - -| Chinese | Before | After | -|---------|--------|-------| -| 请输入或选择配置文件 | Please enter InputorSelectConfigureFile | Please enter or select configuration file | -| 和其他设置 | andOther settings | and other settings | -| 名称和类型 | Nameand type | Name and type | -| 标签管理此插件的凭证 | TagsManageThisplugin's Credentials | tab to manage this plugin's credentials | -| 输入本地插件目录路径或 | InputLocalplugin directory path or | Enter local plugin directory path or | -| 或输入新的 | or InputNew's | or enter new | -| 请检查内容 | Please Checkcontent | Please check content | - -### 2. Incorrect Technical Term Formatting -These translations had technical terms incorrectly formatted: - -| Chinese | Before | After | -|---------|--------|-------| -| 凭证加载成功 | CredentialsLoad successful | Credentials loaded successfully | -| 配置保存成功 | ConfigureSave successful | Configuration saved successfully | -| 凭证添加成功 | CredentialsAdd successful | Credential added successfully | -| 凭证刷新成功 | CredentialsRefresh successful | Credential refreshed successfully | -| 已复制凭证 | CopyCredentials | Credential copied | -| 检查模型名称 | CheckModel name | Check model name | -| 上传新文件 | UploadNew file | Upload new file | -| 导入凭证文件 | ImportCredentials file | Import credentials file | -| 打开链接失败 | Failed to OpenLink | Failed to open link | -| 等待授权中 | WaitingAuthorizationing | Waiting for authorization | -| 未登录状态 | Not LoginStatus | Not logged in | -| 配置文件同步失败 | Failed to ConfigureFileSync | Failed to sync configuration file | -| 检查同步状态失败 | Failed to CheckSyncStatus | Failed to check sync status | -| 安装完成后点击 | Click after InstallComplete | Click after installation completes | - -## Impact -These fixes improve the quality and readability of English translations throughout the Lime application, ensuring: -- Proper spacing between words -- Natural English phrasing -- Consistent terminology -- Professional presentation -- **No more partial or broken translations** - -## Files Modified -- `lime/src/i18n/patches/en.json` - 20 translation entries corrected -- `lime/src/i18n/dom-replacer.ts` - Fixed replacement order algorithm - -## Testing Recommendations -1. Restart the application to ensure patches apply with the new algorithm -2. Switch language to English in Settings > General > Language -3. Navigate through all pages to verify translations display correctly -4. Specifically check the "初次设置向导" (First-time Setup) section in General Settings -5. Check for any remaining Chinese text that may not be covered by the translation files - -## Technical Details - -### Why Sorting by Length Matters -When replacing text, if a shorter substring is replaced before a longer string containing it, the longer string will never match. For example: - -``` -Original: "初次设置向导" -Translations: - "初次" → "First-time" - "设置" → "Settings" - "向导" → "Wizard" - "初次设置向导" → "First-time Setup" - -Without sorting (wrong order): - "初次设置向导" → "First-time设置向导" (after replacing "初次") - → "First-timeSettings向导" (after replacing "设置") - → "First-timeSettingsWizard" (after replacing "向导") - Result: ❌ "First-timeSettingsWizard" - -With sorting (correct order): - "初次设置向导" → "First-time Setup" (replaced as complete phrase) - Result: ✅ "First-time Setup" -``` - -This is why sorting by length (longest first) is critical for accurate translations. diff --git a/docs/prd/skills/async-image-skill-task-prd.md b/docs/prd/skills/async-image-skill-task-prd.md new file mode 100644 index 000000000..da4e57f4f --- /dev/null +++ b/docs/prd/skills/async-image-skill-task-prd.md @@ -0,0 +1,588 @@ +# Skills 异步图片任务与动态渲染 PRD + +> 文档版本:v1.0 +> 状态:Draft +> 更新时间:2026-04-03 +> 适用范围:`image_generate` skill、`lime-cli`、媒体任务协议、Claw 对话框、图片工作台 +> 目标读者:产品、前端、Rust/CLI、测试 + +--- + +## 一、背景与问题定义 + +图片生成不是一个“页面功能”,而是一个会被正文配图、封面生成、自动化工作流、批处理、队列任务、Agent 组合频繁复用的核心技能。 + +当前 Lime 已存在两条相关链路: + +1. `@配图` 前端快路径 + 已具备占位反馈、图片卡、图片工作台展开等体验,但真相源偏前端本地运行时。 +2. `image_generate` skill 任务路径 + 已具备稳定的任务创建能力,能输出 `task_id / task_type / path / status`,但当前只做到“提交任务”,没有把任务状态与结果动态回流到 Claw 对话框。 + +这导致三个核心问题: + +1. **能力割裂**:前端快路径与 skill 任务路径体验不一致。 +2. **黑盒感强**:用户在 Claw 中看不到真实生成进度与真实结果替换。 +3. **难以组合与测试**:如果图片生成过程继续停留在某个前端页面或某个 skill 内部,就很难在自动化、批处理、队列、重试、回放中复用。 + +本 PRD 的目标是把图片生成正式收敛为一条异步、解耦、标准化的主链: + +- `skill` 只负责创建图片任务 +- `CLI / task file` 作为唯一状态真相源 +- `worker / 执行器` 异步消费任务并回写结果 +- `前端` 只观察任务状态,先渲染占位,再替换成真实图片 + +--- + +## 二、产品目标与非目标 + +### 2.1 产品目标 + +1. 让用户在 Claw 对话框中发起图片 skill 后,立即看到可感知的动态反馈。 +2. 让图片 skill 与正文配图、封面、自动化、未来 worker 共用同一套任务协议。 +3. 让图片任务具备标准生命周期:创建、排队、执行、成功、失败、重试、取消、恢复。 +4. 让前端显示的是“真实任务状态”,而不是 assistant 文案猜测。 +5. 让图片生成具备良好的可测试性、可回放性与幂等能力。 + +### 2.2 非目标 + +1. 不把 `image_generate` 改成同步阻塞 skill。 +2. 不让前端直接成为图片业务真相源。 +3. 不为图片业务单独发明第二套状态协议。 +4. 不要求首期实现复杂多 worker 调度系统,但协议必须为其预留空间。 + +--- + +## 三、核心原则 + +### 3.1 Skill 只负责创建任务 + +`image_generate` 的职责是把用户意图转成高质量图片任务,而不是持有整个生成生命周期。 + +### 3.2 Task File 是唯一真相源 + +图片任务的状态、结果、错误、来源关系都必须落到标准 task file 或其等价协议存储中。Claw、工作台、CLI、测试统一读取这一事实源。 + +### 3.3 前端是观察者,不是执行器 + +前端只负责: + +- 插入任务卡 +- 观察任务状态 +- 渲染占位态、成功态、失败态 +- 提供“打开图片工作台/重试/查看详情”等交互 + +前端不直接持有真实图片任务状态机。 + +### 3.4 任务必须可组合 + +图片生成将被多个能力组合调用,因此协议必须天然支持: + +- 幂等 +- 重试 +- 队列 +- 历史恢复 +- 任务结果回放 +- 多入口共用 + +### 3.5 动态渲染绑定 `task_id` + +前端可以用类似 `[img:multimodal:描述]` 的占位语义做用户可见反馈,但真正的替换与状态更新必须绑定 `task_id`,不能只依赖原始文本占位符。 + +--- + +## 四、用户体验目标 + +## 4.1 Claw 对话框中的图片任务体验 + +用户在 Claw 中触发 `image_generate` 后,系统行为应如下: + +1. 立即创建图片任务。 +2. 聊天区立即出现一张图片任务卡。 +3. 任务卡先展示动态占位图与摘要文案。 +4. 任务进入 `queued / running` 后,任务卡持续保持“处理中”态。 +5. 任务成功后,任务卡原位替换为真实图片。 +6. 用户点击真实图片后,可打开图片工作台或图片画布。 +7. 任务失败后,任务卡切为失败态,并显示可重试入口。 + +用户不应该遇到以下情况: + +- 技能明明已经提交任务,但聊天区无任何视觉反馈 +- 图片生成成功了,但聊天区仍显示失败 +- 成功后又额外插入第二条“结果消息”,造成重复 +- 只有前端快路径有占位体验,skill 路径没有 + +## 4.2 正文或富文本中的占位体验 + +当正文、说明文稿或富文本内容需要“边写边配图”时,前端允许展示图片占位块,视觉上可参考: + +- 行内占位文本:`[img:主题摘要]` +- 块级占位组件:带边框、骨架图、说明文案的图片占位块 + +但内部必须维护如下映射: + +- `render_slot_id -> task_id` +- `task_id -> 最新任务状态` +- `task_id -> 最新结果图片` + +首期规则: + +1. 如果已有 `task_id` 映射,前端按任务状态动态渲染。 +2. 如果只有文本占位,没有映射,则保留为普通文本,不做伪替换。 +3. 成功后,块级占位直接替换为真实图片视图。 + +## 4.3 历史恢复 + +用户刷新、重开会话、切换主题后,只要任务仍存在于 task file 中,前端应能够恢复: + +- 未完成任务的占位态 +- 已完成任务的真实图片态 +- 已失败任务的失败态 + +--- + +## 五、总体架构 + +```mermaid +graph TD + USER[用户 / Claw 对话框] --> SKILL[image_generate skill] + SKILL --> CLI[CLI / Task API] + CLI --> TASKFILE[Task File / 标准任务协议] + WORKER[图片执行器 / Worker] --> TASKFILE + TASKFILE --> FRONTEND[前端任务观察层] + FRONTEND --> CHAT[聊天区图片任务卡] + FRONTEND --> DOC[正文占位块] + FRONTEND --> CANVAS[图片工作台 / 画布] +``` + +### 5.1 分层职责 + +#### Skill 层 + +- 解析用户意图 +- 整理 prompt、尺寸、数量、风格、用途 +- 创建标准图片任务 +- 返回结构化任务摘要 + +#### CLI / Task API 层 + +- 创建任务 +- 查询状态 +- 查询结果 +- 列出任务 +- 重试任务 +- 取消任务 + +#### Task File / 协议层 + +- 保存任务元数据 +- 保存任务状态 +- 保存任务结果 +- 保存任务错误 +- 保存重试链路与幂等信息 + +#### Worker / 执行器层 + +- 异步读取图片任务 +- 调用真实图片服务 +- 更新状态与结果 + +#### 前端观察层 + +- 插入任务卡 +- 轮询或订阅状态变化 +- 渲染占位与替换结果 +- 打开图片工作台 + +--- + +## 六、标准任务协议 + +## 6.1 `image_generate` skill 输出要求 + +`image_generate` skill 的固定输出至少包含: + +- `task_id` +- `task_type = image_generate` +- `status` +- `path` +- `absolute_path` +- `idempotency_key` +- `payload.prompt` +- `payload.size` +- `payload.count` +- `payload.style` +- `payload.usage` + +skill 输出的语义是: + +- “我已经创建了一个图片任务” +- 而不是“我已经拿到了最终图片结果” + +## 6.2 任务状态机 + +统一状态如下: + +- `pending_submit` +- `queued` +- `running` +- `succeeded` +- `failed` +- `cancelled` + +状态语义: + +- `pending_submit`:任务文件已创建,但尚未进入执行队列 +- `queued`:已进入执行队列,等待执行器消费 +- `running`:执行器已开始调用图片服务 +- `succeeded`:结果已生成并已写回任务文件 +- `failed`:任务失败,错误已落盘 +- `cancelled`:任务被取消 + +## 6.3 任务结果结构 + +建议图片任务结果最小结构如下: + +```json +{ + "task_id": "img-task-123", + "task_type": "image_generate", + "status": "succeeded", + "payload": { + "prompt": "一个充满未来感的实验室,中心是一个发光的大脑", + "size": "1280x720", + "count": 1, + "style": "cinematic", + "usage": "article-inline" + }, + "result": { + "prompt": "一个充满未来感的实验室,中心是一个发光的大脑", + "provider": "fal", + "model": "fal-ai/nano-banana-pro", + "images": [ + { + "url": "https://...", + "thumbnail_url": "https://...", + "width": 1280, + "height": 720, + "mime_type": "image/png" + } + ] + }, + "last_error": null +} +``` + +失败时: + +- `status = failed` +- `result = null` +- `last_error` 必须有明确错误文案 + +## 6.4 幂等字段 + +所有图片任务默认支持 `idempotency_key`。 + +约束如下: + +1. 同一业务入口重复提交同一个请求时,应优先复用现有任务。 +2. 同一 `idempotency_key` 不应重复生成多条等价任务。 +3. 前端消息卡更新也必须以 `task_id` 幂等更新,避免重复插卡。 + +## 6.5 CLI / Tauri 接口 + +标准 CLI 主链: + +- `lime task create image --json` +- `lime task status ` +- `lime task result ` +- `lime task retry ` +- `lime task cancel ` +- `lime task list` + +前端推荐通过统一网关暴露: + +- `lime_task_get_status` +- `lime_task_get_result` +- `lime_task_list_active` + +前端不应在页面组件中散落裸 `invoke`。 + +--- + +## 七、动态渲染方案 + +## 7.1 聊天区图片任务卡 + +聊天区新增标准图片任务卡视图模型,最少包含: + +- `taskId` +- `prompt` +- `status` +- `progressText` +- `imageUrl` +- `imageCount` +- `size` +- `errorMessage` +- `projectId` +- `contentId` + +渲染规则: + +1. `pending_submit / queued / running` + - 显示占位图 + - 显示“正在生成预览”或类似文案 + - 显示 prompt 摘要 +2. `succeeded` + - 用真实图片替换占位图 + - 保留点击打开图片工作台入口 +3. `failed` + - 显示失败态 + - 展示错误文案 + - 展示“重试”入口 + +## 7.2 行内或块级占位渲染 + +当正文内容中出现图片占位槽位时,前端可以渲染为块级组件,示例视觉语义如下: + +```text +[img:multimodal:一个充满未来感的实验室,中心是一个发光的大脑] +``` + +推荐规则: + +1. 视觉层允许显示类似上述可读文本摘要。 +2. 内部必须附带不可见绑定信息: + - `slot_id` + - `task_id` + - `status` +3. 一旦任务完成,块级组件直接替换为真实图片视图,而不是简单替换字符串。 + +## 7.3 状态回流方式 + +首期推荐主线: + +1. 创建任务时,通过 `creation_task_submitted` 拿到 `task_id / path` +2. 前端将该任务加入本地“活跃图片任务集合” +3. 前端对活跃任务执行轻量轮询: + - `status` + - `result` +4. 一旦状态变化,原位更新对应消息卡或占位块 + +后续可升级为事件驱动,但首期必须先有轮询闭环。 + +## 7.4 消息幂等更新规则 + +同一个 `task_id` 只允许存在一个主任务卡消息。 + +更新策略: + +- 找到相同 `task_id` 的消息则原位替换 +- 找不到则插入新消息 +- 禁止在成功时再追加第二条“结果消息” + +--- + +## 八、Worker / 执行器要求 + +## 8.1 执行器职责 + +图片执行器必须独立于 skill 运行,负责: + +1. 读取 `image_generate` 任务 +2. 更新任务状态为 `queued / running` +3. 调用真实图片服务 +4. 成功时把结果写回 `result` +5. 失败时把错误写回 `last_error` +6. 支持 retry + +## 8.2 渠道要求 + +当前默认图片执行渠道可使用 FAL,但协议层不能写死某一家 provider。 + +需要保证: + +- `provider` +- `model` +- `result.images[]` + +都从执行结果中标准化落盘。 + +## 8.3 队列与并发 + +图片任务默认是异步队列任务。 + +首期不要求完整的分布式调度,但必须支持以下语义: + +- 队列中等待 +- 运行中 +- 同一任务不重复执行 +- 失败后可重试 + +--- + +## 九、错误、重试、队列、幂等 + +## 9.1 错误处理 + +失败任务必须满足: + +- `status = failed` +- `last_error` 不为空 +- 前端任务卡可显示错误摘要 + +不允许只 toast 一下然后丢失任务状态。 + +## 9.2 重试 + +重试规则: + +1. 重试不直接覆写旧任务。 +2. 重试创建一条新的任务尝试记录。 +3. 新任务需要保留 `source_task_id` 指向原任务。 +4. 前端重试入口默认追踪新任务卡或更新同一业务槽位绑定。 + +## 9.3 队列 + +当执行资源不足时,任务可停留在 `queued`。 + +前端应明确展示: + +- 当前任务尚未开始执行 +- 并非失败 + +## 9.4 幂等 + +同一张图片请求在网络抖动、用户重复点击、skill 重复提交时,不应创建多条等价任务。 + +幂等策略默认基于: + +- `session_id` +- `entry_source` +- `prompt` +- `size` +- `count` +- `usage` +- 可选 `content_id` + +组合生成稳定 `idempotency_key`。 + +--- + +## 十、与现有能力的收敛关系 + +本方案不是完全推翻现有实现,而是做主链收敛。 + +### 10.1 可复用现有能力 + +- `@配图` 路径已有的图片任务卡视觉样式 +- 图片工作台的展开与聚焦交互 +- 图片消息原位更新能力 + +### 10.2 需要补齐的主链 + +- `image_generate` skill 的任务结果回流 +- task status / result 前端统一读取网关 +- 文本占位与 `task_id` 映射 +- 历史恢复逻辑 + +### 10.3 最终收敛目标 + +无论入口来自: + +- `@配图` +- `image_generate` +- 正文自动配图 +- 封面生成 +- 自动化流程 + +都应尽量统一为: + +- 同一套任务协议 +- 同一套状态机 +- 同一套图片任务卡语义 +- 同一套图片结果回写规则 + +--- + +## 十一、验收标准 + +### 11.1 用户体验验收 + +1. 用户在 Claw 中通过图片 skill 发起任务后,聊天区立刻出现图片占位卡。 +2. 任务执行中,占位卡保持动态渲染,不出现“无反馈空窗”。 +3. 任务成功后,占位卡自动替换为真实图片。 +4. 点击成功图片可以打开图片工作台或图片画布。 +5. 任务失败后,聊天区显示失败态和重试入口。 +6. 刷新或恢复会话后,图片任务状态能恢复。 +7. 同一任务不会在聊天区生成多张重复卡片。 + +### 11.2 协议验收 + +1. `image_generate` skill 输出稳定任务字段。 +2. `lime task create image --json` 返回稳定 JSON。 +3. `lime task status` 能读到标准状态。 +4. `lime task result` 能读到标准结果结构。 +5. `retry / cancel / list` 行为符合任务协议。 + +### 11.3 工程验收 + +1. 前端新增稳定回归测试: + - 占位卡插入 + - 原位更新 + - 成功替换 + - 失败切换 + - 历史恢复 +2. 命令边界校验通过: + - `npm run test:contracts` +3. GUI 最小冒烟通过: + - `npm run verify:gui-smoke` + +--- + +## 十二、实施建议 + +### Phase 1:协议补齐 + +- 明确图片任务结果 schema +- 暴露前端任务读取接口 +- 保证执行器会写回结果与错误 + +### Phase 2:Claw 聊天区动态卡 + +- 监听任务创建 +- 插入占位图片卡 +- 轮询状态并原位更新 + +### Phase 3:正文占位替换 + +- 支持正文中的图片占位槽位 +- 建立 `slot_id -> task_id` 映射 +- 完成后原位替换为真实图片 + +### Phase 4:统一收敛 + +- 让 `@配图` 与 `image_generate` 共用统一图片任务 UI 语义 +- 推进封面、自动配图、自动化流程复用同一协议 + +--- + +## 十三、结论 + +图片生成必须被定义为一个异步、解耦、标准化的核心 skill,而不是某个页面里的特殊前端逻辑。 + +真正正确的主线不是“让 skill 自己流式出图”,而是: + +- `skill 创建任务` +- `任务协议承载状态` +- `执行器异步生成` +- `前端动态渲染并原位替换` + +这样才能同时满足: + +- 用户体验可见 +- 工程边界清晰 +- 测试可做 +- 能力可组合 +- 后续可扩展到队列、重试、自动化与多 worker + diff --git a/docs/prd/tools/README.md b/docs/prd/tools/README.md index 158dc3357..e03afc7e7 100644 --- a/docs/prd/tools/README.md +++ b/docs/prd/tools/README.md @@ -43,7 +43,7 @@ Lime 实际已经具备这些能力: - `search_tools` - `list_tools_for_context` -- `tool_search` bridge tool +- `ToolSearch` bridge tool - `deferred_loading` - `allowed_callers` - `input_examples` @@ -75,9 +75,9 @@ Lime 实际已经具备这些能力: | current | `src-tauri/src/agent_tools/catalog.rs` | 完整 native tool 目录与默认授权子集 | | current | `src-tauri/src/agent_tools/execution.rs` | 统一 execution 层的 warning / sandbox / 参数限制事实源 | | current | `src-tauri/crates/mcp/src/manager.rs` | MCP tools runtime registry | -| current | `src-tauri/src/commands/aster_agent_cmd.rs` | Aster runtime 注入、tool_search、inventory 命令 | +| current | `src-tauri/src/commands/aster_agent_cmd.rs` | Aster runtime 注入、`ToolSearch`、inventory 命令 | | current | `src-tauri/src/agent_tools/inventory.rs` | runtime 工具库存快照 | -| compat | `SubAgentTask` | 兼容旧子代理工具名,仍可见但应逐步退出 | +| compat | `SubAgentTask` | 兼容旧子代理工具名;current 协作工具面已收敛到 `Agent / SendMessage / TeamCreate / TeamDelete / ListPeers` | | compat | `workspace_allowed_tool_names(...)` | 当前保留为旧调用入口别名,实际委托默认授权目录 | | dead-candidate | `src-tauri/crates/agent/src/tool_permissions.rs` | 已退出 `lime-agent` 的 `lib.rs` 编译图,仅通过 `src-tauri/crates/agent/tests/legacy_permission_surfaces.rs` 测试夹具加载 | | dead-candidate | `src-tauri/crates/agent/src/shell_security.rs` | 已退出 `lime-agent` 的 `lib.rs` 编译图,仅通过 `src-tauri/crates/agent/tests/legacy_permission_surfaces.rs` 测试夹具加载 | @@ -147,7 +147,7 @@ Lime 实际已经具备这些能力: 负责: -- `bash` / `Task` 的 warning gate 语义 +- `Bash` / `Task*` 的 warning gate 语义 - workspace 参数限制模板 - sandbox profile 归类 - execution permission 模板生成 @@ -200,7 +200,7 @@ Lime 实际已经具备这些能力: - **runtime 事实源没有新增** - **测试入口新增了一条更轻的执行面** -- `tool_search` 与 inventory 的 extension 状态判定也已继续收口到共享 helper,避免主包再次长出重复逻辑 +- `ToolSearch` 与 inventory 的 extension 状态判定也已继续收口到共享 helper,避免主包再次长出重复逻辑 ### 3.7 旧权限表面下沉 @@ -228,13 +228,20 @@ Lime 实际已经具备这些能力: ### 4.1 Core surface -- **Aster built-ins**:19 个 - `read` / `write` / `edit` / `glob` / `grep` / `bash` / `lsp` / `Skill` / `Task` / `TaskOutput` / `KillShell` / `TodoWrite` / `NotebookEdit` / `EnterPlanMode` / `ExitPlanMode` / `WebFetch` / `WebSearch` / `analyze_image` / `ask` +- **Aster built-ins 与 current tool surface** + `Read` / `Write` / `Edit` / `Glob` / `Grep` / `Bash` / `LSP` / `Skill` / `TaskCreate` / `TaskList` / `TaskGet` / `TaskUpdate` / `TaskOutput` / `TaskStop` / `NotebookEdit` / `EnterPlanMode` / `ExitPlanMode` / `EnterWorktree` / `ExitWorktree` / `WebFetch` / `WebSearch` / `AskUserQuestion` / `SendUserMessage` -- **Lime injected core tools**:7 个 - `tool_search` / `spawn_agent` / `send_input` / `wait_agent` / `resume_agent` / `close_agent` / `SubAgentTask` +- **Lime injected current tools** + `ToolSearch` / `ListMcpResourcesTool` / `ReadMcpResourceTool` / `Agent` -- **Core surface catalog total**:26 个 +- **Team runtime current surface** + `Agent` / `SendMessage` / `TeamCreate` / `TeamDelete` / `ListPeers` + +- **Compat only** + `SubAgentTask` + +- **说明** + Core surface 现已按 current surface 收敛;精确数量与分类以 `src-tauri/src/agent_tools/catalog.rs` 为准。 ### 4.2 Workbench surface diff --git a/docs/prd/tools/inventory.md b/docs/prd/tools/inventory.md index 14a4a5cad..14a5bd9ee 100644 --- a/docs/prd/tools/inventory.md +++ b/docs/prd/tools/inventory.md @@ -4,41 +4,54 @@ ## 1.1 Core surface -### Aster built-ins(19) +### Aster built-ins 与 current surface -- `read` -- `write` -- `edit` -- `glob` -- `grep` -- `bash` -- `lsp` +- `Read` +- `Write` +- `Edit` +- `Glob` +- `Grep` +- `Bash` +- `LSP` - `Skill` -- `Task` +- `TaskCreate` +- `TaskList` +- `TaskGet` +- `TaskUpdate` - `TaskOutput` -- `KillShell` -- `TodoWrite` +- `TaskStop` - `NotebookEdit` - `EnterPlanMode` - `ExitPlanMode` +- `EnterWorktree` +- `ExitWorktree` - `WebFetch` - `WebSearch` -- `analyze_image` -- `ask` +- `AskUserQuestion` +- `SendUserMessage` -### Lime injected(7) +### Lime injected current tools + +- `ToolSearch` +- `ListMcpResourcesTool` +- `ReadMcpResourceTool` +- `Agent` + +### Team runtime current surface + +- `Agent` +- `SendMessage` +- `TeamCreate` +- `TeamDelete` +- `ListPeers` + +### Compat only -- `tool_search` -- `spawn_agent` -- `send_input` -- `wait_agent` -- `resume_agent` -- `close_agent` - `SubAgentTask` ### Core 总数 -- **26 个 catalog entries** +- 以 `src-tauri/src/agent_tools/catalog.rs` 当前目录为准,不再手写旧 surface 固定数值 --- @@ -55,7 +68,7 @@ ### Workbench 总数 -- **34 个 catalog entries** +- 以 `src-tauri/src/agent_tools/catalog.rs` 当前目录为准 --- @@ -70,38 +83,36 @@ ### Browser Assist 总数 -- **27 个 catalog entries** +- 以 `src-tauri/src/agent_tools/catalog.rs` 当前目录为准 ### Workbench + Browser Assist 总数 -- **35 个 catalog entries** +- 以 `src-tauri/src/agent_tools/catalog.rs` 当前目录为准 --- ## 2. 默认授权子集 -Core surface 当前默认 allow 的工具为: +Core surface 当前默认 allow 的工具集已经收敛到 current surface,重点包括: - `Skill` - `TaskOutput` -- `KillShell` -- `TodoWrite` - `EnterPlanMode` - `ExitPlanMode` - `WebSearch` -- `ask` -- `tool_search` -- `spawn_agent` -- `send_input` -- `wait_agent` -- `resume_agent` -- `close_agent` -- `SubAgentTask` +- `AskUserQuestion` +- `ToolSearch` +- `Agent` +- `SendMessage` +- `TeamCreate` +- `TeamDelete` +- `ListPeers` 结论: -- 默认 allowlist 是 **15 个** -- 明确排除了 `read` / `write` / `edit` / `bash` / `WebFetch` / `analyze_image` 这类需要参数约束或更强执行控制的工具 +- 默认 allowlist 继续优先保留常驻、小而稳的工具面 +- `SubAgentTask` 已降为 compat 入口,不再作为 current 默认协作工具面 +- `read` / `write` / `edit` / `bash` / `WebFetch` 这类需要参数约束或更强执行控制的工具仍不应默认放开 这符合“常驻工具面小而稳”的原则。 @@ -180,7 +191,7 @@ const snapshot = await getAgentRuntimeToolInventory({ - registry - mcp manager - extension manager -- tool_search 输出 +- `ToolSearch` 输出 现在一条命令就能同时回答这些问题: diff --git a/docs/prd/tools/task-file-protocol-prd.md b/docs/prd/tools/task-file-protocol-prd.md new file mode 100644 index 000000000..dc71f80de --- /dev/null +++ b/docs/prd/tools/task-file-protocol-prd.md @@ -0,0 +1,835 @@ +# Lime 统一 Task File 协议 PRD + +更新时间:2026-04-03 + +## 1. 背景 + +Lime 当前已经具备一套轻量 task file 底座,能够覆盖: + +- `image_generate` +- `cover_generate` +- `video_generate` +- `broadcast_generate` +- `url_parse` +- `typesetting` +- `modal_resource_search` + +现有协议已经解决了“任务创建、状态读取、简单重试”的基础问题,但当 Lime 开始把图片生成、图片编辑、视频生成、视频编辑、自动化工作流、Claw 动态渲染统一收口到同一条主线时,现有 task file 还存在四类缺口: + +1. **顶层字段不够稳定** + - 现在更像“任务记录壳”,还不是“统一任务协议信封” +2. **任务结果缺少结构化语义** + - `result` 还是宽泛 `Value`,对前端、CLI、worker、调试都不够友好 +3. **重试与执行历史太轻** + - 目前只有 `retry_count` 与 `source_task_id`,不够支撑一任务多尝试 +4. **缺少关系图能力** + - 无法自然表达父子任务、依赖任务、来源素材、正文占位槽位 + +这会直接影响后续几条主线: + +- Claw 对话框中的动态占位图与结果替换 +- 图片生成 / 图片编辑 / 视频生成 / 视频编辑的统一观察面 +- 队列、重试、幂等、恢复、诊断能力 +- 类似竞品“统一任务面板”的可交付体验 + +因此,task file 需要从“轻量任务记录”升级为“统一任务协议”。 + +--- + +## 2. 设计目标 + +本次协议设计只解决六个问题: + +1. **所有异步任务共用一个顶层信封** +2. **不同任务类型拥有各自的强类型 payload / result 子协议** +3. **重试不再默认创建新任务文件,而是进入同一任务的多次尝试** +4. **首期即支持父子任务、依赖关系、来源关系** +5. **前端可以只观察 task file,就完成占位、进度、结果替换、失败重试** +6. **CLI、worker、前端、测试都围绕同一事实源,而不是各自维护状态** + +非目标: + +- 不把所有任务字段塞成一个超大万能 schema +- 不让每种任务各写一份完全独立的 JSON 协议 +- 不把大日志、大二进制结果直接塞进主 task file + +--- + +## 3. 核心结论 + +### 3.1 统一信封 + 类型化子协议 + +统一顶层任务信封,稳定承载: + +- 身份 +- 生命周期 +- 执行尝试 +- 关系图 +- 进度 +- UI 提示 +- 审计信息 + +不同任务类型的差异全部进入: + +- `payload` +- `result` + +### 3.2 一任务多尝试 + +每个业务任务保持稳定 `task_id`,重试不会默认生成新的 task file。 + +统一使用: + +- `task_id` +- `current_attempt_id` +- `attempts[]` + +这样前端能稳定盯住一条任务卡,CLI 也能查看历史尝试。 + +### 3.3 首期支持父子任务与依赖关系 + +任务关系不是“以后再说”的增强项,而是首期协议能力。统一支持: + +- 父任务 +- 根任务 +- 前置依赖 +- 子任务 +- 来源素材 +- 来源任务尝试 +- 正文占位槽位 + +### 3.4 Task File 仍是唯一真相源 + +未来即使加数据库索引或任务看板缓存层,task file 仍然是当前唯一事实源。 + +- 前端观察它 +- CLI 读取它 +- worker 更新它 +- 测试回放它 + +--- + +## 4. 顶层统一信封 + +推荐统一结构如下: + +```json +{ + "task_id": "task_01HQ...", + "task_type": "image_generate", + "task_family": "image", + "title": "正文配图:多模态实验室", + "summary": "生成一张用于正文中的配图", + "status": "running", + "normalized_status": "running", + "priority": "normal", + "created_at": "2026-04-03T09:00:00Z", + "updated_at": "2026-04-03T09:00:12Z", + "submitted_at": "2026-04-03T09:00:02Z", + "started_at": "2026-04-03T09:00:05Z", + "completed_at": null, + "cancelled_at": null, + "idempotency_key": "article-123-slot-4-image-generate-v1", + "workspace_id": "workspace_demo", + "project_id": "project_demo", + "session_id": "session_demo", + "content_id": "content_demo", + "requested_by": { + "kind": "skill", + "name": "image_generate" + }, + "entrypoint": "claw_chat", + "tags": ["article-inline", "multimodal", "image"], + "payload_schema_version": "1.0", + "result_schema_version": "1.0", + "payload": {}, + "result": null, + "last_error": null, + "current_attempt_id": "attempt_01HQ...", + "attempts": [], + "relationships": {}, + "artifacts": [], + "progress": {}, + "ui_hints": {}, + "audit": {} +} +``` + +### 4.1 顶层字段职责 + +#### 身份字段 + +- `task_id` +- `task_type` +- `task_family` + +要求: + +- `task_type` 表示具体任务 +- `task_family` 表示 UI / 队列 / 筛选的聚合维度 + +#### 生命周期字段 + +- `status` +- `normalized_status` +- `created_at` +- `updated_at` +- `submitted_at` +- `started_at` +- `completed_at` +- `cancelled_at` + +#### 路由与归属字段 + +- `workspace_id` +- `project_id` +- `session_id` +- `content_id` +- `requested_by` +- `entrypoint` + +#### 幂等与审计字段 + +- `idempotency_key` +- `tags` +- `audit` + +#### 协议演进字段 + +- `payload_schema_version` +- `result_schema_version` + +--- + +## 5. 任务类型分层 + +不要只依赖 `task_type` 承载全部语义,统一拆成: + +### 5.1 `task_family` + +建议首批 family: + +- `image` +- `video` +- `audio` +- `document` +- `resource` +- `automation` + +### 5.2 `task_type` + +建议首批 type: + +- `image_generate` +- `image_edit` +- `image_variation` +- `cover_generate` +- `video_generate` +- `video_edit` +- `video_upscale` +- `url_parse` +- `typesetting` +- `modal_resource_search` + +### 5.3 为什么必须有 family + +因为以下需求都更适合按 family 而不是按 type 聚合: + +- 统一任务面板分组 +- 队列资源配额 +- 前端工作台默认图标与布局 +- 未来权限与能力开关 + +例如: + +- `image_generate / image_edit / image_variation / cover_generate` + 都属于 `image` +- `video_generate / video_edit / video_upscale` + 都属于 `video` + +--- + +## 6. 状态机 + +统一状态如下: + +- `draft` +- `pending_submit` +- `queued` +- `running` +- `partial` +- `succeeded` +- `failed` +- `cancelled` + +### 6.1 状态语义 + +- `draft` + - 本地构建态,尚未正式提交到任务系统 +- `pending_submit` + - task file 已创建,但尚未真正进入执行队列 +- `queued` + - 已进入执行队列,等待 worker 消费 +- `running` + - worker 已开始执行 +- `partial` + - 已返回部分结果,可供前端先渲染中间态 +- `succeeded` + - 当前任务成功完成 +- `failed` + - 当前任务失败 +- `cancelled` + - 任务被取消 + +### 6.2 顶层状态与 attempt 状态关系 + +- 顶层 `status` 表示当前任务总状态 +- `attempts[].status` 表示单次尝试状态 +- 历史尝试失败不代表顶层失败 +- 只要当前 attempt 成功,顶层状态就是 `succeeded` + +--- + +## 7. 一任务多尝试 + +### 7.1 为什么不用“每次重试一个新任务” + +如果每次重试都新建任务文件,会带来这些问题: + +- 前端需要在旧卡片和新卡片之间重新绑定 +- Claw 动态替换更复杂 +- 统一任务列表会出现大量碎片任务 +- “这其实还是同一个任务”的语义丢失 + +因此默认采用: + +- 一个稳定 `task_id` +- 多次 `attempts[]` + +### 7.2 Attempt 结构 + +```json +{ + "attempt_id": "attempt_01HQ...", + "attempt_index": 2, + "status": "running", + "queued_at": "2026-04-03T09:01:00Z", + "started_at": "2026-04-03T09:01:03Z", + "completed_at": null, + "provider": "fal", + "model": "fal-ai/nano-banana-pro", + "worker_id": "worker_local_1", + "input_snapshot": {}, + "result_snapshot": null, + "error": null, + "metrics": { + "queue_ms": 3000, + "run_ms": 0 + }, + "logs_ref": ".lime/tasks-logs/task_01HQ.../attempt_2.jsonl" +} +``` + +### 7.3 规则 + +- `retry` 只追加新 attempt +- `current_attempt_id` 指向当前生效 attempt +- `input_snapshot` 保留当次执行输入 +- `result_snapshot` 保留当次执行结果快照 +- `logs_ref` 指向大日志文件,不直接写在主 task file 中 + +--- + +## 8. 父子任务与依赖关系 + +统一在 `relationships` 中定义任务图结构: + +```json +{ + "parent_task_id": "task_parent_01", + "root_task_id": "task_root_01", + "depends_on_task_ids": ["task_dep_01"], + "child_task_ids": ["task_child_01", "task_child_02"], + "source_asset_ids": ["asset_01"], + "derived_from_attempt_id": "attempt_01", + "triggered_by_skill": "image_generate", + "triggered_by_message_id": "message_01", + "slot_id": "article-image-slot-4" +} +``` + +### 8.1 适用场景 + +#### 图片编辑 + +- `image_edit` 依赖一张原图任务 +- 使用 `source_asset_ids` +- 可选 `parent_task_id` + +#### 视频生成 + +- `video_generate` 依赖图片任务或素材任务 +- 使用 `depends_on_task_ids` + +#### 正文多图 + +- 一篇文章下多张图片属于同一父任务或同一根任务 +- 使用 `parent_task_id / root_task_id` + +#### 正文占位替换 + +- 用 `slot_id` 把正文中的占位块与任务绑定 + +### 8.2 为什么首期必须支持关系图 + +因为一旦只保留扁平任务列表,后续以下能力都会变复杂: + +- 图片编辑链 +- 视频生成链 +- 多图正文插槽替换 +- 工作流调试 +- 任务树面板 + +--- + +## 9. Payload / Result 子协议 + +顶层信封统一,差异全部进入子协议。 + +## 9.1 图片生成 + +### `image_generate.payload` + +```json +{ + "prompt": "一个充满未来感的实验室,中心是一个发光的大脑", + "negative_prompt": "模糊,低清晰度", + "style": "cinematic", + "size": "1280x720", + "aspect_ratio": "16:9", + "count": 1, + "usage": "article-inline", + "reference_assets": [] +} +``` + +### `image_generate.result` + +```json +{ + "prompt": "一个充满未来感的实验室,中心是一个发光的大脑", + "provider": "fal", + "model": "fal-ai/nano-banana-pro", + "seed": 12345, + "images": [ + { + "asset_id": "asset_img_01", + "kind": "image", + "url": "https://...", + "thumbnail_url": "https://...", + "mime_type": "image/png", + "width": 1280, + "height": 720, + "size_bytes": 734002, + "storage_ref": "s3://...", + "preview_ref": ".lime/previews/asset_img_01.json" + } + ], + "billing": { + "provider_cost": 0.04 + } +} +``` + +## 9.2 图片编辑 + +### `image_edit.payload` + +```json +{ + "prompt": "去掉图中的文字,保留主体和整体构图", + "edit_mode": "inpaint", + "source_asset": "asset_img_01", + "mask_asset": null, + "strength": 0.65, + "preserve_regions": ["subject"] +} +``` + +### `image_edit.result` + +```json +{ + "provider": "fal", + "model": "fal-ai/...", + "images": [ + { + "asset_id": "asset_img_edit_01", + "kind": "image", + "url": "https://...", + "thumbnail_url": "https://...", + "mime_type": "image/png", + "width": 1280, + "height": 720 + } + ], + "diff_summary": "已移除文字,主体保持不变" +} +``` + +## 9.3 视频生成 + +### `video_generate.payload` + +```json +{ + "prompt": "未来实验室镜头缓慢推进,发光大脑悬浮在中央", + "duration": 5, + "resolution": "1080p", + "aspect_ratio": "16:9", + "fps": 24, + "camera_motion": "slow_push_in", + "start_frame_asset": "asset_img_01", + "end_frame_asset": null, + "audio_mode": "mute" +} +``` + +### `video_generate.result` + +```json +{ + "provider": "runway", + "model": "gen4", + "videos": [ + { + "asset_id": "asset_video_01", + "kind": "video", + "url": "https://...", + "thumbnail_url": "https://...", + "mime_type": "video/mp4", + "width": 1920, + "height": 1080, + "duration_ms": 5000 + } + ], + "poster_frames": ["asset_img_frame_01"] +} +``` + +## 9.4 视频编辑 + +### `video_edit.payload` + +```json +{ + "source_asset": "asset_video_01", + "edit_mode": "trim_and_overlay", + "trim": { + "start_ms": 1000, + "end_ms": 4500 + }, + "overlay_assets": ["asset_logo_01"], + "subtitle_spec": null, + "target_format": "mp4" +} +``` + +### `video_edit.result` + +```json +{ + "provider": "local_ffmpeg", + "model": null, + "videos": [ + { + "asset_id": "asset_video_edit_01", + "kind": "video", + "url": "file:///...", + "thumbnail_url": "file:///...", + "mime_type": "video/mp4", + "width": 1920, + "height": 1080, + "duration_ms": 3500 + } + ], + "timeline_summary": "裁剪并叠加品牌角标" +} +``` + +--- + +## 10. 进度、错误与 UI 提示 + +为了支撑 Claw 动态占位与结果替换,task file 不能只提供最终状态,还必须提供“可渲染的运行时信息”。 + +## 10.1 `progress` + +```json +{ + "phase": "rendering_preview", + "percent": 42, + "message": "正在生成预览图", + "preview_slots": [ + { + "slot_id": "article-image-slot-4", + "label": "正文配图 1", + "status": "running" + } + ] +} +``` + +要求: + +- `phase` 适合前端直接渲染 +- `percent` 可选,但若提供必须可信 +- `preview_slots` 用于正文占位块与消息卡绑定 + +## 10.2 `last_error` + +错误结构不再只是一段字符串: + +```json +{ + "code": "provider_timeout", + "message": "FAL 请求超时", + "retryable": true, + "stage": "provider_call", + "provider_code": "TIMEOUT", + "occurred_at": "2026-04-03T09:05:00Z" +} +``` + +要求: + +- 前端可以直接根据 `retryable` 决定是否展示重试按钮 +- CLI 可以根据 `code` 给出更稳定的退出提示 + +## 10.3 `ui_hints` + +```json +{ + "render_mode": "media_placeholder_card", + "placeholder_text": "[img:multimodal:一个充满未来感的实验室]", + "preferred_surface": "claw_chat", + "open_action": "open_image_workbench" +} +``` + +要求: + +- 前端不必猜该如何渲染 +- 但 `ui_hints` 只能是提示,不能覆盖真实任务状态 + +--- + +## 11. Artifacts 与大文件策略 + +主 task file 不直接存放大日志、大二进制结果。 + +统一使用 `artifacts[]` 指向外部资源: + +```json +[ + { + "artifact_id": "artifact_preview_01", + "kind": "preview_manifest", + "path": ".lime/task-artifacts/task_01/preview.json" + }, + { + "artifact_id": "artifact_log_01", + "kind": "attempt_log", + "path": ".lime/task-logs/task_01/attempt_2.jsonl" + } +] +``` + +规则: + +- 主文件只保留快速读所需信息 +- 详细日志使用外部引用 +- 结果媒体本体使用 `url / storage_ref` + +--- + +## 12. 存储路径建议 + +当前根目录继续保持: + +- `.lime/tasks` + +但建议按 family / type 继续细分: + +- `.lime/tasks/image/image_generate/.json` +- `.lime/tasks/image/image_edit/.json` +- `.lime/tasks/video/video_generate/.json` +- `.lime/tasks/video/video_edit/.json` + +相关外置目录: + +- `.lime/task-logs//attempt_.jsonl` +- `.lime/task-artifacts//...` +- `.lime/previews/.json` + +这样做的好处: + +- 目录语义更清晰 +- family/type 过滤更便宜 +- worker 和 CLI 更容易做局部扫描 + +--- + +## 13. CLI 与前端接口影响 + +## 13.1 CLI + +现有 CLI 需要在协议升级后扩展到: + +- `lime task create ` +- `lime task status ` +- `lime task result ` +- `lime task attempts ` +- `lime task retry ` +- `lime task cancel ` +- `lime task list --family image --type image_edit --status running` +- 可选:`lime task graph ` + +## 13.2 前端 / Tauri + +前端统一通过 API 网关读取任务: + +- `lime_task_get_status` +- `lime_task_get_result` +- `lime_task_get_attempts` +- `lime_task_list` +- `lime_task_retry` +- `lime_task_cancel` + +前端不允许在页面和组件里自行解析零散任务文件路径。 + +--- + +## 14. 典型场景 + +## 14.1 正文插图生成 + +流程: + +1. 用户在 Claw 中触发 `image_generate` +2. 创建 `task_family=image / task_type=image_generate` +3. `relationships.slot_id` 绑定正文占位块 +4. 前端先显示占位图 +5. worker 完成后写回 `result.images[]` +6. 前端原位替换为真实图片 + +## 14.2 基于图片生成视频 + +流程: + +1. 用户选中一张已生成图片 +2. 创建 `video_generate` +3. `depends_on_task_ids` 指向图像任务 +4. `payload.start_frame_asset` 指向图像资产 +5. 任务面板中可看到“视频任务依赖图片任务” + +## 14.3 图片失败后重试 + +流程: + +1. 顶层 `task_id` 不变 +2. 新增 `attempts[2]` +3. `current_attempt_id` 切换到 attempt 2 +4. 前端仍更新同一张任务卡 + +--- + +## 15. 测试与验收 + +## 15.1 协议测试 + +- 顶层信封反序列化稳定 +- `image_generate / image_edit / video_generate / video_edit` 子协议通过 schema 校验 +- 顶层状态与 attempt 状态语义一致 +- 重试只新增 attempt,不新增 task_id +- 关系字段能正确表达父子任务与依赖关系 + +## 15.2 CLI 测试 + +- `create/status/result/list/retry/cancel/attempts` 输出稳定 +- `--family / --type / --status` 过滤有效 +- `idempotency_key` 生效 +- 大日志不塞进主文件 + +## 15.3 前端测试 + +- 同一 `task_id` 在聊天区原位更新 +- 占位图到真实图替换成功 +- 失败后展示结构化错误与重试入口 +- `slot_id` 绑定的正文占位块替换正确 +- 刷新或恢复会话后能从 task file 恢复显示 + +## 15.4 可交付验收 + +这套协议至少要支撑以下三个已验证任务族: + +1. 图片生成 +2. 图片编辑 +3. 视频生成 + +并且保证: + +- Claw、CLI、worker 读到的是同一份状态 +- 可以支撑统一任务面板,而不需要第二套状态系统 + +--- + +## 16. 分阶段落地建议 + +## Phase 1:统一信封升级 + +- 升级 `TaskArtifactRecord` +- 补充 `task_family / progress / relationships / ui_hints` +- 把 `last_error` 从字符串升级为对象 + +## Phase 2:attempts 模型 + +- 引入 `attempts[]` +- 引入 `current_attempt_id` +- 调整 retry 语义 + +## Phase 3:图片与视频子协议 + +- 先补齐: + - `image_generate` + - `image_edit` + - `video_generate` +- 让 worker 能稳定写回结果 + +## Phase 4:前端与任务面板 + +- 前端基于统一协议渲染聊天区任务卡 +- 正文占位块改为 `slot_id + task_id` 绑定 +- 准备统一任务面板 + +--- + +## 17. 最终结论 + +task file 应该被设计成 Lime 的统一任务协议,而不是某个业务临时写出的 JSON 记录。 + +正确方向不是: + +- 每种任务各自定义一份完全不同的文件 +- 或者把所有任务字段全塞进一份万能顶层 schema + +而是: + +- **统一顶层信封** +- **类型化 payload/result** +- **一任务多尝试** +- **父子任务与依赖关系** +- **结构化进度、错误、UI 提示** + +这样 Lime 才能把图片生成、图片编辑、视频生成、视频编辑、Claw 动态渲染、CLI、worker、统一任务面板全部收敛到同一条主链。 + diff --git a/docs/product-overview.md b/docs/product-overview.md deleted file mode 100644 index 0647ad6f7..000000000 --- a/docs/product-overview.md +++ /dev/null @@ -1,126 +0,0 @@ -# Lime 创作类 AI Agent 平台 - 产品介绍 - -> 版本: 2.0.0 -> 更新: 2026-02-16 -> 用途: 产品介绍、客户演示、团队对齐 - ---- - -## 一、产品定位 - -**面向创作者的一站式 AI Agent 平台** - -Lime 不是单点工具,而是一条完整创作链路: - -- 从灵感讨论开始 -- 到文本与图片产出 -- 再到项目沉淀与长期复用 - -核心理念:**让创作更快,但主导权始终在创作者手中**。 - ---- - -## 二、九类工作区主题 - -| 主题 | 典型任务 | 常见产出 | -|------|----------|----------| -| 通用对话 | 灵感发散、问题梳理 | 对话结论、行动草案 | -| 社媒内容 | 选题、标题、正文 | 多平台文案 | -| 图文海报 | 活动视觉、品牌传播 | 海报文案、配图方案 | -| 歌词曲谱 | 主题创作、段落续写 | 歌词草稿、结构框架 | -| 知识探索 | 学习拆解、资料整合 | 知识卡片、总结笔记 | -| 计划规划 | 目标拆解、执行跟踪 | 周计划、任务清单 | -| 办公文档 | 报告、邮件、纪要 | 可交付文档 | -| 短视频 | 口播、脚本、分镜 | 拍摄脚本、内容提纲 | -| 小说创作 | 世界观、人物、章节 | 连载章节、剧情草案 | - ---- - -## 三、创作流程 - -### 1) 对话定方向 - -先用 AI Agent 明确目标、受众和输出形式。 - -### 2) 生成首稿 - -按主题工作流生成内容初稿,快速得到可迭代版本。 - -### 3) 图片与素材补齐 - -在图片生成功能中完成视觉产出,支持参考图参与编辑。 - -### 4) 资产沉淀 - -把文档、图片、语音、视频统一沉淀到项目资源库,便于复用。 - ---- - -## 四、典型用户场景 - -### 场景 1:自媒体日更 - -- 早上 10 分钟定选题 -- 中午完成首稿与配图 -- 下午发布并沉淀素材用于复盘 - -### 场景 2:短视频团队周更 - -- 统一脚本结构 -- 批量生成口播与镜头要点 -- 版本资产留档,便于协同交接 - -### 场景 3:小说连载 - -- 持续维护设定与角色 -- 章节迭代不丢上下文 -- 连载节奏稳定、可持续推进 - -### 场景 4:品牌与运营活动 - -- 快速生成多套文案与视觉方向 -- 统一保存历史版本与素材 -- 缩短从想法到上线的周期 - ---- - -## 五、产品价值 - -| 价值 | 体现 | -|------|------| -| 创作效率提升 | 同一处完成对话、出稿、出图、沉淀 | -| 结果可复用 | 项目化管理历史内容与素材 | -| 团队协作更顺畅 | 上下文和资产都可追溯 | -| 门槛更低 | 先用再学,按需开启进阶能力 | - ---- - -## 六、目标用户 - -- 自媒体与内容创作者 -- 短视频脚本团队 -- 小说与剧情创作者 -- 品牌运营与营销团队 -- 需要长期沉淀创作资产的个人与小团队 - ---- - -## 七、进阶能力(可选) - -对于开发者或自动化场景,Lime 还提供: - -- 本地 API 接入 -- MCP 工具扩展 -- 插件扩展体系 - -普通创作者不需要先配置这些能力,也能完成完整创作流程。 - ---- - -## 八、推荐阅读 - -- [文档首页](content/index.md) -- [快速开始](content/01.introduction/3.quickstart.md) -- [首页与工作台](content/02.user-guide/1.dashboard.md) -- [资源库](content/02.user-guide/14.resources.md) -- [图片生成与编辑](content/02.user-guide/15.image-generation.md) diff --git a/docs/roadmap/lime-service-skill-cloud-config-prd.md b/docs/roadmap/lime-service-skill-cloud-config-prd.md index 4c52f8ae8..e4b894dc7 100644 --- a/docs/roadmap/lime-service-skill-cloud-config-prd.md +++ b/docs/roadmap/lime-service-skill-cloud-config-prd.md @@ -10,9 +10,8 @@ `lime` 侧主要事实源: -- `src/components/agent/chat/AgentChatHomeShell.tsx` +- `src/components/agent/chat/index.tsx` - `src/components/agent/chat/components/EmptyState.tsx` -- `src-tauri/src/services/claw_solution_service.rs` - `src-tauri/src/skills/README.md` - `src-tauri/src/services/automation_service/mod.rs` - `src-tauri/src/services/execution_tracker_service.rs` @@ -26,7 +25,7 @@ 关键环境事实: -1. `lime` 已有首页壳、技能执行链、自动化调度链、执行追踪链。 +1. `lime` 已有任务入口、技能执行链、自动化调度链、执行追踪链。 2. `limecore` 已有 `control-plane-svc / gateway-svc / scene-orchestrator-svc`,并已有客户端 bootstrap 聚合接口。 3. `bootstrap.serviceCatalog` 现有语义偏商业服务目录,不适合直接承载新的服务型技能目录。 4. `scene-orchestrator-svc` 已定位为 Scene 运行时服务,但不应在本方案中演进为默认主执行流。 diff --git a/package.json b/package.json index 3a66e68d9..38a797b34 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.1.0", + "version": "1.2.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md new file mode 100644 index 000000000..f087f1360 --- /dev/null +++ b/packages/lime-cli-npm/README.md @@ -0,0 +1,128 @@ +# `@limecloud/lime-cli` + +Lime 的官方命令行入口,面向统一任务编排。 + +当前 npm 首发策略: + +- 优先尝试下载预编译二进制 +- 如果预编译资产暂未发布,npm 安装不会失败 +- 运行时会优先查找: + - `LIME_CLI_BINARY_PATH` + - 包内已存在的预编译二进制 + - 当前源码仓库中的本地构建产物 + - 当前源码仓库中的 `cargo run -p lime-cli` + +当前主线提供: + +- `lime task create image` +- `lime task create cover` +- `lime task create video` +- `lime task create broadcast` +- `lime task create url-parse` +- `lime task create typesetting` +- `lime task create resource-search` +- `lime task status` +- `lime task list` +- `lime task retry` +- `lime task cancel` +- `lime task result` +- `lime skill list` +- `lime skill show` +- `lime doctor` + +安装方式: + +```bash +npm install -g @limecloud/lime-cli +``` + +说明: + +- npm wrapper 在 `postinstall` 阶段会从 `https://github.com/limecloud/lime/releases` 下载同版本预编译二进制。 +- 当前预编译 release asset 覆盖:`darwin/arm64`、`darwin/amd64`、`windows/amd64`、`linux/amd64`。 +- 若对应版本的 release asset 尚未发布,安装会失败,此时请先发布 GitHub release asset,或在本地直接编译 Rust 二进制使用。 + +示例: + +```bash +lime task create image \ + --prompt "未来城市插图,蓝色电影感" \ + --size "1024x1024" \ + --workspace "." \ + --idempotency-key "image-future-city" +``` + +成功时标准输出为 JSON,包含: + +- `task_id` +- `task_type` +- `status` +- `normalized_status` +- `artifact_path` +- `absolute_artifact_path` +- `reused_existing` + +失败时标准错误同样输出结构化 JSON,包含: + +- `error_code` +- `error_message` +- `retryable` +- `hint` + +常用命令: + +```bash +lime task list --status failed +lime task status +lime task retry +lime task cancel +lime skill list +lime skill show broadcast_generate +lime doctor +``` + +说明: + +- `lime media image|cover|video generate` 仍保留为兼容别名。 +- 新主线统一收敛到 `lime task create ...`。 +- 如果你现在只发布 npm、不发布 GitHub Release,请至少准备一种运行方式: + - 设置 `LIME_CLI_BINARY_PATH` + - 或在 Lime 源码仓库内使用该 wrapper,让它自动回退到 `cargo run` + +## 维护者发布流程 + +1. 先构建对应目标平台的 `lime-cli`: + +```bash +cargo build --manifest-path "../../src-tauri/Cargo.toml" -p lime-cli --release +``` + +2. 生成和 npm wrapper 约定一致的 release asset: + +```bash +npm run build:release -- \ + --binary "../../src-tauri/target/release/lime" \ + --out-dir "./dist" +``` + +也可以在 CI 中直接传 target triple: + +```bash +npm run build:release -- \ + --target-triple "aarch64-apple-darwin" \ + --version "1.2.0" \ + --out-dir "./dist" +``` + +3. 将生成的归档上传到 GitHub Release,对应命名形如: + +- `lime--darwin-arm64.tar.gz` +- `lime--darwin-amd64.tar.gz` +- `lime--windows-amd64.zip` +- `lime--linux-amd64.tar.gz` + +4. 确认 release asset 已上传后,再执行 npm 发布: + +```bash +npm publish --access public +``` diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json new file mode 100644 index 000000000..405aec5e1 --- /dev/null +++ b/packages/lime-cli-npm/package.json @@ -0,0 +1,35 @@ +{ + "name": "@limecloud/lime-cli", + "version": "1.2.0", + "description": "Lime 官方任务 CLI", + "bin": { + "lime": "scripts/run.js" + }, + "scripts": { + "postinstall": "node scripts/install.js", + "build:release": "node scripts/build-release.js" + }, + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm64" + ], + "engines": { + "node": ">=18" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/limecloud/lime.git" + }, + "license": "MIT", + "files": [ + "README.md", + "scripts/install.js", + "scripts/release-meta.js", + "scripts/run.js" + ] +} diff --git a/packages/lime-cli-npm/scripts/build-release.js b/packages/lime-cli-npm/scripts/build-release.js new file mode 100644 index 000000000..d6a422945 --- /dev/null +++ b/packages/lime-cli-npm/scripts/build-release.js @@ -0,0 +1,180 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); +const { resolveReleaseAssetMeta } = require("./release-meta"); + +const SCRIPT_DIR = __dirname; +const PACKAGE_DIR = path.resolve(SCRIPT_DIR, ".."); +const REPO_ROOT = path.resolve(PACKAGE_DIR, "..", ".."); + +function parseArgs(argv) { + const parsed = {}; + for (let index = 0; index < argv.length; index += 1) { + const current = argv[index]; + if (!current.startsWith("--")) { + continue; + } + + const key = current.slice(2); + if (key === "json" || key === "help") { + parsed[key] = true; + continue; + } + + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + throw new Error(`Missing value for --${key}`); + } + parsed[key] = next; + index += 1; + } + return parsed; +} + +function printHelp() { + console.log(`Usage: + node packages/lime-cli-npm/scripts/build-release.js [options] + +Options: + --binary Explicit lime binary path + --target-triple Rust target triple, e.g. x86_64-apple-darwin + --platform Host platform fallback, e.g. darwin/linux/win32 + --arch Host arch fallback, e.g. x64/arm64 + --version Release version, defaults to packages/lime-cli-npm/package.json + --out-dir Output directory, defaults to packages/lime-cli-npm/dist + --json Print JSON result only + --help Show this help`); +} + +function resolveBinaryCandidates(meta, targetTriple, explicitBinary) { + const candidates = []; + const push = (value) => { + if (!value) { + return; + } + const absolute = path.resolve(value); + if (!candidates.includes(absolute)) { + candidates.push(absolute); + } + }; + + push(explicitBinary); + + if (targetTriple) { + push(path.join(REPO_ROOT, "src-tauri", "target", targetTriple, "release", meta.binaryName)); + push(path.join(REPO_ROOT, "target", targetTriple, "release", meta.binaryName)); + } + + push(path.join(REPO_ROOT, "src-tauri", "target", "release", meta.binaryName)); + push(path.join(REPO_ROOT, "target", "release", meta.binaryName)); + + return candidates; +} + +function resolveBinaryPath(meta, targetTriple, explicitBinary) { + const candidates = resolveBinaryCandidates(meta, targetTriple, explicitBinary); + const resolved = candidates.find((candidate) => fs.existsSync(candidate)); + if (!resolved) { + throw new Error( + `Unable to find ${meta.binaryName}. Tried:\n${candidates.map((value) => `- ${value}`).join("\n")}`, + ); + } + return resolved; +} + +function createTarGz(archivePath, stagingDir, binaryName) { + execFileSync( + "tar", + ["-czf", archivePath, "-C", stagingDir, binaryName], + { stdio: "inherit" }, + ); +} + +function createZip(archivePath, stagedBinaryPath) { + if (process.platform === "win32") { + execFileSync( + "powershell", + [ + "-NoProfile", + "-Command", + `Compress-Archive -Path '${stagedBinaryPath}' -DestinationPath '${archivePath}' -Force`, + ], + { stdio: "inherit" }, + ); + return; + } + + execFileSync("zip", ["-j", "-q", archivePath, stagedBinaryPath], { + stdio: "inherit", + }); +} + +function createArchive(meta, binaryPath, outDir) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lime-cli-release-")); + try { + const stagedBinaryPath = path.join(tempDir, meta.binaryName); + fs.copyFileSync(binaryPath, stagedBinaryPath); + if (!meta.isWindows) { + fs.chmodSync(stagedBinaryPath, 0o755); + } + + fs.mkdirSync(outDir, { recursive: true }); + const archivePath = path.join(outDir, meta.archiveName); + + if (meta.isWindows) { + createZip(archivePath, stagedBinaryPath); + } else { + createTarGz(archivePath, tempDir, meta.binaryName); + } + + return archivePath; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + printHelp(); + return; + } + + const meta = resolveReleaseAssetMeta({ + version: args.version, + targetTriple: args["target-triple"], + platform: args.platform, + arch: args.arch, + }); + const outputDir = path.resolve(args["out-dir"] || path.join(PACKAGE_DIR, "dist")); + const binaryPath = resolveBinaryPath( + meta, + args["target-triple"], + args.binary || process.env.LIME_CLI_BINARY_PATH, + ); + const archivePath = createArchive(meta, binaryPath, outputDir); + const result = { + archiveName: meta.archiveName, + archivePath, + binaryPath, + platform: meta.platform, + arch: meta.arch, + version: meta.version, + }; + + if (args.json) { + console.log(JSON.stringify(result)); + return; + } + + console.log(`Built ${meta.archiveName}`); + console.log(archivePath); +} + +try { + main(); +} catch (error) { + console.error(`[lime-cli release] ${error.message}`); + process.exit(1); +} diff --git a/packages/lime-cli-npm/scripts/install.js b/packages/lime-cli-npm/scripts/install.js new file mode 100644 index 000000000..d913157d6 --- /dev/null +++ b/packages/lime-cli-npm/scripts/install.js @@ -0,0 +1,79 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execSync } = require("child_process"); +const { + resolveReleaseAssetMeta, + isSupportedReleaseAssetMeta, + supportedReleaseAssetLabels, + NAME, +} = require("./release-meta"); + +const meta = resolveReleaseAssetMeta(); +const isWindows = meta.isWindows; +const archiveName = meta.archiveName; +const githubUrl = meta.githubUrl; + +const binDir = path.join(__dirname, "..", "bin"); +const dest = path.join(binDir, meta.binaryName); + +fs.mkdirSync(binDir, { recursive: true }); + +function download(url, destPath) { + const sslFlag = isWindows ? "--ssl-revoke-best-effort " : ""; + execSync( + `curl ${sslFlag}--fail --location --silent --show-error --connect-timeout 10 --max-time 120 --output "${destPath}" "${url}"`, + { + stdio: ["ignore", "ignore", "pipe"], + }, + ); +} + +function install() { + if (!isSupportedReleaseAssetMeta(meta)) { + console.warn( + `[${NAME}] 当前平台 ${meta.platform}/${meta.arch} 暂无预编译资产。` + + `已跳过下载;可通过 LIME_CLI_BINARY_PATH 指向本地二进制,` + + `或在源码仓库中通过 cargo run/cargo build 使用。`, + ); + return; + } + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lime-cli-")); + const archivePath = path.join(tmpDir, archiveName); + + try { + download(githubUrl, archivePath); + + if (isWindows) { + execSync( + `powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`, + { stdio: "ignore" }, + ); + } else { + execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { + stdio: "ignore", + }); + } + + const extractedBinary = path.join(tmpDir, meta.binaryName); + fs.copyFileSync(extractedBinary, dest); + fs.chmodSync(dest, 0o755); + console.log(`${NAME} v${meta.version} installed successfully`); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +try { + install(); +} catch (error) { + console.warn(`Failed to download prebuilt ${NAME}: ${error.message}`); + console.warn( + `\n已继续完成 npm 安装,但当前不会自动提供预编译二进制。` + + `你可以:\n` + + `1. 设置 LIME_CLI_BINARY_PATH 指向本地构建好的 lime 二进制\n` + + `2. 在 Lime 源码仓库中运行该命令,由 wrapper 自动回退到 cargo run\n` + + `3. 后续补发 GitHub Release asset 后重新安装`, + ); +} diff --git a/packages/lime-cli-npm/scripts/release-meta.js b/packages/lime-cli-npm/scripts/release-meta.js new file mode 100644 index 000000000..c18cc6c6b --- /dev/null +++ b/packages/lime-cli-npm/scripts/release-meta.js @@ -0,0 +1,130 @@ +const VERSION = require("../package.json").version; + +const REPO = "limecloud/lime"; +const NAME = "lime"; + +const PLATFORM_MAP = { + darwin: "darwin", + linux: "linux", + win32: "windows", +}; + +const ARCH_MAP = { + x64: "amd64", + arm64: "arm64", +}; + +const TARGET_TRIPLE_MAP = { + "aarch64-apple-darwin": { + platform: "darwin", + arch: "arm64", + }, + "x86_64-apple-darwin": { + platform: "darwin", + arch: "amd64", + }, + "x86_64-pc-windows-msvc": { + platform: "windows", + arch: "amd64", + }, + "aarch64-pc-windows-msvc": { + platform: "windows", + arch: "arm64", + }, + "x86_64-unknown-linux-gnu": { + platform: "linux", + arch: "amd64", + }, + "aarch64-unknown-linux-gnu": { + platform: "linux", + arch: "arm64", + }, +}; + +const SUPPORTED_RELEASE_ASSET_IDS = new Set([ + "darwin-arm64", + "darwin-amd64", + "windows-amd64", + "linux-amd64", +]); + +function resolvePlatformArch({ + targetTriple, + platform = process.platform, + arch = process.arch, +} = {}) { + if (targetTriple) { + const resolved = TARGET_TRIPLE_MAP[targetTriple]; + if (!resolved) { + throw new Error(`Unsupported target triple: ${targetTriple}`); + } + return resolved; + } + + const resolvedPlatform = PLATFORM_MAP[platform]; + const resolvedArch = ARCH_MAP[arch]; + + if (!resolvedPlatform || !resolvedArch) { + throw new Error(`Unsupported platform: ${platform}-${arch}`); + } + + return { + platform: resolvedPlatform, + arch: resolvedArch, + }; +} + +function resolveReleaseAssetMeta({ + version = VERSION, + targetTriple, + platform, + arch, + repo = REPO, + name = NAME, +} = {}) { + const resolved = resolvePlatformArch({ + targetTriple, + platform, + arch, + }); + const isWindows = resolved.platform === "windows"; + const archiveExt = isWindows ? ".zip" : ".tar.gz"; + const binaryName = name + (isWindows ? ".exe" : ""); + const archiveName = + `${name}-${version}-${resolved.platform}-${resolved.arch}${archiveExt}`; + + return { + version, + repo, + name, + platform: resolved.platform, + arch: resolved.arch, + isWindows, + archiveExt, + binaryName, + archiveName, + githubUrl: + `https://github.com/${repo}/releases/download/v${version}/${archiveName}`, + }; +} + +function isSupportedReleaseAssetMeta(meta) { + if (!meta?.platform || !meta?.arch) { + return false; + } + return SUPPORTED_RELEASE_ASSET_IDS.has(`${meta.platform}-${meta.arch}`); +} + +function supportedReleaseAssetLabels() { + return Array.from(SUPPORTED_RELEASE_ASSET_IDS).map((entry) => + entry.replace("-", "/"), + ); +} + +module.exports = { + NAME, + REPO, + isSupportedReleaseAssetMeta, + resolveReleaseAssetMeta, + supportedReleaseAssetLabels, +}; diff --git a/packages/lime-cli-npm/scripts/run.js b/packages/lime-cli-npm/scripts/run.js new file mode 100644 index 000000000..db0102700 --- /dev/null +++ b/packages/lime-cli-npm/scripts/run.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node +const fs = require("fs"); +const { execFileSync, spawnSync } = require("child_process"); +const path = require("path"); + +const ext = process.platform === "win32" ? ".exe" : ""; +const packageDir = path.join(__dirname, ".."); +const bundledBin = path.join(packageDir, "bin", "lime" + ext); + +function isExecutableFile(filePath) { + return Boolean(filePath) && fs.existsSync(filePath) && fs.statSync(filePath).isFile(); +} + +function resolveBinaryCandidates() { + const candidates = []; + const push = (candidate) => { + if (!candidate) { + return; + } + const absolute = path.resolve(candidate); + if (!candidates.includes(absolute)) { + candidates.push(absolute); + } + }; + + push(process.env.LIME_CLI_BINARY_PATH); + push(bundledBin); + push(path.join(process.cwd(), "src-tauri", "target", "release", "lime" + ext)); + push(path.join(process.cwd(), "target", "release", "lime" + ext)); + push(path.join(packageDir, "..", "..", "src-tauri", "target", "release", "lime" + ext)); + push(path.join(packageDir, "..", "..", "target", "release", "lime" + ext)); + + return candidates; +} + +function findBinary() { + return resolveBinaryCandidates().find((candidate) => isExecutableFile(candidate)) || null; +} + +function cargoExists() { + const result = spawnSync(process.platform === "win32" ? "where" : "which", ["cargo"], { + stdio: "ignore", + }); + return result.status === 0; +} + +function findManifestPath() { + const candidates = [ + path.join(process.cwd(), "src-tauri", "Cargo.toml"), + path.join(process.cwd(), "Cargo.toml"), + path.join(packageDir, "..", "..", "src-tauri", "Cargo.toml"), + ]; + return candidates.find((candidate) => fs.existsSync(candidate)) || null; +} + +function runCargoFallback(manifestPath) { + const args = ["run", "--quiet", "--manifest-path", manifestPath, "-p", "lime-cli", "--", ...process.argv.slice(2)]; + const result = spawnSync("cargo", args, { + stdio: "inherit", + cwd: path.dirname(manifestPath), + }); + process.exit(result.status || 1); +} + +const resolvedBinary = findBinary(); + +try { + if (resolvedBinary) { + execFileSync(resolvedBinary, process.argv.slice(2), { stdio: "inherit" }); + process.exit(0); + } + + const manifestPath = findManifestPath(); + if (manifestPath && cargoExists()) { + runCargoFallback(manifestPath); + } + + console.error( + [ + "未找到可执行的 lime 二进制。", + "可选解决方案:", + "1. 设置环境变量 LIME_CLI_BINARY_PATH 指向本地构建好的二进制", + "2. 在 Lime 源码仓库内运行命令,让 wrapper 自动使用 cargo run", + "3. 等待后续 GitHub Release 预编译资产可用后重新安装", + ].join("\n"), + ); + process.exit(1); +} catch (error) { + process.exit(error.status || 1); +} diff --git a/scripts/verify-gui-smoke.mjs b/scripts/verify-gui-smoke.mjs index 17a107e1e..e3bd77ca6 100644 --- a/scripts/verify-gui-smoke.mjs +++ b/scripts/verify-gui-smoke.mjs @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; const DEFAULTS = { appUrl: "http://127.0.0.1:1420/", healthUrl: "http://127.0.0.1:3030/health", - timeoutMs: 180_000, + timeoutMs: 600_000, intervalMs: 1_000, reuseRunning: false, sampleProjectName: "Lime Smoke Workspace", @@ -35,12 +35,12 @@ Lime GUI 冒烟入口 用法: npm run verify:gui-smoke npm run verify:gui-smoke -- --reuse-running - npm run verify:gui-smoke -- --timeout-ms 180000 + npm run verify:gui-smoke -- --timeout-ms 600000 选项: --app-url 前端地址,默认 http://127.0.0.1:1420/ --health-url DevBridge 健康检查地址,默认 http://127.0.0.1:3030/health - --timeout-ms 等待 headless / bridge / smoke 的超时,默认 180000 + --timeout-ms 等待 headless / bridge / smoke 的超时,默认 600000 --interval-ms 轮询间隔,默认 1000 --sample-project-name workspace 路径校验使用的示例项目名 --reuse-running 复用已启动的 headless Tauri,不主动拉起 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ae8f4afeb..6ef715ee1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -378,7 +378,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster-core" -version = "0.25.0" +version = "0.26.0" dependencies = [ "ahash", "anyhow", @@ -470,7 +470,7 @@ dependencies = [ [[package]] name = "aster-models" -version = "0.25.0" +version = "0.26.0" dependencies = [ "serde", "serde_json", @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.1.0" +version = "1.2.0" dependencies = [ "anyhow", "arboard", @@ -5137,6 +5137,7 @@ dependencies = [ "lime-gateway", "lime-infra", "lime-mcp", + "lime-media-runtime", "lime-memory", "lime-processor", "lime-providers", @@ -5205,7 +5206,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.1.0" +version = "1.2.0" dependencies = [ "anyhow", "aster-core", @@ -5234,7 +5235,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.1.0" +version = "1.2.0" dependencies = [ "chrono", "futures", @@ -5249,9 +5250,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "lime-cli" +version = "1.2.0" +dependencies = [ + "clap", + "lime-media-runtime", + "serde_json", + "tempfile", +] + [[package]] name = "lime-config" -version = "1.1.0" +version = "1.2.0" dependencies = [ "async-trait", "lime-core", @@ -5267,7 +5278,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.1.0" +version = "1.2.0" dependencies = [ "aster-models", "async-trait", @@ -5307,7 +5318,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "1.1.0" +version = "1.2.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5342,7 +5353,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.1.0" +version = "1.2.0" dependencies = [ "aes", "axum 0.7.9", @@ -5372,7 +5383,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.1.0" +version = "1.2.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5392,7 +5403,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.1.0" +version = "1.2.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5406,6 +5417,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "lime-media-runtime" +version = "1.2.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "uuid", +] + [[package]] name = "lime-memory" version = "0.1.0" @@ -5424,7 +5447,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.1.0" +version = "1.2.0" dependencies = [ "async-trait", "lime-core", @@ -5443,7 +5466,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.1.0" +version = "1.2.0" dependencies = [ "anyhow", "async-stream", @@ -5498,7 +5521,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.1.0" +version = "1.2.0" dependencies = [ "aster-core", "async-stream", @@ -5543,7 +5566,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.1.0" +version = "1.2.0" dependencies = [ "axum 0.7.9", "futures", @@ -5558,7 +5581,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.1.0" +version = "1.2.0" dependencies = [ "anyhow", "aster-core", @@ -5600,7 +5623,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.1.0" +version = "1.2.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5618,7 +5641,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "1.1.0" +version = "1.2.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5645,7 +5668,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.1.0" +version = "1.2.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 355bbac1a..2a89c9bab 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "1.1.0" +version = "1.2.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -30,6 +30,7 @@ lime-memory = { path = "crates/memory" } lime-embedding = { path = "crates/embedding" } lime-gateway = { path = "crates/gateway" } lime-browser-runtime = { path = "crates/browser-runtime" } +lime-media-runtime = { path = "crates/media-runtime" } voice-core = { path = "crates/voice-core" } # 序列化 @@ -44,6 +45,7 @@ tokio-util = "0.7" futures = "0.3" async-stream = "0.3" async-trait = "0.1" +clap = { version = "4.5", features = ["derive"] } # 错误处理 anyhow = "1" @@ -127,8 +129,8 @@ enigo = "0.3" # 如需联调本地 aster-rust,请运行: # npm run setup:local-aster -- /path/to/aster-rust # 脚本会在仓库根 .cargo/config.toml 写入本地 patch 覆盖;该文件已被 .gitignore 忽略。 -aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.25.0" } -aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.25.0" } +aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.26.0" } +aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.26.0" } # MCP (Model Context Protocol) rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] } @@ -192,7 +194,7 @@ version = "2.4" [package] name = "lime" -version = "1.1.0" +version = "1.2.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" @@ -227,6 +229,7 @@ lime-scheduler.workspace = true lime-memory.workspace = true lime-embedding.workspace = true lime-browser-runtime.workspace = true +lime-media-runtime.workspace = true voice-core.workspace = true # Tauri @@ -353,5 +356,5 @@ tempfile.workspace = true default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] # 本地 Whisper 语音识别(编译很慢,CI 默认不启用) -local-whisper = ["voice-core/local-whisper"] +local-whisper = ["lime-services/local-whisper"] notification = [] # 预留特性:系统通知功能 diff --git a/src-tauri/crates/agent/src/aster_state_support.rs b/src-tauri/crates/agent/src/aster_state_support.rs index 49ac5bc09..2e0895995 100644 --- a/src-tauri/crates/agent/src/aster_state_support.rs +++ b/src-tauri/crates/agent/src/aster_state_support.rs @@ -190,7 +190,8 @@ Lime 是一个 AI 代理服务应用,帮助用户: - 先区分关键路径与 sidecar 任务:如果下一步立即依赖结果,优先主线程自己做;只有不会阻塞下一步的独立子任务才适合并发委派 - 多个子代理并发时,必须明确分工,避免让不同子代理修改同一片文件或重复劳动 - 子代理默认不应继续创建新的子代理,避免团队深度失控 -- 优先复用已有子代理上下文,通过 send_input 继续推进强相关任务,而不是反复创建新子代理 -- 只有当主线程确实被结果阻塞时,才调用 wait_agent;可以一次等待多个 id,且不要反复机械等待 +- 需要显式建立或清理 team 上下文时,优先使用 TeamCreate / TeamDelete;进入 team 后再通过 Agent / SendMessage / ListPeers 维持协作主路径 +- 优先复用已有子代理上下文,通过 SendMessage 继续推进强相关任务,而不是反复创建新子代理 +- 只有当主线程确实被结果阻塞时,才围绕已有 team workspace 状态等待结果,不要反复机械轮询 - 旧的 SubAgentTask 仅视为兼容入口,不应作为新的 team runtime 主路径 "#; diff --git a/src-tauri/crates/agent/src/prompt/mod.rs b/src-tauri/crates/agent/src/prompt/mod.rs index 78ab00582..b5fdd0c05 100644 --- a/src-tauri/crates/agent/src/prompt/mod.rs +++ b/src-tauri/crates/agent/src/prompt/mod.rs @@ -1,7 +1,7 @@ //! System Prompt 模块 //! //! 为 Aster Agent 提供 System Prompt 配置 -//! 参考 claude-code-open 的设计,提供模块化的提示词组件 +//! 提供模块化的提示词组件 //! //! ## 模块结构 //! - templates - 提示词模板定义 diff --git a/src-tauri/crates/agent/src/prompt/templates.rs b/src-tauri/crates/agent/src/prompt/templates.rs index b3bf57849..36eb23064 100644 --- a/src-tauri/crates/agent/src/prompt/templates.rs +++ b/src-tauri/crates/agent/src/prompt/templates.rs @@ -1,6 +1,6 @@ //! System Prompt 模板定义 //! -//! 模块化的提示词组件,参考 claude-code-open 设计 +//! 模块化的提示词组件 /// 核心身份描述 pub const CORE_IDENTITY: &str = r#"你是 Lime Agent,一个强大的 AI 编程助手。 @@ -24,18 +24,18 @@ pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略 你有以下工具可以使用: ### 文件操作工具 -- **read**: 读取文件内容(支持文本、图片、PDF、notebook) -- **write**: 创建或覆盖文件 -- **edit**: 智能编辑文件(推荐用于修改现有文件) +- **Read**: 读取文件内容(支持文本、图片、PDF、notebook) +- **Write**: 创建或覆盖文件 +- **Edit**: 智能编辑文件(推荐用于修改现有文件) ### 搜索工具 -- **glob**: 使用 glob 模式搜索文件路径 -- **grep**: 使用正则表达式搜索文件内容 +- **Glob**: 使用 glob 模式搜索文件路径 +- **Grep**: 使用正则表达式搜索文件内容 - **ToolSearch**: 搜索当前会话可用工具,尤其是 extension / MCP / 延迟加载工具 - **ListMcpResourcesTool / ReadMcpResourceTool**: 浏览和读取 MCP 资源 ### 系统工具 -- **bash**: 执行 shell 命令;需要后台运行时使用 `background=true` +- **Bash / PowerShell**: 执行 shell 命令;需要后台运行时使用 `background=true` - **TaskOutput** / **TaskStop**: 读取或终止后台任务 ### 任务管理工具 @@ -43,19 +43,19 @@ pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略 - **EnterPlanMode** / **ExitPlanMode**: 显式进入或结束规划阶段 ### 委派工具 -- **spawn_agent / send_input / wait_agent / resume_agent / close_agent**: 当前 team runtime 主路径 +- **Agent / TeamCreate / TeamDelete / SendMessage / ListPeers**: 当前 team runtime 主路径 - **SubAgentTask**: 兼容入口,仅用于历史 prompt/schema 仍输出旧格式时兜底 ### 人在环工具 -- **ask**: 向用户请求确认或补充信息 +- **AskUserQuestion**: 向用户请求确认或补充信息 ## 使用原则 -1. **优先使用专用工具**:文件操作使用 read/write/edit,不要用 bash 的 cat/echo +1. **优先使用专用工具**:文件操作使用 Read/Write/Edit,不要用 Bash 的 cat/echo 2. **并行调用**:如果多个工具调用之间没有依赖关系,应该并行调用 3. **先读后改**:修改文件前必须先读取文件内容 4. **最小权限**:只执行必要的操作,避免不必要的文件修改 -5. **独立子问题再委派**:只有当任务需要隔离上下文、并行探索或分离执行时,才使用 team runtime 工具;优先 `spawn_agent`,不要默认走 `SubAgentTask`"#; +5. **独立子问题再委派**:只有当任务需要隔离上下文、并行探索或分离执行时,才使用 team runtime 工具;优先 `Agent`,不要默认走 `SubAgentTask`"#; /// 代码编写指南 pub const CODING_GUIDELINES: &str = r#"# 代码编写指南 @@ -64,7 +64,7 @@ pub const CODING_GUIDELINES: &str = r#"# 代码编写指南 1. **先理解再修改**:在修改代码之前,先阅读相关文件理解现有模式和架构 2. **使用 Task* 规划**:对于复杂任务,先用 `TaskCreate / TaskList / TaskGet / TaskUpdate` 维护任务板 -3. **需要隔离上下文时委派**:对于可以独立完成的研究、规划或执行子问题,使用 `spawn_agent` 创建真实子代理;对强依赖既有上下文的延续任务,优先 `send_input` +3. **需要隔离上下文时委派**:对于可以独立完成的研究、规划或执行子问题,使用 `Agent` 创建真实子代理;对强依赖既有上下文的延续任务,优先 `SendMessage` 4. **安全第一**:避免引入安全漏洞(命令注入、XSS、SQL 注入等) 5. **避免过度工程**:只做必要的修改,保持解决方案简单 @@ -102,7 +102,7 @@ pub const TASK_MANAGEMENT: &str = r#"# 任务管理 不要批量完成多个任务后再标记,应该完成一个标记一个。 -如果某个子问题可以独立分析、规划或执行,并且不需要持续共享主对话上下文,可以使用 `spawn_agent` 委派出去;`SubAgentTask` 只保留给兼容旧 schema 的场景。"#; +如果某个子问题可以独立分析、规划或执行,并且不需要持续共享主对话上下文,可以使用 `Agent` 委派出去;`SubAgentTask` 只保留给兼容旧 schema 的场景。"#; /// Git 操作指南 pub const GIT_GUIDELINES: &str = r#"# Git 操作 diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index 420714f09..3413fd0d1 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -1746,7 +1746,7 @@ mod tests { ); summary.task_summary = Some("整理事实源".to_string()); summary.role_hint = Some("explorer".to_string()); - summary.origin_tool = Some("spawn_agent".to_string()); + summary.origin_tool = Some("Agent".to_string()); summary.created_from_turn_id = Some("turn-1".to_string()); apply_runtime_status_to_child_subagent_session( diff --git a/src-tauri/crates/agent/src/shell_security.rs b/src-tauri/crates/agent/src/shell_security.rs index 6680eea0f..a32bbb533 100644 --- a/src-tauri/crates/agent/src/shell_security.rs +++ b/src-tauri/crates/agent/src/shell_security.rs @@ -138,7 +138,13 @@ impl ShellSecurityChecker { impl DynamicPermissionCheck for ShellSecurityChecker { fn check_permissions(&self, tool_name: &str, input: &serde_json::Value) -> PermissionBehavior { // 只检查 bash/shell 类工具 - if tool_name != "bash" && tool_name != "shell" && tool_name != "execute_command" { + if tool_name != "bash" + && tool_name != "Bash" + && tool_name != "PowerShell" + && tool_name != "powershell" + && tool_name != "shell" + && tool_name != "execute_command" + { return PermissionBehavior::Allow; } @@ -216,7 +222,7 @@ mod tests { let checker = ShellSecurityChecker; let input = serde_json::json!({"command": "ls -la"}); assert_eq!( - checker.check_permissions("bash", &input), + checker.check_permissions("Bash", &input), PermissionBehavior::Allow ); } @@ -225,7 +231,7 @@ mod tests { fn test_dynamic_permission_check_dangerous() { let checker = ShellSecurityChecker; let input = serde_json::json!({"command": "rm -rf /"}); - match checker.check_permissions("bash", &input) { + match checker.check_permissions("Bash", &input) { PermissionBehavior::Deny { .. } => {} other => panic!("Expected Deny, got {:?}", other), } diff --git a/src-tauri/crates/agent/src/tool_permissions.rs b/src-tauri/crates/agent/src/tool_permissions.rs index a37ab4c8c..8c718aa4d 100644 --- a/src-tauri/crates/agent/src/tool_permissions.rs +++ b/src-tauri/crates/agent/src/tool_permissions.rs @@ -52,6 +52,21 @@ pub struct ToolPermissionChecker { dynamic_checker: Option>, } +fn normalize_permission_tool_name(tool_name: &str) -> String { + match tool_name.trim() { + "Read" | "read" | "read_file" => "read_file".to_string(), + "Write" | "write" | "write_file" | "create_file" => "write_file".to_string(), + "Edit" | "edit" | "edit_file" => "edit_file".to_string(), + "Glob" | "glob" => "glob".to_string(), + "Grep" | "grep" => "grep".to_string(), + "LSP" | "lsp" | "lsp_query" => "lsp_query".to_string(), + "Bash" | "bash" | "PowerShell" | "powershell" | "shell" | "execute_command" => { + "bash".to_string() + } + other => other.to_string(), + } +} + impl ToolPermissionChecker { pub fn new() -> Self { let mut checker = Self { @@ -69,12 +84,20 @@ impl ToolPermissionChecker { /// 注册工具的权限元数据 pub fn register_tool(&mut self, meta: ToolPermissionMeta) { - self.permissions.insert(meta.tool_name.clone(), meta); + let normalized_name = normalize_permission_tool_name(&meta.tool_name); + self.permissions.insert( + normalized_name.clone(), + ToolPermissionMeta { + tool_name: normalized_name, + ..meta + }, + ); } /// 检查工具是否需要用户确认 pub fn needs_confirmation(&self, tool_name: &str) -> bool { - match self.permissions.get(tool_name) { + let normalized_name = normalize_permission_tool_name(tool_name); + match self.permissions.get(&normalized_name) { Some(meta) => meta.requires_confirmation && meta.risk_level > self.auto_approve_level, // 未知工具默认需要确认 None => true, @@ -83,8 +106,9 @@ impl ToolPermissionChecker { /// 获取工具的风险等级 pub fn risk_level(&self, tool_name: &str) -> ToolRiskLevel { + let normalized_name = normalize_permission_tool_name(tool_name); self.permissions - .get(tool_name) + .get(&normalized_name) .map(|m| m.risk_level) // 未知工具默认为破坏性 .unwrap_or(ToolRiskLevel::Destructive) @@ -106,8 +130,9 @@ impl ToolPermissionChecker { tool_name: &str, input: Option<&serde_json::Value>, ) -> PermissionBehavior { + let normalized_name = normalize_permission_tool_name(tool_name); // 1. 会话级记忆 - if let Some(allowed) = self.has_session_decision(tool_name) { + if let Some(allowed) = self.has_session_decision(&normalized_name) { return if allowed { PermissionBehavior::Allow } else { @@ -119,14 +144,14 @@ impl ToolPermissionChecker { // 2. 动态检查(如 shell 安全) if let Some(input) = input { if let Some(checker) = &self.dynamic_checker { - let result = checker.check_permissions(tool_name, input); + let result = checker.check_permissions(&normalized_name, input); if result != PermissionBehavior::Allow { return result; } } } // 3. 静态分级 - if self.needs_confirmation(tool_name) { + if self.needs_confirmation(&normalized_name) { PermissionBehavior::Ask { message: format!("工具 {} 需要确认执行", tool_name), } @@ -137,25 +162,28 @@ impl ToolPermissionChecker { /// 记录用户的允许决策 pub fn record_allow(&mut self, tool_name: &str) { + let normalized_name = normalize_permission_tool_name(tool_name); let count = self .session_allowed - .entry(tool_name.to_string()) + .entry(normalized_name.clone()) .or_insert(0); *count += 1; - self.session_denied.remove(tool_name); + self.session_denied.remove(&normalized_name); } /// 记录用户的拒绝决策 pub fn record_deny(&mut self, tool_name: &str) { - self.session_denied.insert(tool_name.to_string()); - self.session_allowed.remove(tool_name); + let normalized_name = normalize_permission_tool_name(tool_name); + self.session_denied.insert(normalized_name.clone()); + self.session_allowed.remove(&normalized_name); } /// 检查是否有会话级记忆 pub fn has_session_decision(&self, tool_name: &str) -> Option { - if self.session_allowed.contains_key(tool_name) { + let normalized_name = normalize_permission_tool_name(tool_name); + if self.session_allowed.contains_key(&normalized_name) { Some(true) - } else if self.session_denied.contains(tool_name) { + } else if self.session_denied.contains(&normalized_name) { Some(false) } else { None @@ -251,6 +279,9 @@ mod tests { #[test] fn test_default_permissions_loaded() { let checker = ToolPermissionChecker::new(); + assert_eq!(checker.risk_level("Read"), ToolRiskLevel::ReadOnly); + assert_eq!(checker.risk_level("Edit"), ToolRiskLevel::Reversible); + assert_eq!(checker.risk_level("Bash"), ToolRiskLevel::Destructive); assert_eq!(checker.risk_level("read_file"), ToolRiskLevel::ReadOnly); assert_eq!(checker.risk_level("edit_file"), ToolRiskLevel::Reversible); assert_eq!( diff --git a/src-tauri/crates/core/src/config/mod.rs b/src-tauri/crates/core/src/config/mod.rs index 43a720035..9cbfd142f 100644 --- a/src-tauri/crates/core/src/config/mod.rs +++ b/src-tauri/crates/core/src/config/mod.rs @@ -22,29 +22,29 @@ pub use path_utils::{collapse_tilde, contains_tilde, expand_tilde}; pub use types::{ generate_secure_api_key, AmpConfig, AmpModelMapping, ApiKeyEntry, AsrCredentialEntry, AsrProviderType, AutomationExecutionMode, AutomationSettings, BaiduConfig, ChannelsConfig, - ChatAppearanceConfig, CloudflareTunnelConfig, Config, ConversationSettings, - CrashReportingConfig, CredentialEntry, CredentialPoolConfig, CustomProviderConfig, - DeliveryConfig, DiscordAccountConfig, DiscordActionsConfig, DiscordAgentComponentsConfig, - DiscordAutoPresenceConfig, DiscordBotConfig, DiscordChannelConfig, DiscordExecApprovalsConfig, - DiscordGuildConfig, DiscordIntentsConfig, DiscordThreadBindingsConfig, - DiscordUiComponentsConfig, DiscordUiConfig, DiscordVoiceAutoJoinConfig, DiscordVoiceConfig, - EndpointProvidersConfig, EnvironmentConfig, EnvironmentVariableOverride, ExperimentalFeatures, - FeishuAccountConfig, FeishuBotConfig, FeishuGroupConfig, GatewayConfig, GatewayTunnelConfig, - GeminiApiKeyEntry, HintRouteSettingsEntry, HintRouterSettings, ImageGenConfig, - InjectionRuleConfig, InjectionSettings, LoggingConfig, MemoryAutoConfig, MemoryConfig, - MemoryProfileConfig, MemoryResolveConfig, MemorySourcesConfig, ModelInfo, ModelsConfig, - MultiSearchConfig, MultiSearchEngineEntryConfig, NativeAgentConfig, NavigationConfig, - OpenAIAsrConfig, PairingSettings, ProviderConfig, ProviderModelsConfig, ProvidersConfig, - QuotaExceededConfig, RateLimitSettings, RemoteManagementConfig, ResponseCacheSettings, - RetrySettings, RoutingConfig, ScreenshotChatConfig, SearchEngine, ServerConfig, - ShellEnvironmentImportConfig, TaskSchedule, TelegramAccountConfig, TelegramBotConfig, - TelegramGroupConfig, TelegramTopicConfig, TlsConfig, ToolCallingConfig, - ToolExecutionOverrideConfig, ToolExecutionPolicyConfig, ToolExecutionRestrictionProfileConfig, - ToolExecutionSandboxProfileConfig, ToolExecutionWarningPolicyConfig, UpdateCheckConfig, - UserProfile, VertexApiKeyEntry, VertexModelAlias, VoiceConfig, VoiceInputConfig, - VoiceInstruction, VoiceOutputConfig, VoiceOutputMode, VoiceProcessorConfig, WebSearchConfig, - WebSearchProvider, WechatAccountConfig, WechatBotConfig, WechatGroupConfig, WhisperLocalConfig, - WhisperModelSize, WorkspacePreferencesConfig, WorkspaceSandboxConfig, XunfeiConfig, - DEFAULT_API_KEY, + ChatAppearanceConfig, CloudflareTunnelConfig, CompanionDefaultsConfig, Config, + ConversationSettings, CrashReportingConfig, CredentialEntry, CredentialPoolConfig, + CustomProviderConfig, DeliveryConfig, DiscordAccountConfig, DiscordActionsConfig, + DiscordAgentComponentsConfig, DiscordAutoPresenceConfig, DiscordBotConfig, + DiscordChannelConfig, DiscordExecApprovalsConfig, DiscordGuildConfig, DiscordIntentsConfig, + DiscordThreadBindingsConfig, DiscordUiComponentsConfig, DiscordUiConfig, + DiscordVoiceAutoJoinConfig, DiscordVoiceConfig, EndpointProvidersConfig, EnvironmentConfig, + EnvironmentVariableOverride, ExperimentalFeatures, FeishuAccountConfig, FeishuBotConfig, + FeishuGroupConfig, GatewayConfig, GatewayTunnelConfig, GeminiApiKeyEntry, + HintRouteSettingsEntry, HintRouterSettings, ImageGenConfig, InjectionRuleConfig, + InjectionSettings, LoggingConfig, MemoryAutoConfig, MemoryConfig, MemoryProfileConfig, + MemoryResolveConfig, MemorySourcesConfig, ModelInfo, ModelsConfig, MultiSearchConfig, + MultiSearchEngineEntryConfig, NativeAgentConfig, NavigationConfig, OpenAIAsrConfig, + PairingSettings, ProviderConfig, ProviderModelsConfig, ProvidersConfig, QuotaExceededConfig, + RateLimitSettings, RemoteManagementConfig, ResponseCacheSettings, RetrySettings, RoutingConfig, + ScreenshotChatConfig, SearchEngine, ServerConfig, ShellEnvironmentImportConfig, TaskSchedule, + TelegramAccountConfig, TelegramBotConfig, TelegramGroupConfig, TelegramTopicConfig, TlsConfig, + ToolCallingConfig, ToolExecutionOverrideConfig, ToolExecutionPolicyConfig, + ToolExecutionRestrictionProfileConfig, ToolExecutionSandboxProfileConfig, + ToolExecutionWarningPolicyConfig, UpdateCheckConfig, UserProfile, VertexApiKeyEntry, + VertexModelAlias, VoiceConfig, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig, + VoiceOutputMode, VoiceProcessorConfig, WebSearchConfig, WebSearchProvider, WechatAccountConfig, + WechatBotConfig, WechatGroupConfig, WhisperLocalConfig, WhisperModelSize, + WorkspacePreferencesConfig, WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY, }; pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService}; diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index 4f4005232..cb144cd13 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -643,6 +643,9 @@ pub struct WorkspacePreferencesConfig { /// 全局媒体生成默认设置 #[serde(default)] pub media_defaults: MediaGenerationDefaultsConfig, + /// 桌宠能力偏好设置 + #[serde(default)] + pub companion_defaults: CompanionDefaultsConfig, } fn current_workspace_preferences_schema_version() -> u8 { @@ -654,6 +657,7 @@ impl Default for WorkspacePreferencesConfig { Self { schema_version: current_workspace_preferences_schema_version(), media_defaults: MediaGenerationDefaultsConfig::default(), + companion_defaults: CompanionDefaultsConfig::default(), } } } @@ -694,6 +698,14 @@ pub struct MediaGenerationDefaultsConfig { pub voice: MediaGenerationPreferenceConfig, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct CompanionDefaultsConfig { + #[serde(default)] + pub general: MediaGenerationPreferenceConfig, + #[serde(default)] + pub tts: MediaGenerationPreferenceConfig, +} + // ============ 导航栏配置类型 ============ /// 导航栏模块配置 @@ -2898,6 +2910,59 @@ mod unit_tests { ); } + #[test] + fn test_workspace_preferences_supports_companion_defaults_roundtrip() { + let mut config = Config::default(); + config + .workspace_preferences + .companion_defaults + .general + .preferred_provider_id = Some("deepseek".to_string()); + config + .workspace_preferences + .companion_defaults + .general + .preferred_model_id = Some("deepseek-chat".to_string()); + config + .workspace_preferences + .companion_defaults + .tts + .preferred_provider_id = Some("openai-tts".to_string()); + + let value = + serde_json::to_value(&config).expect("config should serialize companion defaults"); + let parsed: Config = + serde_json::from_value(value).expect("config should deserialize companion defaults"); + + assert_eq!( + parsed + .workspace_preferences + .companion_defaults + .general + .preferred_provider_id + .as_deref(), + Some("deepseek") + ); + assert_eq!( + parsed + .workspace_preferences + .companion_defaults + .general + .preferred_model_id + .as_deref(), + Some("deepseek-chat") + ); + assert_eq!( + parsed + .workspace_preferences + .companion_defaults + .tts + .preferred_provider_id + .as_deref(), + Some("openai-tts") + ); + } + #[test] fn test_normalize_local_server_surface_forces_loopback_and_disables_remote_management() { let mut config = Config::default(); diff --git a/src-tauri/crates/core/src/database/dao/video_generation_task_dao.rs b/src-tauri/crates/core/src/database/dao/video_generation_task_dao.rs index bfd9e5736..1a4cf82d5 100644 --- a/src-tauri/crates/core/src/database/dao/video_generation_task_dao.rs +++ b/src-tauri/crates/core/src/database/dao/video_generation_task_dao.rs @@ -40,6 +40,12 @@ impl VideoGenerationTaskStatus { } } +impl std::fmt::Display for VideoGenerationTaskStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + /// 视频生成任务 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/crates/lime-cli/Cargo.toml b/src-tauri/crates/lime-cli/Cargo.toml new file mode 100644 index 000000000..a2ab7b704 --- /dev/null +++ b/src-tauri/crates/lime-cli/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "lime-cli" +version.workspace = true +edition.workspace = true +authors.workspace = true +repository.workspace = true + +[[bin]] +name = "lime" +path = "src/main.rs" + +[dependencies] +clap.workspace = true +serde_json.workspace = true +lime-media-runtime.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/src-tauri/crates/lime-cli/src/catalog.rs b/src-tauri/crates/lime-cli/src/catalog.rs new file mode 100644 index 000000000..78c311b51 --- /dev/null +++ b/src-tauri/crates/lime-cli/src/catalog.rs @@ -0,0 +1,195 @@ +use lime_media_runtime::TaskType; + +pub const CLI_ROOT_SKILL_PATH: &str = "tools/lime-cli/SKILL.md"; +pub const ROOT_AFTER_HELP: &str = "\ +AI Agent 技能: + lime skill list + lime skill show broadcast + +任务生命周期: + draft -> pending_submit -> queued -> running -> partial -> succeeded|failed|cancelled + +示例: + lime task create image --prompt \"未来城市插图\" --size \"1024x1024\" + lime task create broadcast --title \"播客摘要\" --content \"原文内容\" + lime task list --family image --status running + lime task status + lime task attempts + lime task list --status failed + lime task retry "; + +pub const TASK_AFTER_HELP: &str = "\ +常用命令: + lime task create image --prompt \"未来城市插图\" + lime task create url-parse --url \"https://example.com\" --summary \"摘要\" + lime task list --family image + lime task list --type broadcast + lime task status + lime task attempts + lime task retry "; + +pub const SKILL_AFTER_HELP: &str = "\ +使用建议: + 先运行 `lime skill list` 查看可用业务域 + 再运行 `lime skill show ` 获取推荐命令和文档入口"; + +#[derive(Debug, Clone, Copy)] +pub struct TaskCatalogEntry { + pub command_name: &'static str, + pub task_type: TaskType, + pub description: &'static str, + pub skill_name: &'static str, + pub docs_dir: &'static str, + pub example: &'static str, +} + +pub const TASK_ENTRIES: &[TaskCatalogEntry] = &[ + TaskCatalogEntry { + command_name: "image", + task_type: TaskType::ImageGenerate, + description: "根据提示词创建普通配图任务。", + skill_name: "image_generate", + docs_dir: "src-tauri/resources/default-skills/image_generate", + example: "lime task create image --prompt \"未来城市插图\" --size \"1024x1024\"", + }, + TaskCatalogEntry { + command_name: "cover", + task_type: TaskType::CoverGenerate, + description: "创建平台封面图任务,适合文章和视频封面。", + skill_name: "cover_generate", + docs_dir: "src-tauri/resources/default-skills/cover_generate", + example: "lime task create cover --prompt \"科技播客封面\" --platform xiaohongshu", + }, + TaskCatalogEntry { + command_name: "video", + task_type: TaskType::VideoGenerate, + description: "创建视频生成任务记录,不伪造已完成结果。", + skill_name: "video_generate", + docs_dir: "src-tauri/resources/default-skills/video_generate", + example: "lime task create video --prompt \"产品发布短视频\" --aspect-ratio 9:16", + }, + TaskCatalogEntry { + command_name: "broadcast", + task_type: TaskType::BroadcastGenerate, + description: "把文稿整理成可播报文本材料任务。", + skill_name: "broadcast_generate", + docs_dir: "tools/lime-cli/domains/broadcast", + example: "lime task create broadcast --title \"AI 周报\" --content \"原文内容\"", + }, + TaskCatalogEntry { + command_name: "url-parse", + task_type: TaskType::UrlParse, + description: "把外部链接解析为可阅读、可引用的文本任务。", + skill_name: "url_parse", + docs_dir: "tools/lime-cli/domains/url-parse", + example: "lime task create url-parse --url \"https://example.com\" --summary \"摘要\"", + }, + TaskCatalogEntry { + command_name: "typesetting", + task_type: TaskType::Typesetting, + description: "创建文稿排版优化任务。", + skill_name: "typesetting", + docs_dir: "tools/lime-cli/domains/typesetting", + example: "lime task create typesetting --target-platform xiaohongshu --content \"原文\"", + }, + TaskCatalogEntry { + command_name: "resource-search", + task_type: TaskType::ModalResourceSearch, + description: "创建图片、BGM、音效等素材检索任务。", + skill_name: "modal_resource_search", + docs_dir: "tools/lime-cli/domains/resource-search", + example: + "lime task create resource-search --resource-type image --query 城市夜景 --usage 封面", + }, +]; + +pub fn find_task_entry(value: &str) -> Option<&'static TaskCatalogEntry> { + let normalized = value.trim().to_ascii_lowercase(); + TASK_ENTRIES.iter().find(|entry| { + entry.command_name == normalized + || entry.task_type.as_str() == normalized + || entry.skill_name == normalized + }) +} + +#[derive(Debug, Clone, Copy)] +pub struct SkillCatalogEntry { + pub name: &'static str, + pub description: &'static str, + pub recommended_command: &'static str, + pub skill_path: &'static str, + pub references: &'static [&'static str], +} + +pub const SKILL_ENTRIES: &[SkillCatalogEntry] = &[ + SkillCatalogEntry { + name: "lime-cli", + description: "Lime CLI 平台技能,负责统一任务命令、状态、重试与队列语义。", + recommended_command: "lime --help", + skill_path: CLI_ROOT_SKILL_PATH, + references: &[ + "tools/lime-cli/references/overview.md", + "tools/lime-cli/references/command-model.md", + "tools/lime-cli/references/task-lifecycle.md", + ], + }, + SkillCatalogEntry { + name: "image_generate", + description: "普通配图任务技能。", + recommended_command: "lime task create image --prompt \"...\"", + skill_path: "src-tauri/resources/default-skills/image_generate/SKILL.md", + references: &[], + }, + SkillCatalogEntry { + name: "cover_generate", + description: "封面图任务技能。", + recommended_command: "lime task create cover --prompt \"...\" --platform xiaohongshu", + skill_path: "src-tauri/resources/default-skills/cover_generate/SKILL.md", + references: &[], + }, + SkillCatalogEntry { + name: "video_generate", + description: "视频任务编排技能。", + recommended_command: "lime task create video --prompt \"...\" --aspect-ratio 9:16", + skill_path: "src-tauri/resources/default-skills/video_generate/SKILL.md", + references: &[], + }, + SkillCatalogEntry { + name: "broadcast_generate", + description: "播客文本整理任务技能。", + recommended_command: "lime task create broadcast --title \"...\" --content \"...\"", + skill_path: "tools/lime-cli/domains/broadcast/SKILL.md", + references: &["tools/lime-cli/domains/broadcast/references/create.md"], + }, + SkillCatalogEntry { + name: "url_parse", + description: "链接解析任务技能。", + recommended_command: + "lime task create url-parse --url \"https://example.com\" --summary \"...\"", + skill_path: "tools/lime-cli/domains/url-parse/SKILL.md", + references: &["tools/lime-cli/domains/url-parse/references/create.md"], + }, + SkillCatalogEntry { + name: "typesetting", + description: "排版优化任务技能。", + recommended_command: + "lime task create typesetting --target-platform xiaohongshu --content \"...\"", + skill_path: "tools/lime-cli/domains/typesetting/SKILL.md", + references: &["tools/lime-cli/domains/typesetting/references/create.md"], + }, + SkillCatalogEntry { + name: "modal_resource_search", + description: "素材检索任务技能。", + recommended_command: + "lime task create resource-search --resource-type image --query 城市夜景 --usage 封面", + skill_path: "tools/lime-cli/domains/resource-search/SKILL.md", + references: &["tools/lime-cli/domains/resource-search/references/create.md"], + }, +]; + +pub fn find_skill_entry(value: &str) -> Option<&'static SkillCatalogEntry> { + let normalized = value.trim().to_ascii_lowercase(); + SKILL_ENTRIES + .iter() + .find(|entry| entry.name.eq_ignore_ascii_case(&normalized)) +} diff --git a/src-tauri/crates/lime-cli/src/main.rs b/src-tauri/crates/lime-cli/src/main.rs new file mode 100644 index 000000000..5862030f7 --- /dev/null +++ b/src-tauri/crates/lime-cli/src/main.rs @@ -0,0 +1,1019 @@ +mod catalog; + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use clap::{Args, Parser, Subcommand}; +use lime_media_runtime::{ + list_task_outputs, load_task_output, retry_task_artifact, update_task_status, + write_task_artifact, MediaRuntimeError, MediaTaskErrorOutput, TaskType, TaskWriteOptions, + DEFAULT_ARTIFACT_ROOT, +}; +use serde_json::{json, Value}; + +use crate::catalog::{ + find_skill_entry, find_task_entry, ROOT_AFTER_HELP, SKILL_AFTER_HELP, SKILL_ENTRIES, + TASK_AFTER_HELP, TASK_ENTRIES, +}; + +const ROOT_LONG_ABOUT: &str = "\ +Lime 官方任务 CLI。 + +默认输出结构化 JSON,优先服务 Agent 与技能调用。 +当前主线覆盖内容生成、链接解析、排版优化与素材检索等任务型业务。"; + +#[derive(Debug, Parser)] +#[command( + name = "lime", + version, + about = "Lime 官方任务 CLI", + long_about = ROOT_LONG_ABOUT, + after_help = ROOT_AFTER_HELP, + arg_required_else_help = true +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + Task(TaskCommand), + Skill(SkillCommand), + Doctor(DoctorArgs), + #[command(hide = true)] + Media(MediaCommand), +} + +#[derive(Debug, Args)] +#[command(after_help = TASK_AFTER_HELP, arg_required_else_help = true)] +struct TaskCommand { + #[command(subcommand)] + command: TaskSubcommand, +} + +#[derive(Debug, Subcommand)] +enum TaskSubcommand { + Create(TaskCreateCommand), + Status(TaskLookupArgs), + List(TaskListArgs), + Attempts(TaskLookupArgs), + Retry(TaskLookupArgs), + Cancel(TaskLookupArgs), + Result(TaskLookupArgs), +} + +#[derive(Debug, Args)] +#[command(arg_required_else_help = true)] +struct TaskCreateCommand { + #[command(subcommand)] + command: TaskCreateSubcommand, +} + +#[derive(Debug, Subcommand)] +enum TaskCreateSubcommand { + Image(ImageGenerateArgs), + Cover(CoverGenerateArgs), + Video(VideoGenerateArgs), + Broadcast(BroadcastGenerateArgs), + #[command(name = "url-parse")] + UrlParse(UrlParseArgs), + Typesetting(TypesettingArgs), + #[command(name = "resource-search")] + ResourceSearch(ResourceSearchArgs), +} + +#[derive(Debug, Args)] +#[command(after_help = SKILL_AFTER_HELP, arg_required_else_help = true)] +struct SkillCommand { + #[command(subcommand)] + command: SkillSubcommand, +} + +#[derive(Debug, Subcommand)] +enum SkillSubcommand { + List, + Show(SkillShowArgs), +} + +#[derive(Debug, Args)] +struct SkillShowArgs { + name: String, +} + +#[derive(Debug, Args)] +struct DoctorArgs { + #[arg(long)] + workspace: Option, + #[arg(long = "artifact-dir")] + artifact_dir: Option, + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args, Clone)] +struct SharedTaskWriteArgs { + #[arg(long)] + workspace: Option, + #[arg(long)] + output: Option, + #[arg(long = "artifact-dir")] + artifact_dir: Option, + #[arg(long = "idempotency-key")] + idempotency_key: Option, + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args, Clone)] +struct SharedTaskReadArgs { + #[arg(long)] + workspace: Option, + #[arg(long = "artifact-dir")] + artifact_dir: Option, + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args)] +struct TaskLookupArgs { + task_ref: String, + #[command(flatten)] + shared: SharedTaskReadArgs, +} + +#[derive(Debug, Args)] +struct TaskListArgs { + #[command(flatten)] + shared: SharedTaskReadArgs, + #[arg(long)] + status: Option, + #[arg(long = "family")] + task_family: Option, + #[arg(long = "type")] + task_type: Option, + #[arg(long)] + limit: Option, +} + +#[derive(Debug, Args, Clone)] +struct ImageGenerateArgs { + #[arg(long)] + prompt: String, + #[arg(long)] + title: Option, + #[arg(long)] + model: Option, + #[arg(long)] + style: Option, + #[arg(long)] + size: Option, + #[arg(long)] + count: Option, + #[arg(long)] + usage: Option, + #[command(flatten)] + output: SharedTaskWriteArgs, +} + +#[derive(Debug, Args, Clone)] +struct CoverGenerateArgs { + #[arg(long)] + prompt: String, + #[arg(long)] + title: Option, + #[arg(long)] + model: Option, + #[arg(long)] + style: Option, + #[arg(long)] + platform: Option, + #[arg(long)] + size: Option, + #[arg(long = "image-url")] + image_url: Option, + #[arg(long = "reference-image-url")] + reference_image_url: Option, + #[arg(long)] + usage: Option, + #[command(flatten)] + output: SharedTaskWriteArgs, +} + +#[derive(Debug, Args, Clone)] +struct VideoGenerateArgs { + #[arg(long)] + prompt: String, + #[arg(long)] + title: Option, + #[arg(long = "project-id")] + project_id: Option, + #[arg(long = "provider-id")] + provider_id: Option, + #[arg(long)] + model: Option, + #[arg(long = "aspect-ratio")] + aspect_ratio: Option, + #[arg(long)] + resolution: Option, + #[arg(long)] + duration: Option, + #[arg(long = "image-url")] + image_url: Option, + #[arg(long = "end-image-url")] + end_image_url: Option, + #[arg(long)] + seed: Option, + #[arg(long = "generate-audio")] + generate_audio: Option, + #[arg(long = "camera-fixed")] + camera_fixed: Option, + #[command(flatten)] + output: SharedTaskWriteArgs, +} + +#[derive(Debug, Args, Clone)] +struct BroadcastGenerateArgs { + #[arg(long)] + content: String, + #[arg(long)] + title: Option, + #[arg(long)] + audience: Option, + #[arg(long)] + tone: Option, + #[arg(long = "duration-hint-minutes")] + duration_hint_minutes: Option, + #[command(flatten)] + output: SharedTaskWriteArgs, +} + +#[derive(Debug, Args, Clone)] +struct UrlParseArgs { + #[arg(long)] + url: String, + #[arg(long)] + title: Option, + #[arg(long)] + summary: String, + #[arg(long = "key-point")] + key_points: Vec, + #[arg(long = "extract-status", default_value = "ready")] + extract_status: String, + #[command(flatten)] + output: SharedTaskWriteArgs, +} + +#[derive(Debug, Args, Clone)] +struct TypesettingArgs { + #[arg(long)] + content: String, + #[arg(long)] + title: Option, + #[arg(long = "target-platform")] + target_platform: String, + #[arg(long = "rule")] + rules: Vec, + #[command(flatten)] + output: SharedTaskWriteArgs, +} + +#[derive(Debug, Args, Clone)] +struct ResourceSearchArgs { + #[arg(long = "resource-type")] + resource_type: String, + #[arg(long)] + query: String, + #[arg(long)] + title: Option, + #[arg(long)] + usage: String, + #[arg(long, default_value_t = 6)] + count: u32, + #[arg(long = "constraint")] + constraints: Vec, + #[command(flatten)] + output: SharedTaskWriteArgs, +} + +#[derive(Debug, Args)] +struct MediaCommand { + #[command(subcommand)] + command: MediaSubcommand, +} + +#[derive(Debug, Subcommand)] +enum MediaSubcommand { + Image(ImageCommand), + Cover(CoverCommand), + Video(VideoCommand), +} + +#[derive(Debug, Args)] +struct ImageCommand { + #[command(subcommand)] + command: ImageSubcommand, +} + +#[derive(Debug, Args)] +struct CoverCommand { + #[command(subcommand)] + command: CoverSubcommand, +} + +#[derive(Debug, Args)] +struct VideoCommand { + #[command(subcommand)] + command: VideoSubcommand, +} + +#[derive(Debug, Subcommand)] +enum ImageSubcommand { + Generate(ImageGenerateArgs), +} + +#[derive(Debug, Subcommand)] +enum CoverSubcommand { + Generate(CoverGenerateArgs), +} + +#[derive(Debug, Subcommand)] +enum VideoSubcommand { + Generate(VideoGenerateArgs), +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + match run(cli) { + Ok(output) => { + print_json(&output, false); + ExitCode::SUCCESS + } + Err(error) => { + let payload = MediaTaskErrorOutput::from_error(&error); + let serialized = + serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()); + eprintln!("{serialized}"); + ExitCode::from(error.exit_code().clamp(1, 255) as u8) + } + } +} + +fn print_json(value: &Value, stderr: bool) { + let serialized = serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_string()); + if stderr { + eprintln!("{serialized}"); + } else { + println!("{serialized}"); + } +} + +fn run(cli: Cli) -> Result { + match cli.command { + Command::Task(task) => run_task_command(task), + Command::Skill(skill) => run_skill_command(skill), + Command::Doctor(doctor) => run_doctor_command(doctor), + Command::Media(media) => run_media_command(media), + } +} + +fn run_task_command(command: TaskCommand) -> Result { + match command.command { + TaskSubcommand::Create(create) => run_task_create_command(create), + TaskSubcommand::Status(args) => { + let workspace_root = resolve_workspace_root(args.shared.workspace)?; + let output = load_task_output( + &workspace_root, + &args.task_ref, + args.shared.artifact_dir.as_deref(), + )?; + Ok(json!(output)) + } + TaskSubcommand::Attempts(args) => { + let workspace_root = resolve_workspace_root(args.shared.workspace)?; + let output = load_task_output( + &workspace_root, + &args.task_ref, + args.shared.artifact_dir.as_deref(), + )?; + Ok(json!({ + "success": true, + "task_id": output.task_id, + "task_type": output.task_type, + "task_family": output.task_family, + "status": output.status, + "normalized_status": output.normalized_status, + "current_attempt_id": output.current_attempt_id, + "attempt_count": output.attempt_count, + "attempts": output.record.attempts, + "path": output.path, + "absolute_path": output.absolute_path, + })) + } + TaskSubcommand::List(args) => { + let workspace_root = resolve_workspace_root(args.shared.workspace)?; + let task_type_filter = match args.task_type.as_deref() { + Some(raw) => Some(raw.parse::().map_err(|_| { + MediaRuntimeError::InvalidParams(format!("未知任务类型: {raw}")) + })?), + None => None, + }; + let task_family_filter = args + .task_family + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let tasks = list_task_outputs( + &workspace_root, + args.shared.artifact_dir.as_deref(), + args.status.as_deref(), + task_family_filter, + task_type_filter, + args.limit, + )?; + Ok(json!({ + "success": true, + "workspace_root": workspace_root.to_string_lossy().to_string(), + "artifact_root": resolve_artifact_root(&workspace_root, args.shared.artifact_dir.as_deref()).to_string_lossy().to_string(), + "filters": { + "status": args.status, + "task_family": task_family_filter, + "task_type": task_type_filter.map(|value| value.as_str().to_string()), + "limit": args.limit, + }, + "total": tasks.len(), + "tasks": tasks, + })) + } + TaskSubcommand::Retry(args) => { + let workspace_root = resolve_workspace_root(args.shared.workspace)?; + let output = retry_task_artifact( + &workspace_root, + &args.task_ref, + args.shared.artifact_dir.as_deref(), + )?; + Ok(json!(output)) + } + TaskSubcommand::Cancel(args) => { + let workspace_root = resolve_workspace_root(args.shared.workspace)?; + let output = update_task_status( + &workspace_root, + &args.task_ref, + args.shared.artifact_dir.as_deref(), + "cancelled", + )?; + Ok(json!(output)) + } + TaskSubcommand::Result(args) => { + let workspace_root = resolve_workspace_root(args.shared.workspace)?; + let output = load_task_output( + &workspace_root, + &args.task_ref, + args.shared.artifact_dir.as_deref(), + )?; + let has_result = output.record.result.is_some(); + let result = output.record.result.clone(); + let record = output.record.clone(); + Ok(json!({ + "success": true, + "task_id": output.task_id, + "task_type": output.task_type, + "task_family": output.task_family, + "status": output.status, + "normalized_status": output.normalized_status, + "current_attempt_id": output.current_attempt_id, + "attempt_count": output.attempt_count, + "last_error": output.last_error, + "progress": output.progress, + "ui_hints": output.ui_hints, + "path": output.path, + "absolute_path": output.absolute_path, + "artifact_path": output.artifact_path, + "absolute_artifact_path": output.absolute_artifact_path, + "has_result": has_result, + "result": result, + "record": record, + })) + } + } +} + +fn run_task_create_command(command: TaskCreateCommand) -> Result { + match command.command { + TaskCreateSubcommand::Image(args) => create_image_task(args), + TaskCreateSubcommand::Cover(args) => create_cover_task(args), + TaskCreateSubcommand::Video(args) => create_video_task(args), + TaskCreateSubcommand::Broadcast(args) => create_broadcast_task(args), + TaskCreateSubcommand::UrlParse(args) => create_url_parse_task(args), + TaskCreateSubcommand::Typesetting(args) => create_typesetting_task(args), + TaskCreateSubcommand::ResourceSearch(args) => create_resource_search_task(args), + } +} + +fn run_media_command(command: MediaCommand) -> Result { + match command.command { + MediaSubcommand::Image(image) => match image.command { + ImageSubcommand::Generate(args) => create_image_task(args), + }, + MediaSubcommand::Cover(cover) => match cover.command { + CoverSubcommand::Generate(args) => create_cover_task(args), + }, + MediaSubcommand::Video(video) => match video.command { + VideoSubcommand::Generate(args) => create_video_task(args), + }, + } +} + +fn run_skill_command(command: SkillCommand) -> Result { + match command.command { + SkillSubcommand::List => Ok(json!({ + "success": true, + "total": SKILL_ENTRIES.len(), + "skills": SKILL_ENTRIES.iter().map(|entry| { + json!({ + "name": entry.name, + "description": entry.description, + "recommended_command": entry.recommended_command, + "skill_path": entry.skill_path, + "references": entry.references, + }) + }).collect::>(), + })), + SkillSubcommand::Show(args) => { + let entry = find_skill_entry(&args.name) + .or_else(|| { + find_task_entry(&args.name).and_then(|task| find_skill_entry(task.skill_name)) + }) + .ok_or_else(|| { + MediaRuntimeError::InvalidParams(format!("未知 skill: {}", args.name)) + })?; + Ok(json!({ + "success": true, + "skill": { + "name": entry.name, + "description": entry.description, + "recommended_command": entry.recommended_command, + "skill_path": entry.skill_path, + "references": entry.references, + } + })) + } + } +} + +fn run_doctor_command(args: DoctorArgs) -> Result { + let _ = args.json; + let workspace_root = resolve_workspace_root(args.workspace)?; + let artifact_root = resolve_artifact_root(&workspace_root, args.artifact_dir.as_deref()); + let docs_root = workspace_root.join("tools/lime-cli"); + let current_dir = std::env::current_dir() + .map_err(|error| MediaRuntimeError::Io(format!("读取当前工作目录失败: {error}")))?; + let current_exe = std::env::current_exe() + .map_err(|error| MediaRuntimeError::Io(format!("读取当前可执行路径失败: {error}")))?; + let task_count = list_task_outputs( + &workspace_root, + args.artifact_dir.as_deref(), + None, + None, + None, + None, + )? + .len(); + + Ok(json!({ + "success": true, + "cli_version": env!("CARGO_PKG_VERSION"), + "current_dir": current_dir.to_string_lossy().to_string(), + "current_exe": current_exe.to_string_lossy().to_string(), + "workspace_root": workspace_root.to_string_lossy().to_string(), + "artifact_root": artifact_root.to_string_lossy().to_string(), + "artifact_root_exists": artifact_root.exists(), + "docs_root": docs_root.to_string_lossy().to_string(), + "docs_root_exists": docs_root.exists(), + "known_task_count": task_count, + "supported_tasks": TASK_ENTRIES.iter().map(|entry| { + json!({ + "command_name": entry.command_name, + "task_type": entry.task_type.as_str(), + "task_family": entry.task_type.family(), + "description": entry.description, + "skill_name": entry.skill_name, + "docs_dir": entry.docs_dir, + "example": entry.example, + }) + }).collect::>(), + "supported_skills": SKILL_ENTRIES.iter().map(|entry| entry.name).collect::>(), + })) +} + +fn create_image_task(args: ImageGenerateArgs) -> Result { + let workspace_root = resolve_workspace_root(args.output.workspace.clone())?; + let output = write_task_artifact( + &workspace_root, + TaskType::ImageGenerate, + args.title, + json!({ + "prompt": args.prompt, + "model": args.model, + "style": args.style, + "size": args.size, + "count": args.count, + "usage": args.usage, + }), + task_write_options(&args.output), + )?; + Ok(json!(output)) +} + +fn create_cover_task(args: CoverGenerateArgs) -> Result { + let workspace_root = resolve_workspace_root(args.output.workspace.clone())?; + let output = write_task_artifact( + &workspace_root, + TaskType::CoverGenerate, + args.title, + json!({ + "prompt": args.prompt, + "model": args.model, + "style": args.style, + "platform": args.platform, + "size": args.size, + "imageUrl": args.image_url, + "referenceImageUrl": args.reference_image_url, + "usage": args.usage.or(Some("cover".to_string())), + }), + task_write_options(&args.output), + )?; + Ok(json!(output)) +} + +fn create_video_task(args: VideoGenerateArgs) -> Result { + let workspace_root = resolve_workspace_root(args.output.workspace.clone())?; + let output = write_task_artifact( + &workspace_root, + TaskType::VideoGenerate, + args.title, + json!({ + "prompt": args.prompt, + "projectId": args.project_id, + "providerId": args.provider_id, + "model": args.model, + "aspectRatio": args.aspect_ratio, + "resolution": args.resolution, + "duration": args.duration, + "imageUrl": args.image_url, + "endImageUrl": args.end_image_url, + "seed": args.seed, + "generateAudio": args.generate_audio, + "cameraFixed": args.camera_fixed, + }), + task_write_options(&args.output), + )?; + Ok(json!(output)) +} + +fn create_broadcast_task(args: BroadcastGenerateArgs) -> Result { + let workspace_root = resolve_workspace_root(args.output.workspace.clone())?; + if args.content.trim().is_empty() { + return Err(MediaRuntimeError::InvalidParams( + "content 不能为空字符串".to_string(), + )); + } + let output = write_task_artifact( + &workspace_root, + TaskType::BroadcastGenerate, + args.title, + json!({ + "content": args.content, + "audience": args.audience, + "tone": args.tone, + "durationHintMinutes": args.duration_hint_minutes, + }), + task_write_options(&args.output), + )?; + Ok(json!(output)) +} + +fn create_url_parse_task(args: UrlParseArgs) -> Result { + let workspace_root = resolve_workspace_root(args.output.workspace.clone())?; + if args.url.trim().is_empty() { + return Err(MediaRuntimeError::InvalidParams( + "url 不能为空字符串".to_string(), + )); + } + if args.summary.trim().is_empty() { + return Err(MediaRuntimeError::InvalidParams( + "summary 不能为空字符串".to_string(), + )); + } + let output = write_task_artifact( + &workspace_root, + TaskType::UrlParse, + args.title, + json!({ + "url": args.url, + "summary": args.summary, + "keyPoints": args.key_points, + "extractStatus": args.extract_status, + }), + task_write_options(&args.output), + )?; + Ok(json!(output)) +} + +fn create_typesetting_task(args: TypesettingArgs) -> Result { + let workspace_root = resolve_workspace_root(args.output.workspace.clone())?; + if args.content.trim().is_empty() { + return Err(MediaRuntimeError::InvalidParams( + "content 不能为空字符串".to_string(), + )); + } + if args.target_platform.trim().is_empty() { + return Err(MediaRuntimeError::InvalidParams( + "targetPlatform 不能为空字符串".to_string(), + )); + } + let output = write_task_artifact( + &workspace_root, + TaskType::Typesetting, + args.title, + json!({ + "targetPlatform": args.target_platform, + "rules": args.rules, + "content": args.content, + }), + task_write_options(&args.output), + )?; + Ok(json!(output)) +} + +fn create_resource_search_task(args: ResourceSearchArgs) -> Result { + let workspace_root = resolve_workspace_root(args.output.workspace.clone())?; + if args.resource_type.trim().is_empty() { + return Err(MediaRuntimeError::InvalidParams( + "resourceType 不能为空字符串".to_string(), + )); + } + if args.query.trim().is_empty() { + return Err(MediaRuntimeError::InvalidParams( + "query 不能为空字符串".to_string(), + )); + } + if args.usage.trim().is_empty() { + return Err(MediaRuntimeError::InvalidParams( + "usage 不能为空字符串".to_string(), + )); + } + let output = write_task_artifact( + &workspace_root, + TaskType::ModalResourceSearch, + args.title, + json!({ + "resourceType": args.resource_type, + "query": args.query, + "usage": args.usage, + "count": args.count, + "constraints": args.constraints, + }), + task_write_options(&args.output), + )?; + Ok(json!(output)) +} + +fn task_write_options(args: &SharedTaskWriteArgs) -> TaskWriteOptions<'_> { + let _ = args.json; + TaskWriteOptions { + status: None, + output_path: args.output.as_deref(), + artifact_dir: args.artifact_dir.as_deref(), + idempotency_key: args.idempotency_key.as_deref(), + } +} + +fn resolve_workspace_root( + explicit_workspace: Option, +) -> Result { + if let Some(workspace) = explicit_workspace { + return Ok(workspace); + } + + std::env::current_dir() + .map_err(|error| MediaRuntimeError::Io(format!("读取当前工作目录失败: {error}"))) +} + +fn resolve_artifact_root(workspace_root: &Path, artifact_dir: Option<&str>) -> PathBuf { + match artifact_dir { + Some(raw) if !raw.trim().is_empty() => workspace_root.join(raw), + _ => workspace_root.join(DEFAULT_ARTIFACT_ROOT), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_workspace_root_defaults_to_current_dir() { + let root = resolve_workspace_root(None).expect("resolve current dir"); + assert!(root.is_dir()); + } + + #[test] + fn run_broadcast_create_supports_idempotency_key() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + + let first = run(Cli { + command: Command::Task(TaskCommand { + command: TaskSubcommand::Create(TaskCreateCommand { + command: TaskCreateSubcommand::Broadcast(BroadcastGenerateArgs { + content: "原文".to_string(), + title: Some("播客".to_string()), + audience: Some("开发者".to_string()), + tone: Some("理性".to_string()), + duration_hint_minutes: Some(8), + output: SharedTaskWriteArgs { + workspace: Some(temp_dir.path().to_path_buf()), + output: None, + artifact_dir: None, + idempotency_key: Some("broadcast-1".to_string()), + json: true, + }, + }), + }), + }), + }) + .expect("run first"); + + let second = run(Cli { + command: Command::Task(TaskCommand { + command: TaskSubcommand::Create(TaskCreateCommand { + command: TaskCreateSubcommand::Broadcast(BroadcastGenerateArgs { + content: "原文".to_string(), + title: Some("播客".to_string()), + audience: Some("开发者".to_string()), + tone: Some("理性".to_string()), + duration_hint_minutes: Some(8), + output: SharedTaskWriteArgs { + workspace: Some(temp_dir.path().to_path_buf()), + output: None, + artifact_dir: None, + idempotency_key: Some("broadcast-1".to_string()), + json: true, + }, + }), + }), + }), + }) + .expect("run second"); + + assert_eq!(first["task_id"], second["task_id"]); + assert_eq!(second["reused_existing"], json!(true)); + } + + #[test] + fn skill_show_accepts_task_command_name() { + let output = run(Cli { + command: Command::Skill(SkillCommand { + command: SkillSubcommand::Show(SkillShowArgs { + name: "broadcast".to_string(), + }), + }), + }) + .expect("show skill"); + + assert_eq!(output["skill"]["name"], "broadcast_generate"); + } + + #[test] + fn task_list_returns_created_items() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let _ = create_image_task(ImageGenerateArgs { + prompt: "城市".to_string(), + title: None, + model: None, + style: None, + size: None, + count: None, + usage: None, + output: SharedTaskWriteArgs { + workspace: Some(temp_dir.path().to_path_buf()), + output: None, + artifact_dir: None, + idempotency_key: None, + json: true, + }, + }) + .expect("create image"); + + let output = run(Cli { + command: Command::Task(TaskCommand { + command: TaskSubcommand::List(TaskListArgs { + shared: SharedTaskReadArgs { + workspace: Some(temp_dir.path().to_path_buf()), + artifact_dir: None, + json: true, + }, + status: None, + task_family: None, + task_type: Some("image".to_string()), + limit: Some(10), + }), + }), + }) + .expect("list tasks"); + + assert_eq!(output["total"], 1); + assert_eq!(output["tasks"][0]["task_type"], "image_generate"); + } + + #[test] + fn task_list_supports_family_filter() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let _ = create_image_task(ImageGenerateArgs { + prompt: "城市".to_string(), + title: None, + model: None, + style: None, + size: None, + count: None, + usage: None, + output: SharedTaskWriteArgs { + workspace: Some(temp_dir.path().to_path_buf()), + output: None, + artifact_dir: None, + idempotency_key: None, + json: true, + }, + }) + .expect("create image"); + let _ = create_url_parse_task(UrlParseArgs { + url: "https://example.com".to_string(), + title: None, + summary: "摘要".to_string(), + key_points: Vec::new(), + extract_status: "ready".to_string(), + output: SharedTaskWriteArgs { + workspace: Some(temp_dir.path().to_path_buf()), + output: None, + artifact_dir: None, + idempotency_key: None, + json: true, + }, + }) + .expect("create url parse"); + + let output = run(Cli { + command: Command::Task(TaskCommand { + command: TaskSubcommand::List(TaskListArgs { + shared: SharedTaskReadArgs { + workspace: Some(temp_dir.path().to_path_buf()), + artifact_dir: None, + json: true, + }, + status: None, + task_family: Some("image".to_string()), + task_type: None, + limit: Some(10), + }), + }), + }) + .expect("list image family"); + + assert_eq!(output["total"], 1); + assert_eq!(output["tasks"][0]["task_family"], "image"); + } + + #[test] + fn task_attempts_returns_attempt_history() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let failed = write_task_artifact( + temp_dir.path(), + TaskType::ModalResourceSearch, + Some("素材检索".to_string()), + json!({ "query": "城市夜景" }), + TaskWriteOptions { + status: Some("failed".to_string()), + ..TaskWriteOptions::default() + }, + ) + .expect("write failed task"); + let _ = retry_task_artifact(temp_dir.path(), &failed.task_id, None).expect("retry task"); + + let output = run(Cli { + command: Command::Task(TaskCommand { + command: TaskSubcommand::Attempts(TaskLookupArgs { + task_ref: failed.task_id.clone(), + shared: SharedTaskReadArgs { + workspace: Some(temp_dir.path().to_path_buf()), + artifact_dir: None, + json: true, + }, + }), + }), + }) + .expect("get attempts"); + + assert_eq!(output["attempt_count"], 2); + assert_eq!(output["attempts"].as_array().map(Vec::len), Some(2)); + assert_eq!(output["task_family"], "resource"); + } +} diff --git a/src-tauri/crates/mcp/src/manager.rs b/src-tauri/crates/mcp/src/manager.rs index 11ebdea39..3004a546a 100644 --- a/src-tauri/crates/mcp/src/manager.rs +++ b/src-tauri/crates/mcp/src/manager.rs @@ -43,6 +43,33 @@ use crate::client::McpClientWrapper; use crate::types::*; const AUTO_DEFER_TOOL_COUNT_THRESHOLD: usize = 6; +const MCP_RUNTIME_TOOL_PREFIX: &str = "mcp__"; + +fn build_mcp_runtime_tool_name(server_name: &str, tool_name: &str) -> String { + format!("{MCP_RUNTIME_TOOL_PREFIX}{server_name}__{tool_name}") +} + +fn extract_mcp_runtime_inner_tool_name<'a>( + server_name: &str, + tool_name: &'a str, +) -> Option<&'a str> { + let prefix = build_mcp_runtime_tool_name(server_name, ""); + tool_name + .strip_prefix(prefix.as_str()) + .filter(|inner_name| !inner_name.is_empty()) +} + +fn parse_mcp_runtime_tool_name<'a>( + tool_name: &str, + server_names: impl Iterator, +) -> Option<(String, String)> { + server_names + .filter_map(|server_name| { + extract_mcp_runtime_inner_tool_name(server_name, tool_name) + .map(|inner_name| (server_name.clone(), inner_name.to_string())) + }) + .max_by_key(|(server_name, _)| server_name.len()) +} /// MCP 客户端管理器 /// @@ -755,9 +782,9 @@ impl McpClientManager { } drop(clients); - // 3. 解决名称冲突(添加服务器前缀) + // 3. 收口到 MCP current runtime 名称 let resolved_tools = - Self::apply_default_loading_policy(Self::resolve_tool_name_conflicts(all_tools)); + Self::apply_default_loading_policy(Self::apply_runtime_tool_names(all_tools)); // 4. 更新缓存 self.update_tool_cache(resolved_tools.clone()).await; @@ -858,12 +885,21 @@ impl McpClientManager { } fn score_tool_match(tool: &McpToolDefinition, query: &str) -> i32 { - let score = lime_core::tool_calling::score_tool_match( - &tool.name, - &tool.description, - tool.tags.as_deref().unwrap_or(&[]), - query, - ); + let tags = tool.tags.as_deref().unwrap_or(&[]); + let inner_tool_name = extract_mcp_runtime_inner_tool_name(&tool.server_name, &tool.name) + .unwrap_or(&tool.name); + let score = [ + lime_core::tool_calling::score_tool_match(&tool.name, &tool.description, tags, query), + lime_core::tool_calling::score_tool_match( + inner_tool_name, + &tool.description, + tags, + query, + ), + ] + .into_iter() + .max() + .unwrap_or(0); if tool.always_visible.unwrap_or(false) { return score + 5; @@ -897,9 +933,7 @@ impl McpClientManager { .collect() } - /// 解决工具名称冲突 - /// - /// 当多个服务器提供同名工具时,为冲突的工具名称添加服务器前缀。 + /// 将 MCP 工具名统一收口到当前 runtime 命名。 /// /// # Arguments /// @@ -907,33 +941,13 @@ impl McpClientManager { /// /// # Returns /// - /// 返回解决冲突后的工具列表。 - fn resolve_tool_name_conflicts(tools: Vec) -> Vec { - // 统计每个工具名称出现的次数 - let mut name_counts: HashMap = HashMap::new(); - for tool in &tools { - *name_counts.entry(tool.name.clone()).or_insert(0) += 1; - } - - // 找出有冲突的名称 - let conflicting_names: HashSet = name_counts - .into_iter() - .filter(|(_, count)| *count > 1) - .map(|(name, _)| name) - .collect(); - - // 为冲突的工具添加服务器前缀 + /// 返回统一后的工具列表,名称始终为 `mcp____`。 + fn apply_runtime_tool_names(tools: Vec) -> Vec { tools .into_iter() .map(|mut tool| { - if conflicting_names.contains(&tool.name) { - debug!( - original_name = %tool.name, - server_name = %tool.server_name, - "工具名称冲突,添加服务器前缀" - ); - tool.name = format!("{}_{}", tool.server_name, tool.name); - } + let original_name = tool.name.clone(); + tool.name = build_mcp_runtime_tool_name(&tool.server_name, &original_name); tool }) .collect() @@ -943,7 +957,7 @@ impl McpClientManager { /// /// # Arguments /// - /// * `tool_name` - 工具名称(可能包含服务器前缀) + /// * `tool_name` - 工具名称,格式为 `mcp____` /// * `arguments` - 工具参数 /// /// # Returns @@ -1068,7 +1082,7 @@ impl McpClientManager { /// /// # Arguments /// - /// * `tool_name` - 工具名称(可能包含服务器前缀,格式为 "server_toolname") + /// * `tool_name` - 工具名称,格式为 `mcp____` /// /// # Returns /// @@ -1076,34 +1090,16 @@ impl McpClientManager { /// /// # 解析逻辑 /// - /// 1. 如果工具名包含下划线,尝试解析为 "server_toolname" 格式 - /// 2. 检查解析出的服务器是否存在 - /// 3. 如果服务器存在,使用解析结果 - /// 4. 如果服务器不存在,在所有服务器中查找该工具 + /// 1. 按 `mcp____` 解析运行时工具名 + /// 2. 使用最长 server 名匹配,避免 server 名中包含 `__` 时误切割 + /// 3. 解析失败则视为未知 current 工具名 async fn resolve_tool_target(&self, tool_name: &str) -> Result<(String, String), McpError> { let clients = self.clients.read().await; - // 尝试解析带前缀的工具名(格式:server_toolname) - if let Some(underscore_pos) = tool_name.find('_') { - let potential_server = &tool_name[..underscore_pos]; - let potential_tool = &tool_name[underscore_pos + 1..]; - - // 检查是否存在该服务器 - if clients.contains_key(potential_server) && !potential_tool.is_empty() { - return Ok((potential_server.to_string(), potential_tool.to_string())); - } - } - - // 没有前缀或前缀不匹配,在所有服务器中查找该工具 - for (server_name, wrapper) in clients.iter() { - if let Some(service) = wrapper.running_service() { - // 尝试获取工具列表并查找 - if let Ok(tools) = service.list_all_tools().await { - if tools.iter().any(|t| t.name.as_ref() == tool_name) { - return Ok((server_name.clone(), tool_name.to_string())); - } - } - } + if let Some((server_name, actual_tool_name)) = + parse_mcp_runtime_tool_name(tool_name, clients.keys()) + { + return Ok((server_name, actual_tool_name)); } // 工具未找到 @@ -2080,63 +2076,57 @@ mod tests { // ======================================================================== #[test] - fn test_resolve_tool_name_conflicts_no_conflict() { - // 没有冲突的情况 + fn test_apply_runtime_tool_names_prefixes_all_tools() { let tools = vec![ create_test_tool("tool1", "Tool 1", "server1"), create_test_tool("tool2", "Tool 2", "server2"), ]; - let resolved = McpClientManager::resolve_tool_name_conflicts(tools); + let resolved = McpClientManager::apply_runtime_tool_names(tools); - // 名称应该保持不变 assert_eq!(resolved.len(), 2); - assert!(resolved.iter().any(|t| t.name == "tool1")); - assert!(resolved.iter().any(|t| t.name == "tool2")); + assert!(resolved.iter().any(|t| t.name == "mcp__server1__tool1")); + assert!(resolved.iter().any(|t| t.name == "mcp__server2__tool2")); } #[test] - fn test_resolve_tool_name_conflicts_with_conflict() { - // 有冲突的情况:两个服务器都提供 "read_file" 工具 + fn test_apply_runtime_tool_names_keeps_distinct_servers_for_same_tool() { let tools = vec![ create_test_tool("read_file", "Read file from server1", "server1"), create_test_tool("read_file", "Read file from server2", "server2"), create_test_tool("unique_tool", "Unique tool", "server1"), ]; - let resolved = McpClientManager::resolve_tool_name_conflicts(tools); + let resolved = McpClientManager::apply_runtime_tool_names(tools); - // 冲突的工具应该添加服务器前缀 assert_eq!(resolved.len(), 3); - assert!(resolved.iter().any(|t| t.name == "server1_read_file")); - assert!(resolved.iter().any(|t| t.name == "server2_read_file")); - // 唯一的工具名称应该保持不变 - assert!(resolved.iter().any(|t| t.name == "unique_tool")); + assert!(resolved.iter().any(|t| t.name == "mcp__server1__read_file")); + assert!(resolved.iter().any(|t| t.name == "mcp__server2__read_file")); + assert!(resolved + .iter() + .any(|t| t.name == "mcp__server1__unique_tool")); } #[test] - fn test_resolve_tool_name_conflicts_multiple_conflicts() { - // 多个冲突的情况 + fn test_apply_runtime_tool_names_supports_multiple_same_name_tools() { let tools = vec![ create_test_tool("tool_a", "Tool A from server1", "server1"), create_test_tool("tool_a", "Tool A from server2", "server2"), create_test_tool("tool_a", "Tool A from server3", "server3"), ]; - let resolved = McpClientManager::resolve_tool_name_conflicts(tools); + let resolved = McpClientManager::apply_runtime_tool_names(tools); - // 所有冲突的工具都应该添加服务器前缀 assert_eq!(resolved.len(), 3); - assert!(resolved.iter().any(|t| t.name == "server1_tool_a")); - assert!(resolved.iter().any(|t| t.name == "server2_tool_a")); - assert!(resolved.iter().any(|t| t.name == "server3_tool_a")); + assert!(resolved.iter().any(|t| t.name == "mcp__server1__tool_a")); + assert!(resolved.iter().any(|t| t.name == "mcp__server2__tool_a")); + assert!(resolved.iter().any(|t| t.name == "mcp__server3__tool_a")); } #[test] - fn test_resolve_tool_name_conflicts_empty_list() { - // 空列表的情况 + fn test_apply_runtime_tool_names_empty_list() { let tools: Vec = vec![]; - let resolved = McpClientManager::resolve_tool_name_conflicts(tools); + let resolved = McpClientManager::apply_runtime_tool_names(tools); assert!(resolved.is_empty()); } @@ -2166,7 +2156,7 @@ mod tests { } #[test] - fn test_resolve_tool_name_conflicts_preserves_metadata_fields() { + fn test_apply_runtime_tool_names_preserves_metadata_fields() { let mut tool_a = create_test_tool("search", "Search docs", "server1"); tool_a.deferred_loading = Some(true); tool_a.always_visible = Some(true); @@ -2181,14 +2171,14 @@ mod tests { tool_b.tags = Some(vec!["issues".to_string()]); tool_b.input_examples = Some(vec![serde_json::json!({ "query": "bug" })]); - let resolved = McpClientManager::resolve_tool_name_conflicts(vec![tool_a, tool_b]); + let resolved = McpClientManager::apply_runtime_tool_names(vec![tool_a, tool_b]); let server1 = resolved .iter() - .find(|tool| tool.name == "server1_search") + .find(|tool| tool.name == "mcp__server1__search") .expect("server1 tool should be renamed"); let server2 = resolved .iter() - .find(|tool| tool.name == "server2_search") + .find(|tool| tool.name == "mcp__server2__search") .expect("server2 tool should be renamed"); assert_eq!(server1.deferred_loading, Some(true)); @@ -2304,7 +2294,7 @@ mod tests { // 预先设置缓存 let cached_tools = vec![create_test_tool( - "cached_tool", + "mcp__cached_server__cached_tool", "Cached tool", "cached_server", )]; @@ -2313,7 +2303,7 @@ mod tests { // 调用 list_tools 应该返回缓存的工具 let result = manager.list_tools().await.unwrap(); assert_eq!(result.len(), 1); - assert_eq!(result[0].name, "cached_tool"); + assert_eq!(result[0].name, "mcp__cached_server__cached_tool"); } #[tokio::test] @@ -2321,9 +2311,9 @@ mod tests { let manager = McpClientManager::new(None); manager .update_tool_cache(vec![ - create_test_tool("always_tool", "always", "s1"), + create_test_tool("mcp__s1__always_tool", "always", "s1"), McpToolDefinition { - name: "hidden_tool".to_string(), + name: "mcp__s1__hidden_tool".to_string(), description: "hidden".to_string(), input_schema: serde_json::json!({}), server_name: "s1".to_string(), @@ -2334,7 +2324,7 @@ mod tests { tags: None, }, McpToolDefinition { - name: "visible_deferred".to_string(), + name: "mcp__s1__visible_deferred".to_string(), description: "visible deferred".to_string(), input_schema: serde_json::json!({}), server_name: "s1".to_string(), @@ -2351,15 +2341,23 @@ mod tests { .list_tools_for_context(Some("assistant"), false) .await .unwrap(); - assert!(assistant_tools.iter().any(|t| t.name == "always_tool")); - assert!(assistant_tools.iter().any(|t| t.name == "visible_deferred")); - assert!(!assistant_tools.iter().any(|t| t.name == "hidden_tool")); + assert!(assistant_tools + .iter() + .any(|t| t.name == "mcp__s1__always_tool")); + assert!(assistant_tools + .iter() + .any(|t| t.name == "mcp__s1__visible_deferred")); + assert!(!assistant_tools + .iter() + .any(|t| t.name == "mcp__s1__hidden_tool")); let code_exec_tools = manager .list_tools_for_context(Some("code_execution"), true) .await .unwrap(); - assert!(code_exec_tools.iter().any(|t| t.name == "hidden_tool")); + assert!(code_exec_tools + .iter() + .any(|t| t.name == "mcp__s1__hidden_tool")); } #[tokio::test] @@ -2368,7 +2366,7 @@ mod tests { manager .update_tool_cache(vec![ McpToolDefinition { - name: "weather".to_string(), + name: "mcp__s1__weather".to_string(), description: "Get weather".to_string(), input_schema: serde_json::json!({}), server_name: "s1".to_string(), @@ -2379,7 +2377,7 @@ mod tests { tags: Some(vec!["forecast".to_string()]), }, McpToolDefinition { - name: "get_weather".to_string(), + name: "mcp__s1__get_weather".to_string(), description: "weather by city".to_string(), input_schema: serde_json::json!({}), server_name: "s1".to_string(), @@ -2397,7 +2395,7 @@ mod tests { .await .unwrap(); assert_eq!(tools.len(), 2); - assert_eq!(tools[0].name, "weather"); + assert_eq!(tools[0].name, "mcp__s1__weather"); } #[tokio::test] @@ -2406,7 +2404,7 @@ mod tests { manager .update_tool_cache(vec![ McpToolDefinition { - name: "alpha".to_string(), + name: "mcp__s1__alpha".to_string(), description: "alpha".to_string(), input_schema: serde_json::json!({}), server_name: "s1".to_string(), @@ -2417,7 +2415,7 @@ mod tests { tags: None, }, McpToolDefinition { - name: "zeta".to_string(), + name: "mcp__s1__zeta".to_string(), description: "zeta".to_string(), input_schema: serde_json::json!({}), server_name: "s1".to_string(), @@ -2428,7 +2426,7 @@ mod tests { tags: None, }, McpToolDefinition { - name: "beta".to_string(), + name: "mcp__s1__beta".to_string(), description: "beta".to_string(), input_schema: serde_json::json!({}), server_name: "s1".to_string(), @@ -2452,7 +2450,7 @@ mod tests { .iter() .map(|tool| tool.name.as_str()) .collect::>(), - vec!["beta", "zeta"] + vec!["mcp__s1__beta", "mcp__s1__zeta"] ); } @@ -2461,7 +2459,7 @@ mod tests { let manager = McpClientManager::new(None); manager .update_tool_cache(vec![McpToolDefinition { - name: "restricted".to_string(), + name: "mcp__s1__restricted".to_string(), description: "Restricted tool".to_string(), input_schema: serde_json::json!({}), server_name: "s1".to_string(), @@ -2474,7 +2472,11 @@ mod tests { .await; let result = manager - .call_tool_with_caller("restricted", serde_json::json!({}), Some("assistant")) + .call_tool_with_caller( + "mcp__s1__restricted", + serde_json::json!({}), + Some("assistant"), + ) .await; assert!(result.is_err()); match result { @@ -2504,14 +2506,14 @@ mod tests { // 调用不存在的工具 let result = manager - .call_tool("nonexistent_tool", serde_json::json!({})) + .call_tool("mcp__missing__nonexistent_tool", serde_json::json!({})) .await; // 应该返回 ToolNotFound 错误 assert!(result.is_err()); match result { Err(McpError::ToolNotFound(name)) => { - assert_eq!(name, "nonexistent_tool"); + assert_eq!(name, "mcp__missing__nonexistent_tool"); } _ => panic!("Expected ToolNotFound error"), } @@ -2530,7 +2532,7 @@ mod tests { // 使用非对象参数调用工具 let result = manager - .call_tool("test-server_some_tool", serde_json::json!("invalid")) + .call_tool("mcp__test-server__some_tool", serde_json::json!("invalid")) .await; // 应该返回错误(参数必须是对象或 null) diff --git a/src-tauri/crates/media-runtime/Cargo.toml b/src-tauri/crates/media-runtime/Cargo.toml new file mode 100644 index 000000000..772fdc1fe --- /dev/null +++ b/src-tauri/crates/media-runtime/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "lime-media-runtime" +version.workspace = true +edition.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true +uuid.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/src-tauri/crates/media-runtime/src/lib.rs b/src-tauri/crates/media-runtime/src/lib.rs new file mode 100644 index 000000000..290673974 --- /dev/null +++ b/src-tauri/crates/media-runtime/src/lib.rs @@ -0,0 +1,1884 @@ +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::str::FromStr; + +use chrono::Utc; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use thiserror::Error; +use uuid::Uuid; + +pub const DEFAULT_ARTIFACT_ROOT: &str = ".lime/tasks"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskType { + ImageGenerate, + CoverGenerate, + VideoGenerate, + BroadcastGenerate, + UrlParse, + Typesetting, + ModalResourceSearch, +} + +impl TaskType { + pub fn as_str(self) -> &'static str { + match self { + Self::ImageGenerate => "image_generate", + Self::CoverGenerate => "cover_generate", + Self::VideoGenerate => "video_generate", + Self::BroadcastGenerate => "broadcast_generate", + Self::UrlParse => "url_parse", + Self::Typesetting => "typesetting", + Self::ModalResourceSearch => "modal_resource_search", + } + } + + pub fn command_name(self) -> &'static str { + match self { + Self::ImageGenerate => "image", + Self::CoverGenerate => "cover", + Self::VideoGenerate => "video", + Self::BroadcastGenerate => "broadcast", + Self::UrlParse => "url-parse", + Self::Typesetting => "typesetting", + Self::ModalResourceSearch => "resource-search", + } + } + + pub fn default_status(self) -> &'static str { + match self { + Self::VideoGenerate => "queued", + Self::ImageGenerate + | Self::CoverGenerate + | Self::BroadcastGenerate + | Self::UrlParse + | Self::Typesetting + | Self::ModalResourceSearch => "pending_submit", + } + } + + pub fn family(self) -> &'static str { + match self { + Self::ImageGenerate | Self::CoverGenerate => "image", + Self::VideoGenerate => "video", + Self::BroadcastGenerate | Self::UrlParse | Self::Typesetting => "document", + Self::ModalResourceSearch => "resource", + } + } + + pub fn all() -> &'static [Self] { + &[ + Self::ImageGenerate, + Self::CoverGenerate, + Self::VideoGenerate, + Self::BroadcastGenerate, + Self::UrlParse, + Self::Typesetting, + Self::ModalResourceSearch, + ] + } +} + +impl FromStr for TaskType { + type Err = (); + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "image" | "image_generate" => Ok(Self::ImageGenerate), + "cover" | "cover_generate" => Ok(Self::CoverGenerate), + "video" | "video_generate" => Ok(Self::VideoGenerate), + "broadcast" | "broadcast_generate" => Ok(Self::BroadcastGenerate), + "url-parse" | "url_parse" | "urlparse" => Ok(Self::UrlParse), + "typesetting" => Ok(Self::Typesetting), + "resource-search" | "resource_search" | "modal_resource_search" | "resource" => { + Ok(Self::ModalResourceSearch) + } + _ => Err(()), + } + } +} + +pub type MediaTaskType = TaskType; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct TaskErrorRecord { + pub code: String, + pub message: String, + #[serde(default)] + pub retryable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub stage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub occurred_at: Option, +} + +impl TaskErrorRecord { + fn from_legacy_message(message: String) -> Option { + let trimmed = message.trim(); + if trimmed.is_empty() { + return None; + } + + Some(Self { + code: "legacy_error".to_string(), + message: trimmed.to_string(), + retryable: false, + stage: None, + provider_code: None, + occurred_at: None, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct TaskRelationships { + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_task_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub root_task_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub depends_on_task_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub child_task_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_asset_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub derived_from_attempt_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub triggered_by_skill: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub triggered_by_message_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub slot_id: Option, +} + +impl TaskRelationships { + fn is_empty(&self) -> bool { + self.parent_task_id.is_none() + && self.root_task_id.is_none() + && self.depends_on_task_ids.is_empty() + && self.child_task_ids.is_empty() + && self.source_asset_ids.is_empty() + && self.derived_from_attempt_id.is_none() + && self.triggered_by_skill.is_none() + && self.triggered_by_message_id.is_none() + && self.slot_id.is_none() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct TaskPreviewSlot { + pub slot_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + pub status: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct TaskProgress { + #[serde(skip_serializing_if = "Option::is_none")] + pub phase: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub percent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub preview_slots: Vec, +} + +impl TaskProgress { + fn is_empty(&self) -> bool { + self.phase.is_none() + && self.percent.is_none() + && self.message.is_none() + && self.preview_slots.is_empty() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct TaskUiHints { + #[serde(skip_serializing_if = "Option::is_none")] + pub render_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub placeholder_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_surface: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub open_action: Option, +} + +impl TaskUiHints { + fn is_empty(&self) -> bool { + self.render_mode.is_none() + && self.placeholder_text.is_none() + && self.preferred_surface.is_none() + && self.open_action.is_none() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct TaskAttemptMetrics { + #[serde(skip_serializing_if = "Option::is_none")] + pub queue_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub run_ms: Option, +} + +impl TaskAttemptMetrics { + fn is_empty(&self) -> bool { + self.queue_ms.is_none() && self.run_ms.is_none() + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct TaskAttemptRecord { + pub attempt_id: String, + pub attempt_index: u32, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub queued_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub worker_id: Option, + #[serde(default, skip_serializing_if = "Value::is_null")] + pub input_snapshot: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub result_snapshot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metrics: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub logs_ref: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TaskArtifactRecord { + pub task_id: String, + pub task_type: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub task_family: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + pub payload: Value, + pub status: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub normalized_status: String, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub submitted_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cancelled_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, + #[serde(default, skip_serializing_if = "is_zero")] + pub retry_count: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_task_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde( + default, + deserialize_with = "deserialize_task_error_opt", + skip_serializing_if = "Option::is_none" + )] + pub last_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_attempt_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attempts: Vec, + #[serde(default, skip_serializing_if = "TaskRelationships::is_empty")] + pub relationships: TaskRelationships, + #[serde(default, skip_serializing_if = "TaskProgress::is_empty")] + pub progress: TaskProgress, + #[serde(default, skip_serializing_if = "TaskUiHints::is_empty")] + pub ui_hints: TaskUiHints, +} + +pub type MediaTaskArtifactRecord = TaskArtifactRecord; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TaskOutput { + pub success: bool, + pub task_id: String, + pub task_type: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub task_family: String, + pub status: String, + pub normalized_status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_attempt_id: Option, + #[serde(default, skip_serializing_if = "is_zero")] + pub attempt_count: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_error: Option, + #[serde(default, skip_serializing_if = "TaskProgress::is_empty")] + pub progress: TaskProgress, + #[serde(default, skip_serializing_if = "TaskUiHints::is_empty")] + pub ui_hints: TaskUiHints, + pub path: String, + pub absolute_path: String, + pub artifact_path: String, + pub absolute_artifact_path: String, + #[serde(default)] + pub reused_existing: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, + pub record: TaskArtifactRecord, +} + +impl TaskOutput { + pub fn artifact_paths(&self) -> Vec { + vec![self.path.clone()] + } +} + +pub type MediaTaskOutput = TaskOutput; + +fn is_zero(value: &u32) -> bool { + *value == 0 +} + +fn deserialize_task_error_opt<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let Some(value) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + + match value { + Value::Null => Ok(None), + Value::String(message) => Ok(TaskErrorRecord::from_legacy_message(message)), + other => serde_json::from_value(other) + .map(Some) + .map_err(serde::de::Error::custom), + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskErrorOutput { + pub success: bool, + pub error_code: String, + pub error_message: String, + pub retryable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub task_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, +} + +impl TaskErrorOutput { + pub fn from_error(error: &MediaRuntimeError) -> Self { + Self { + success: false, + error_code: error.code().to_string(), + error_message: error.to_string(), + retryable: error.retryable(), + hint: error.hint().map(ToOwned::to_owned), + task_id: error.task_id(), + idempotency_key: error.idempotency_key(), + } + } +} + +pub type MediaTaskErrorOutput = TaskErrorOutput; + +#[derive(Debug, Error)] +pub enum MediaRuntimeError { + #[error("{0}")] + InvalidParams(String), + #[error("{0}")] + Io(String), + #[error("未找到任务: {task_ref}")] + TaskNotFound { task_ref: String }, + #[error("{0}")] + Conflict(String), + #[error("{0}")] + InvalidState(String), + #[error("{0}")] + NotRetryable(String), +} + +impl MediaRuntimeError { + pub fn code(&self) -> &'static str { + match self { + Self::InvalidParams(_) => "invalid_params", + Self::Io(_) => "io_error", + Self::TaskNotFound { .. } => "task_not_found", + Self::Conflict(_) => "task_conflict", + Self::InvalidState(_) => "invalid_state", + Self::NotRetryable(_) => "not_retryable", + } + } + + pub fn exit_code(&self) -> i32 { + match self { + Self::InvalidParams(_) => 2, + Self::TaskNotFound { .. } => 3, + Self::Io(_) => 4, + Self::Conflict(_) => 5, + Self::InvalidState(_) => 6, + Self::NotRetryable(_) => 7, + } + } + + pub fn retryable(&self) -> bool { + matches!(self, Self::Io(_)) + } + + pub fn hint(&self) -> Option<&'static str> { + match self { + Self::InvalidParams(_) => Some("请检查命令参数、路径和 JSON 字段是否完整。"), + Self::Io(_) => Some("请检查工作目录、文件权限,或稍后重试。"), + Self::TaskNotFound { .. } => { + Some("可先运行 `lime task list` 或检查 `--artifact-dir`。") + } + Self::Conflict(_) => Some("请更换 `--output`,或使用稳定的 `--idempotency-key` 重试。"), + Self::InvalidState(_) => Some("可先运行 `lime task status ` 查看当前状态。"), + Self::NotRetryable(_) => Some("只有 failed 或 cancelled 的任务可以重试。"), + } + } + + pub fn task_id(&self) -> Option { + match self { + Self::TaskNotFound { task_ref } => Some(task_ref.clone()), + _ => None, + } + } + + pub fn idempotency_key(&self) -> Option { + None + } +} + +#[derive(Debug, Clone, Default)] +pub struct TaskWriteOptions<'a> { + pub status: Option, + pub output_path: Option<&'a str>, + pub artifact_dir: Option<&'a str>, + pub idempotency_key: Option<&'a str>, +} + +fn is_safe_relative_path(path: &Path) -> bool { + if path.is_absolute() { + return false; + } + + !path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) +} + +fn normalize_relative_path(raw: &str, field_name: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(MediaRuntimeError::InvalidParams(format!( + "{field_name} 不能为空字符串" + ))); + } + + let candidate = PathBuf::from(trimmed); + if !is_safe_relative_path(&candidate) { + return Err(MediaRuntimeError::InvalidParams(format!( + "{field_name} 必须是安全的相对路径,且不能包含 '..'" + ))); + } + + Ok(candidate) +} + +fn normalize_optional_text(value: Option) -> Option { + value.and_then(|raw| { + let trimmed = raw.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn normalize_idempotency_key(raw: Option<&str>) -> Result, MediaRuntimeError> { + let Some(raw) = raw else { + return Ok(None); + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(MediaRuntimeError::InvalidParams( + "idempotencyKey 不能为空字符串".to_string(), + )); + } + Ok(Some(trimmed.to_string())) +} + +fn resolve_artifact_root_relative_path( + artifact_dir: Option<&str>, +) -> Result { + match artifact_dir { + Some(raw) => normalize_relative_path(raw, "artifactDir"), + None => Ok(PathBuf::from(DEFAULT_ARTIFACT_ROOT)), + } +} + +fn resolve_output_relative_path( + task_type: TaskType, + output_path: Option<&str>, + artifact_dir: Option<&str>, +) -> Result { + if let Some(raw) = output_path { + return normalize_relative_path(raw, "output"); + } + + let artifact_root = resolve_artifact_root_relative_path(artifact_dir)?; + let timestamp = Utc::now().format("%Y%m%d-%H%M%S").to_string(); + let suffix = Uuid::new_v4().simple().to_string(); + Ok(artifact_root + .join(task_type.as_str()) + .join(format!("{timestamp}-{suffix}.json"))) +} + +fn task_family_for_type(task_type: &str) -> String { + task_type + .parse::() + .ok() + .map(|value| value.family().to_string()) + .unwrap_or_else(|| match task_type.trim().to_ascii_lowercase().as_str() { + value if value.contains("image") || value.contains("cover") => "image".to_string(), + value if value.contains("video") => "video".to_string(), + value if value.contains("resource") => "resource".to_string(), + "broadcast_generate" | "url_parse" | "typesetting" => "document".to_string(), + _ => "automation".to_string(), + }) +} + +fn payload_string(payload: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + payload + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn summarize_text(raw: &str, limit: usize) -> String { + let total = raw.chars().count(); + if total <= limit { + return raw.to_string(); + } + + let summary: String = raw.chars().take(limit).collect(); + format!("{summary}...") +} + +fn derive_task_summary(task_type: &str, title: Option<&str>, payload: &Value) -> Option { + title + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + let candidate = match task_type { + "image_generate" | "cover_generate" | "video_generate" => { + payload_string(payload, &["prompt", "usage"]) + } + "broadcast_generate" | "typesetting" => { + payload_string(payload, &["content", "targetPlatform"]) + } + "url_parse" => payload_string(payload, &["summary", "url"]), + "modal_resource_search" => payload_string(payload, &["query", "usage"]), + _ => payload_string(payload, &["prompt", "query", "content", "summary"]), + }?; + Some(summarize_text(&candidate, 48)) + }) +} + +fn derive_task_ui_hints(task_family: &str, summary: Option<&str>) -> TaskUiHints { + match task_family { + "image" => TaskUiHints { + render_mode: Some("media_placeholder_card".to_string()), + placeholder_text: Some(format!("[img:{}]", summary.unwrap_or("图片任务"))), + preferred_surface: Some("claw_chat".to_string()), + open_action: Some("open_image_workbench".to_string()), + }, + "video" => TaskUiHints { + render_mode: Some("media_placeholder_card".to_string()), + placeholder_text: Some(format!("[video:{}]", summary.unwrap_or("视频任务"))), + preferred_surface: Some("claw_chat".to_string()), + open_action: Some("open_video_workbench".to_string()), + }, + _ => TaskUiHints { + render_mode: Some("task_status_card".to_string()), + placeholder_text: None, + preferred_surface: Some("task_panel".to_string()), + open_action: Some("open_task_panel".to_string()), + }, + } +} + +fn derive_task_progress(status: &str, last_error: Option<&TaskErrorRecord>) -> TaskProgress { + let normalized_status = normalize_status(status); + match normalized_status.as_str() { + "pending" => TaskProgress { + phase: Some("pending_submit".to_string()), + percent: Some(0), + message: Some("任务已创建,等待进入队列".to_string()), + preview_slots: Vec::new(), + }, + "queued" => TaskProgress { + phase: Some("queued".to_string()), + percent: Some(0), + message: Some("任务已进入队列".to_string()), + preview_slots: Vec::new(), + }, + "running" => TaskProgress { + phase: Some("running".to_string()), + percent: None, + message: Some("任务执行中".to_string()), + preview_slots: Vec::new(), + }, + "partial" => TaskProgress { + phase: Some("partial".to_string()), + percent: None, + message: Some("任务已返回部分结果".to_string()), + preview_slots: Vec::new(), + }, + "succeeded" => TaskProgress { + phase: Some("succeeded".to_string()), + percent: Some(100), + message: Some("任务已完成".to_string()), + preview_slots: Vec::new(), + }, + "failed" => TaskProgress { + phase: Some("failed".to_string()), + percent: None, + message: Some( + last_error + .map(|value| value.message.clone()) + .unwrap_or_else(|| "任务执行失败".to_string()), + ), + preview_slots: Vec::new(), + }, + "cancelled" => TaskProgress { + phase: Some("cancelled".to_string()), + percent: None, + message: Some("任务已取消".to_string()), + preview_slots: Vec::new(), + }, + _ => TaskProgress::default(), + } +} + +fn infer_attempt_provider(payload: &Value) -> Option { + payload_string(payload, &["provider", "providerId"]) +} + +fn infer_attempt_model(payload: &Value) -> Option { + payload_string(payload, &["model"]) +} + +fn new_attempt_id() -> String { + format!("attempt_{}", Uuid::new_v4().simple()) +} + +fn legacy_attempt_id(task_id: &str, attempt_index: u32) -> String { + format!("{task_id}:attempt:{attempt_index}") +} + +fn fallback_attempt_index(record: &TaskArtifactRecord) -> u32 { + record.retry_count.saturating_add(1).max(1) +} + +struct AttemptRecordInput { + attempt_id: String, + attempt_index: u32, + status: String, + queued_at: Option, + started_at: Option, + completed_at: Option, + result_snapshot: Option, + error: Option, +} + +fn build_attempt_record( + task_id: &str, + payload: &Value, + input: AttemptRecordInput, +) -> TaskAttemptRecord { + let AttemptRecordInput { + attempt_id, + attempt_index, + status, + queued_at, + started_at, + completed_at, + result_snapshot, + error, + } = input; + + TaskAttemptRecord { + attempt_id, + attempt_index, + status, + queued_at, + started_at, + completed_at, + provider: infer_attempt_provider(payload), + model: infer_attempt_model(payload), + worker_id: None, + input_snapshot: payload.clone(), + result_snapshot, + error, + metrics: None, + logs_ref: Some(format!( + ".lime/task-logs/{task_id}/attempt_{attempt_index}.jsonl" + )), + } +} + +fn current_attempt_index(record: &TaskArtifactRecord) -> Option { + if record.attempts.is_empty() { + return None; + } + + record + .current_attempt_id + .as_deref() + .and_then(|attempt_id| { + record + .attempts + .iter() + .position(|attempt| attempt.attempt_id == attempt_id) + }) + .or_else(|| record.attempts.len().checked_sub(1)) +} + +fn canonicalize_task_record(mut record: TaskArtifactRecord) -> TaskArtifactRecord { + record.title = normalize_optional_text(record.title); + record.summary = normalize_optional_text(record.summary); + record.task_family = if record.task_family.trim().is_empty() { + task_family_for_type(&record.task_type) + } else { + record.task_family.trim().to_string() + }; + record.normalized_status = normalize_status(&record.status); + if record.summary.is_none() { + record.summary = + derive_task_summary(&record.task_type, record.title.as_deref(), &record.payload); + } + + if record.attempts.is_empty() { + let attempt_index = fallback_attempt_index(&record); + let anchor_time = record + .updated_at + .clone() + .unwrap_or_else(|| record.created_at.clone()); + let normalized_status = record.normalized_status.clone(); + let queued_at = matches!( + normalized_status.as_str(), + "queued" | "running" | "partial" | "succeeded" | "failed" | "cancelled" + ) + .then(|| { + record + .submitted_at + .clone() + .unwrap_or_else(|| anchor_time.clone()) + }); + let started_at = matches!( + normalized_status.as_str(), + "running" | "partial" | "succeeded" | "failed" | "cancelled" + ) + .then(|| { + record + .started_at + .clone() + .unwrap_or_else(|| anchor_time.clone()) + }); + let completed_at = matches!( + normalized_status.as_str(), + "partial" | "succeeded" | "failed" | "cancelled" + ) + .then(|| { + record + .completed_at + .clone() + .or_else(|| record.cancelled_at.clone()) + .unwrap_or_else(|| anchor_time.clone()) + }); + + record.attempts.push(build_attempt_record( + &record.task_id, + &record.payload, + AttemptRecordInput { + attempt_id: legacy_attempt_id(&record.task_id, attempt_index), + attempt_index, + status: record.status.clone(), + queued_at, + started_at, + completed_at, + result_snapshot: record.result.clone(), + error: record.last_error.clone(), + }, + )); + } + + for (index, attempt) in record.attempts.iter_mut().enumerate() { + if attempt.attempt_index == 0 { + attempt.attempt_index = index as u32 + 1; + } + if attempt.attempt_id.trim().is_empty() { + attempt.attempt_id = legacy_attempt_id(&record.task_id, attempt.attempt_index); + } + if attempt.status.trim().is_empty() { + attempt.status = record.status.clone(); + } else if let Ok(status) = normalize_mutation_status(&attempt.status) { + attempt.status = status; + } + if attempt.input_snapshot.is_null() { + attempt.input_snapshot = record.payload.clone(); + } + if attempt.provider.is_none() { + attempt.provider = infer_attempt_provider(&attempt.input_snapshot); + } + if attempt.model.is_none() { + attempt.model = infer_attempt_model(&attempt.input_snapshot); + } + if attempt.logs_ref.is_none() { + attempt.logs_ref = Some(format!( + ".lime/task-logs/{}/attempt_{}.jsonl", + record.task_id, attempt.attempt_index + )); + } + if attempt + .metrics + .as_ref() + .is_some_and(TaskAttemptMetrics::is_empty) + { + attempt.metrics = None; + } + } + + if let Some(index) = current_attempt_index(&record) { + let current_attempt = &mut record.attempts[index]; + if record.normalized_status == "failed" && current_attempt.error.is_none() { + current_attempt.error = record.last_error.clone(); + } + if matches!(record.normalized_status.as_str(), "partial" | "succeeded") + && current_attempt.result_snapshot.is_none() + { + current_attempt.result_snapshot = record.result.clone(); + } + record.current_attempt_id = Some(current_attempt.attempt_id.clone()); + } else { + record.current_attempt_id = None; + } + + record.retry_count = record.attempts.len().saturating_sub(1) as u32; + if record.progress.is_empty() { + record.progress = derive_task_progress(&record.status, record.last_error.as_ref()); + } + if record.ui_hints.is_empty() { + record.ui_hints = derive_task_ui_hints(&record.task_family, record.summary.as_deref()); + } + + record +} + +fn normalize_status(status: &str) -> String { + match status.trim().to_ascii_lowercase().as_str() { + "pending" | "pending_submit" => "pending".to_string(), + "queued" => "queued".to_string(), + "running" | "processing" | "in_progress" => "running".to_string(), + "partial" => "partial".to_string(), + "completed" | "success" | "succeeded" => "succeeded".to_string(), + "failed" | "error" => "failed".to_string(), + "cancelled" | "canceled" => "cancelled".to_string(), + other => other.to_string(), + } +} + +fn normalize_mutation_status(status: &str) -> Result { + let normalized = status.trim().to_ascii_lowercase(); + let resolved = match normalized.as_str() { + "pending" | "pending_submit" => "pending_submit", + "queued" => "queued", + "running" => "running", + "partial" => "partial", + "succeeded" | "completed" | "success" => "succeeded", + "failed" | "error" => "failed", + "cancelled" | "canceled" => "cancelled", + _ => { + return Err(MediaRuntimeError::InvalidParams(format!( + "不支持的任务状态: {status}" + ))); + } + }; + Ok(resolved.to_string()) +} + +fn record_sort_key(record: &TaskArtifactRecord) -> &str { + record + .updated_at + .as_deref() + .unwrap_or(record.created_at.as_str()) +} + +fn task_record_matches_filter( + record: &TaskArtifactRecord, + status_filter: Option<&str>, + task_family_filter: Option<&str>, + task_type_filter: Option, +) -> bool { + if let Some(task_type) = task_type_filter { + if record.task_type != task_type.as_str() { + return false; + } + } + + if let Some(task_family_filter) = task_family_filter { + if task_family_filter.trim().is_empty() { + return false; + } + if !record + .task_family + .trim() + .eq_ignore_ascii_case(task_family_filter.trim()) + { + return false; + } + } + + if let Some(status_filter) = status_filter { + let normalized_filter = normalize_status(status_filter); + let normalized_record = record.normalized_status.clone(); + if normalized_filter != normalized_record + && !status_filter + .trim() + .eq_ignore_ascii_case(record.status.trim()) + { + return false; + } + } + + true +} + +fn read_task_record(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|error| MediaRuntimeError::Io(format!("读取任务文件失败: {error}")))?; + serde_json::from_str::(&content) + .map(canonicalize_task_record) + .map_err(|error| MediaRuntimeError::Io(format!("解析任务文件失败: {error}"))) +} + +fn write_task_record(path: &Path, record: &TaskArtifactRecord) -> Result<(), MediaRuntimeError> { + let canonical_record = canonicalize_task_record(record.clone()); + let serialized = serde_json::to_string_pretty(&canonical_record) + .unwrap_or_else(|_| serde_json::json!(canonical_record).to_string()); + fs::write(path, serialized.as_bytes()) + .map_err(|error| MediaRuntimeError::Io(format!("写入任务文件失败: {error}"))) +} + +fn relative_path_from_workspace(workspace_root: &Path, task_path: &Path) -> String { + task_path + .strip_prefix(workspace_root) + .map(|value| value.to_string_lossy().to_string()) + .unwrap_or_else(|_| task_path.to_string_lossy().to_string()) +} + +fn build_task_output( + workspace_root: &Path, + task_path: &Path, + record: TaskArtifactRecord, + reused_existing: bool, +) -> TaskOutput { + let record = canonicalize_task_record(record); + let path = relative_path_from_workspace(workspace_root, task_path); + let absolute_path = task_path.to_string_lossy().to_string(); + TaskOutput { + success: true, + task_id: record.task_id.clone(), + task_type: record.task_type.clone(), + task_family: record.task_family.clone(), + status: record.status.clone(), + normalized_status: record.normalized_status.clone(), + current_attempt_id: record.current_attempt_id.clone(), + attempt_count: record.attempts.len() as u32, + last_error: record.last_error.clone(), + progress: record.progress.clone(), + ui_hints: record.ui_hints.clone(), + path: path.clone(), + absolute_path: absolute_path.clone(), + artifact_path: path, + absolute_artifact_path: absolute_path, + reused_existing, + idempotency_key: record.idempotency_key.clone(), + record, + } +} + +fn collect_task_files(root: &Path) -> Result, MediaRuntimeError> { + if !root.exists() { + return Ok(Vec::new()); + } + + let mut stack = vec![root.to_path_buf()]; + let mut files = Vec::new(); + + while let Some(dir) = stack.pop() { + let entries = fs::read_dir(&dir) + .map_err(|error| MediaRuntimeError::Io(format!("读取任务目录失败: {error}")))?; + for entry in entries { + let entry = entry + .map_err(|error| MediaRuntimeError::Io(format!("读取任务目录项失败: {error}")))?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + + if path.extension().and_then(|value| value.to_str()) == Some("json") { + files.push(path); + } + } + } + + Ok(files) +} + +fn find_task_record_by_idempotency_key( + workspace_root: &Path, + artifact_dir: Option<&str>, + task_type: TaskType, + idempotency_key: &str, +) -> Result, MediaRuntimeError> { + let artifact_root = workspace_root.join(resolve_artifact_root_relative_path(artifact_dir)?); + let mut best_match: Option<(PathBuf, TaskArtifactRecord)> = None; + + for file_path in collect_task_files(&artifact_root)? { + let Ok(record) = read_task_record(&file_path) else { + continue; + }; + if record.task_type != task_type.as_str() { + continue; + } + if record.idempotency_key.as_deref() != Some(idempotency_key) { + continue; + } + + let should_replace = match best_match.as_ref() { + Some((_, existing)) => record_sort_key(&record) > record_sort_key(existing), + None => true, + }; + if should_replace { + best_match = Some((file_path, record)); + } + } + + Ok(best_match) +} + +fn ensure_output_not_occupied( + workspace_root: &Path, + output_abs_path: &Path, + task_type: TaskType, + idempotency_key: Option<&str>, +) -> Result, MediaRuntimeError> { + if !output_abs_path.exists() { + return Ok(None); + } + + let record = read_task_record(output_abs_path)?; + if record.task_type == task_type.as_str() + && idempotency_key.is_some() + && record.idempotency_key.as_deref() == idempotency_key + { + return Ok(Some(build_task_output( + workspace_root, + output_abs_path, + record, + true, + ))); + } + + Err(MediaRuntimeError::Conflict(format!( + "输出路径已存在: {}", + output_abs_path.to_string_lossy() + ))) +} + +fn persist_task_record( + workspace_root: &Path, + output_rel_path: &Path, + record: TaskArtifactRecord, + reused_existing: bool, +) -> Result { + let output_abs_path = workspace_root.join(output_rel_path); + let parent = output_abs_path + .parent() + .ok_or_else(|| MediaRuntimeError::Io("无法解析任务文件父目录".to_string()))?; + fs::create_dir_all(parent) + .map_err(|error| MediaRuntimeError::Io(format!("创建任务目录失败: {error}")))?; + write_task_record(&output_abs_path, &record)?; + Ok(build_task_output( + workspace_root, + &output_abs_path, + record, + reused_existing, + )) +} + +fn apply_status_to_attempt(attempt: &mut TaskAttemptRecord, status: &str, occurred_at: &str) { + let normalized_status = normalize_status(status); + attempt.status = status.to_string(); + + match normalized_status.as_str() { + "pending" => { + attempt.queued_at = None; + attempt.started_at = None; + attempt.completed_at = None; + } + "queued" => { + if attempt.queued_at.is_none() { + attempt.queued_at = Some(occurred_at.to_string()); + } + attempt.started_at = None; + attempt.completed_at = None; + } + "running" => { + if attempt.queued_at.is_none() { + attempt.queued_at = Some(occurred_at.to_string()); + } + attempt.started_at = Some(occurred_at.to_string()); + attempt.completed_at = None; + } + "partial" => { + if attempt.queued_at.is_none() { + attempt.queued_at = Some(occurred_at.to_string()); + } + if attempt.started_at.is_none() { + attempt.started_at = Some(occurred_at.to_string()); + } + attempt.completed_at = None; + } + "succeeded" | "failed" | "cancelled" => { + if attempt.queued_at.is_none() { + attempt.queued_at = Some(occurred_at.to_string()); + } + if attempt.started_at.is_none() { + attempt.started_at = Some(occurred_at.to_string()); + } + attempt.completed_at = Some(occurred_at.to_string()); + } + _ => {} + } +} + +fn apply_status_to_record(record: &mut TaskArtifactRecord, status: &str, occurred_at: &str) { + let normalized_status = normalize_status(status); + record.status = status.to_string(); + record.normalized_status = normalized_status.clone(); + + match normalized_status.as_str() { + "pending" => { + record.submitted_at = None; + record.started_at = None; + record.completed_at = None; + record.cancelled_at = None; + } + "queued" => { + if record.submitted_at.is_none() { + record.submitted_at = Some(occurred_at.to_string()); + } + record.started_at = None; + record.completed_at = None; + record.cancelled_at = None; + } + "running" => { + if record.submitted_at.is_none() { + record.submitted_at = Some(occurred_at.to_string()); + } + record.started_at = Some(occurred_at.to_string()); + record.completed_at = None; + record.cancelled_at = None; + } + "partial" => { + if record.submitted_at.is_none() { + record.submitted_at = Some(occurred_at.to_string()); + } + if record.started_at.is_none() { + record.started_at = Some(occurred_at.to_string()); + } + record.completed_at = None; + record.cancelled_at = None; + } + "succeeded" | "failed" => { + if record.submitted_at.is_none() { + record.submitted_at = Some(occurred_at.to_string()); + } + if record.started_at.is_none() { + record.started_at = Some(occurred_at.to_string()); + } + record.completed_at = Some(occurred_at.to_string()); + record.cancelled_at = None; + } + "cancelled" => { + record.cancelled_at = Some(occurred_at.to_string()); + } + _ => {} + } + + record.progress = derive_task_progress(status, record.last_error.as_ref()); +} + +pub fn write_task_artifact( + workspace_root: &Path, + task_type: TaskType, + title: Option, + payload: Value, + options: TaskWriteOptions<'_>, +) -> Result { + let normalized_title = normalize_optional_text(title); + let normalized_idempotency_key = normalize_idempotency_key(options.idempotency_key)?; + let initial_status = match options.status.as_deref() { + Some(status) => normalize_mutation_status(status)?, + None => task_type.default_status().to_string(), + }; + + if let Some(idempotency_key) = normalized_idempotency_key.as_deref() { + if let Some((task_path, record)) = find_task_record_by_idempotency_key( + workspace_root, + options.artifact_dir, + task_type, + idempotency_key, + )? { + return Ok(build_task_output(workspace_root, &task_path, record, true)); + } + } + + let output_rel_path = + resolve_output_relative_path(task_type, options.output_path, options.artifact_dir)?; + let output_abs_path = workspace_root.join(&output_rel_path); + if let Some(existing) = ensure_output_not_occupied( + workspace_root, + &output_abs_path, + task_type, + normalized_idempotency_key.as_deref(), + )? { + return Ok(existing); + } + + let created_at = Utc::now().to_rfc3339(); + let summary = derive_task_summary(task_type.as_str(), normalized_title.as_deref(), &payload); + let task_family = task_type.family().to_string(); + let initial_attempt = build_attempt_record( + "", + &payload, + AttemptRecordInput { + attempt_id: new_attempt_id(), + attempt_index: 1, + status: initial_status.clone(), + queued_at: None, + started_at: None, + completed_at: None, + result_snapshot: None, + error: None, + }, + ); + let mut record = TaskArtifactRecord { + task_id: Uuid::new_v4().to_string(), + task_type: task_type.as_str().to_string(), + task_family, + title: normalized_title, + summary, + payload, + status: initial_status.clone(), + normalized_status: normalize_status(&initial_status), + created_at: created_at.clone(), + updated_at: None, + submitted_at: None, + started_at: None, + completed_at: None, + cancelled_at: None, + idempotency_key: normalized_idempotency_key, + retry_count: 0, + source_task_id: None, + result: None, + last_error: None, + current_attempt_id: Some(initial_attempt.attempt_id.clone()), + attempts: vec![TaskAttemptRecord { + input_snapshot: serde_json::json!({}), + ..initial_attempt + }], + relationships: TaskRelationships::default(), + progress: TaskProgress::default(), + ui_hints: TaskUiHints::default(), + }; + record.attempts[0].input_snapshot = record.payload.clone(); + record.attempts[0].logs_ref = Some(format!( + ".lime/task-logs/{}/attempt_1.jsonl", + record.task_id + )); + apply_status_to_attempt(&mut record.attempts[0], &initial_status, &created_at); + apply_status_to_record(&mut record, &initial_status, &created_at); + + persist_task_record(workspace_root, &output_rel_path, record, false) +} + +pub fn write_media_task_artifact( + workspace_root: &Path, + task_type: MediaTaskType, + title: Option, + payload: Value, + status: Option, + output_path: Option<&str>, + artifact_dir: Option<&str>, +) -> Result { + write_task_artifact( + workspace_root, + task_type, + title, + payload, + TaskWriteOptions { + status, + output_path, + artifact_dir, + idempotency_key: None, + }, + ) +} + +fn resolve_task_reference_path( + workspace_root: &Path, + task_ref: &str, + artifact_dir: Option<&str>, +) -> Result { + let trimmed = task_ref.trim(); + if trimmed.is_empty() { + return Err(MediaRuntimeError::InvalidParams( + "task_ref 不能为空字符串".to_string(), + )); + } + + let explicit_path = PathBuf::from(trimmed); + if explicit_path.is_absolute() && explicit_path.is_file() { + return Ok(explicit_path); + } + + let workspace_relative = workspace_root.join(trimmed); + if workspace_relative.is_file() { + return Ok(workspace_relative); + } + + let artifact_root = workspace_root.join(resolve_artifact_root_relative_path(artifact_dir)?); + for file_path in collect_task_files(&artifact_root)? { + let Ok(record) = read_task_record(&file_path) else { + continue; + }; + if record.task_id == trimmed { + return Ok(file_path); + } + } + + Err(MediaRuntimeError::TaskNotFound { + task_ref: trimmed.to_string(), + }) +} + +pub fn load_task_output( + workspace_root: &Path, + task_ref: &str, + artifact_dir: Option<&str>, +) -> Result { + let task_path = resolve_task_reference_path(workspace_root, task_ref, artifact_dir)?; + let record = read_task_record(&task_path)?; + Ok(build_task_output(workspace_root, &task_path, record, false)) +} + +pub fn list_task_outputs( + workspace_root: &Path, + artifact_dir: Option<&str>, + status_filter: Option<&str>, + task_family_filter: Option<&str>, + task_type_filter: Option, + limit: Option, +) -> Result, MediaRuntimeError> { + let artifact_root = workspace_root.join(resolve_artifact_root_relative_path(artifact_dir)?); + let mut outputs = Vec::new(); + + for file_path in collect_task_files(&artifact_root)? { + let Ok(record) = read_task_record(&file_path) else { + continue; + }; + if !task_record_matches_filter(&record, status_filter, task_family_filter, task_type_filter) + { + continue; + } + outputs.push(build_task_output(workspace_root, &file_path, record, false)); + } + + outputs + .sort_by(|left, right| record_sort_key(&right.record).cmp(record_sort_key(&left.record))); + + if let Some(limit) = limit { + outputs.truncate(limit); + } + + Ok(outputs) +} + +pub fn update_task_status( + workspace_root: &Path, + task_ref: &str, + artifact_dir: Option<&str>, + new_status: &str, +) -> Result { + let task_path = resolve_task_reference_path(workspace_root, task_ref, artifact_dir)?; + let mut record = read_task_record(&task_path)?; + let current_normalized_status = record.normalized_status.clone(); + let next_status = normalize_mutation_status(new_status)?; + let next_normalized_status = normalize_status(&next_status); + let occurred_at = Utc::now().to_rfc3339(); + + if current_normalized_status == "succeeded" && next_normalized_status != "succeeded" { + return Err(MediaRuntimeError::InvalidState( + "已成功完成的任务不能再修改状态".to_string(), + )); + } + if current_normalized_status == "failed" && next_normalized_status == "running" { + return Err(MediaRuntimeError::InvalidState( + "失败任务请使用 retry 创建新尝试,不要直接改回 running".to_string(), + )); + } + + record.updated_at = Some(occurred_at.clone()); + if next_normalized_status != "failed" { + record.last_error = None; + } + apply_status_to_record(&mut record, &next_status, &occurred_at); + if let Some(index) = current_attempt_index(&record) { + let current_attempt = &mut record.attempts[index]; + apply_status_to_attempt(current_attempt, &next_status, &occurred_at); + if next_normalized_status != "failed" { + current_attempt.error = None; + } else if current_attempt.error.is_none() { + current_attempt.error = record.last_error.clone(); + } + + if matches!(next_normalized_status.as_str(), "partial" | "succeeded") { + current_attempt.result_snapshot = record.result.clone(); + } else if next_normalized_status != "failed" { + current_attempt.result_snapshot = None; + } + } + write_task_record(&task_path, &record)?; + Ok(build_task_output(workspace_root, &task_path, record, false)) +} + +pub fn retry_task_artifact( + workspace_root: &Path, + task_ref: &str, + artifact_dir: Option<&str>, +) -> Result { + let task_path = resolve_task_reference_path(workspace_root, task_ref, artifact_dir)?; + let mut record = read_task_record(&task_path)?; + let normalized_status = record.normalized_status.clone(); + if normalized_status != "failed" && normalized_status != "cancelled" { + return Err(MediaRuntimeError::NotRetryable(format!( + "当前状态 `{}` 不支持 retry", + record.status + ))); + } + + let task_type = record.task_type.parse::().map_err(|_| { + MediaRuntimeError::InvalidState(format!("未知任务类型: {}", record.task_type)) + })?; + let retry_status = task_type.default_status().to_string(); + let occurred_at = Utc::now().to_rfc3339(); + let next_attempt_index = record.attempts.len() as u32 + 1; + let previous_attempt_id = record.current_attempt_id.clone(); + let mut next_attempt = build_attempt_record( + &record.task_id, + &record.payload, + AttemptRecordInput { + attempt_id: new_attempt_id(), + attempt_index: next_attempt_index, + status: retry_status.clone(), + queued_at: None, + started_at: None, + completed_at: None, + result_snapshot: None, + error: None, + }, + ); + apply_status_to_attempt(&mut next_attempt, &retry_status, &occurred_at); + + record.attempts.push(next_attempt); + record.current_attempt_id = record + .attempts + .last() + .map(|attempt| attempt.attempt_id.clone()); + record.relationships.derived_from_attempt_id = previous_attempt_id; + record.updated_at = Some(occurred_at.clone()); + record.result = None; + record.last_error = None; + record.source_task_id = None; + record.retry_count = record.attempts.len().saturating_sub(1) as u32; + apply_status_to_record(&mut record, &retry_status, &occurred_at); + + write_task_record(&task_path, &record)?; + Ok(build_task_output(workspace_root, &task_path, record, false)) +} + +pub fn parse_task_output(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + serde_json::from_str::(trimmed) + .ok() + .map(|mut value| { + value.record = canonicalize_task_record(value.record); + value.task_family = if value.task_family.trim().is_empty() { + value.record.task_family.clone() + } else { + value.task_family + }; + value.status = if value.status.trim().is_empty() { + value.record.status.clone() + } else { + value.status + }; + value.normalized_status = normalize_status(&value.status); + value.current_attempt_id = value + .current_attempt_id + .or_else(|| value.record.current_attempt_id.clone()); + if value.attempt_count == 0 { + value.attempt_count = value.record.attempts.len() as u32; + } + if value.last_error.is_none() { + value.last_error = value.record.last_error.clone(); + } + if value.progress.is_empty() { + value.progress = value.record.progress.clone(); + } + if value.ui_hints.is_empty() { + value.ui_hints = value.record.ui_hints.clone(); + } + value + }) + .filter(|value| value.success && !value.task_type.trim().is_empty()) +} + +pub fn parse_media_task_output(raw: &str) -> Option { + parse_task_output(raw) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_media_task_artifact_uses_default_task_root() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let output = write_media_task_artifact( + temp_dir.path(), + MediaTaskType::ImageGenerate, + Some("配图".to_string()), + serde_json::json!({ "prompt": "未来城市插图" }), + None, + None, + None, + ) + .expect("write media task"); + + assert!(output.path.starts_with(".lime/tasks/image_generate/")); + assert_eq!(output.task_type, "image_generate"); + assert_eq!(output.task_family, "image"); + assert_eq!(output.status, "pending_submit"); + assert_eq!(output.normalized_status, "pending"); + assert_eq!(output.attempt_count, 1); + assert!(output.current_attempt_id.is_some()); + assert_eq!(output.record.attempts.len(), 1); + assert_eq!( + output.ui_hints.render_mode.as_deref(), + Some("media_placeholder_card") + ); + assert!(temp_dir.path().join(&output.path).exists()); + } + + #[test] + fn write_media_task_artifact_rejects_parent_dir_escape() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let error = write_media_task_artifact( + temp_dir.path(), + MediaTaskType::CoverGenerate, + None, + serde_json::json!({ "prompt": "封面" }), + None, + Some("../escape.json"), + None, + ) + .expect_err("should reject unsafe path"); + + assert!(matches!(error, MediaRuntimeError::InvalidParams(_))); + } + + #[test] + fn write_media_task_artifact_supports_custom_artifact_dir() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let output = write_media_task_artifact( + temp_dir.path(), + MediaTaskType::VideoGenerate, + None, + serde_json::json!({ "prompt": "短视频" }), + Some("queued".to_string()), + None, + Some("custom/tasks"), + ) + .expect("write media task"); + + assert!(output.path.starts_with("custom/tasks/video_generate/")); + assert_eq!(output.status, "queued"); + assert_eq!(output.normalized_status, "queued"); + assert_eq!(output.record.attempts.len(), 1); + assert!(output.record.current_attempt_id.is_some()); + } + + #[test] + fn write_task_artifact_reuses_idempotent_record() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let first = write_task_artifact( + temp_dir.path(), + TaskType::BroadcastGenerate, + Some("播客".to_string()), + serde_json::json!({ "content": "demo" }), + TaskWriteOptions { + idempotency_key: Some("broadcast-1"), + ..TaskWriteOptions::default() + }, + ) + .expect("write first"); + + let second = write_task_artifact( + temp_dir.path(), + TaskType::BroadcastGenerate, + Some("播客".to_string()), + serde_json::json!({ "content": "demo" }), + TaskWriteOptions { + idempotency_key: Some("broadcast-1"), + ..TaskWriteOptions::default() + }, + ) + .expect("write second"); + + assert_eq!(first.task_id, second.task_id); + assert!(second.reused_existing); + } + + #[test] + fn list_task_outputs_filters_by_normalized_status() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let _pending = write_task_artifact( + temp_dir.path(), + TaskType::Typesetting, + None, + serde_json::json!({ "content": "demo" }), + TaskWriteOptions::default(), + ) + .expect("write pending"); + let failed = write_task_artifact( + temp_dir.path(), + TaskType::UrlParse, + None, + serde_json::json!({ "url": "https://example.com" }), + TaskWriteOptions { + status: Some("failed".to_string()), + ..TaskWriteOptions::default() + }, + ) + .expect("write failed"); + + let items = list_task_outputs(temp_dir.path(), None, Some("failed"), None, None, Some(10)) + .expect("list tasks"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].task_id, failed.task_id); + } + + #[test] + fn retry_task_artifact_appends_attempt_to_same_task() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let failed = write_task_artifact( + temp_dir.path(), + TaskType::ModalResourceSearch, + None, + serde_json::json!({ "query": "城市" }), + TaskWriteOptions { + status: Some("failed".to_string()), + idempotency_key: Some("search-1"), + ..TaskWriteOptions::default() + }, + ) + .expect("write failed"); + + let retried = + retry_task_artifact(temp_dir.path(), &failed.task_id, None).expect("retry task"); + + assert_eq!(failed.task_id, retried.task_id); + assert_ne!(failed.current_attempt_id, retried.current_attempt_id); + assert_eq!(retried.status, "pending_submit"); + assert_eq!(retried.record.retry_count, 1); + assert_eq!(retried.record.source_task_id, None); + assert_eq!(retried.record.idempotency_key.as_deref(), Some("search-1")); + assert_eq!(retried.record.attempts.len(), 2); + assert_eq!( + retried.record.relationships.derived_from_attempt_id, + failed.current_attempt_id + ); + } + + #[test] + fn load_task_output_upgrades_legacy_error_and_attempt_history() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let legacy_path = temp_dir + .path() + .join(".lime/tasks/image_generate/legacy-task.json"); + std::fs::create_dir_all( + legacy_path + .parent() + .expect("legacy task parent should exist"), + ) + .expect("create legacy task dir"); + std::fs::write( + &legacy_path, + serde_json::json!({ + "task_id": "legacy-task", + "task_type": "image_generate", + "payload": { + "prompt": "未来实验室" + }, + "status": "failed", + "created_at": "2026-04-03T00:00:00Z", + "retry_count": 2, + "last_error": "provider timeout" + }) + .to_string(), + ) + .expect("write legacy task"); + + let output = + load_task_output(temp_dir.path(), "legacy-task", None).expect("load legacy task"); + + assert_eq!(output.task_family, "image"); + assert_eq!(output.attempt_count, 1); + assert_eq!(output.record.retry_count, 0); + assert_eq!(output.record.attempts.len(), 1); + assert_eq!( + output + .record + .last_error + .as_ref() + .map(|value| value.code.as_str()), + Some("legacy_error") + ); + assert_eq!( + output + .record + .attempts + .first() + .and_then(|attempt| attempt.error.as_ref()) + .map(|value| value.message.as_str()), + Some("provider timeout") + ); + } + + #[test] + fn parse_media_task_output_accepts_serialized_success_payload() { + let payload = MediaTaskOutput { + success: true, + task_id: "task-1".to_string(), + task_type: "image_generate".to_string(), + task_family: "image".to_string(), + status: "pending_submit".to_string(), + normalized_status: "pending".to_string(), + current_attempt_id: Some("attempt-1".to_string()), + attempt_count: 1, + last_error: None, + progress: TaskProgress::default(), + ui_hints: TaskUiHints::default(), + path: ".lime/tasks/image_generate/demo.json".to_string(), + absolute_path: "/tmp/demo.json".to_string(), + artifact_path: ".lime/tasks/image_generate/demo.json".to_string(), + absolute_artifact_path: "/tmp/demo.json".to_string(), + reused_existing: false, + idempotency_key: None, + record: MediaTaskArtifactRecord { + task_id: "task-1".to_string(), + task_type: "image_generate".to_string(), + task_family: "image".to_string(), + title: None, + summary: Some("image_generate 任务".to_string()), + payload: serde_json::json!({ "prompt": "demo" }), + status: "pending_submit".to_string(), + normalized_status: "pending".to_string(), + created_at: "2026-04-03T00:00:00Z".to_string(), + updated_at: None, + submitted_at: None, + started_at: None, + completed_at: None, + cancelled_at: None, + idempotency_key: None, + retry_count: 0, + source_task_id: None, + result: None, + last_error: None, + current_attempt_id: Some("attempt-1".to_string()), + attempts: vec![TaskAttemptRecord { + attempt_id: "attempt-1".to_string(), + attempt_index: 1, + status: "pending_submit".to_string(), + input_snapshot: serde_json::json!({ "prompt": "demo" }), + ..TaskAttemptRecord::default() + }], + relationships: TaskRelationships::default(), + progress: TaskProgress::default(), + ui_hints: TaskUiHints::default(), + }, + }; + let serialized = serde_json::to_string(&payload).expect("serialize"); + + let parsed = parse_media_task_output(&serialized).expect("parse success payload"); + assert_eq!(parsed.task_id, "task-1"); + assert_eq!( + parsed.artifact_paths(), + vec![".lime/tasks/image_generate/demo.json".to_string()] + ); + } +} diff --git a/src-tauri/crates/providers/src/providers/antigravity.rs b/src-tauri/crates/providers/src/providers/antigravity.rs index f609dac87..6903c2d0a 100644 --- a/src-tauri/crates/providers/src/providers/antigravity.rs +++ b/src-tauri/crates/providers/src/providers/antigravity.rs @@ -423,11 +423,33 @@ impl AntigravityProvider { Ok(()) } + async fn resolve_credentials_path(path: &str) -> PathBuf { + let explicit_path = PathBuf::from(path); + if tokio::fs::try_exists(&explicit_path).await.unwrap_or(false) { + return explicit_path; + } + + let default_path = Self::default_creds_path(); + if explicit_path != default_path + && tokio::fs::try_exists(&default_path).await.unwrap_or(false) + { + tracing::warn!( + "[ANTIGRAVITY] 显式凭证路径不存在,回退到默认路径: missing={}, fallback={}", + explicit_path.display(), + default_path.display() + ); + return default_path; + } + + explicit_path + } + pub async fn load_credentials_from_path( &mut self, path: &str, ) -> Result<(), Box> { - let content = tokio::fs::read_to_string(path).await?; + let resolved_path = Self::resolve_credentials_path(path).await; + let content = tokio::fs::read_to_string(&resolved_path).await?; // 尝试解析为单个凭证对象 if let Ok(creds) = serde_json::from_str::(&content) { @@ -2268,6 +2290,41 @@ impl StreamingProvider for AntigravityProvider { mod tests { use super::*; use proptest::prelude::*; + use std::ffi::OsString; + use std::sync::{Mutex, OnceLock}; + use tempfile::tempdir; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + struct EnvGuard { + values: Vec<(&'static str, Option)>, + } + + impl EnvGuard { + fn set(entries: &[(&'static str, OsString)]) -> Self { + let mut values = Vec::new(); + for (key, value) in entries { + values.push((*key, std::env::var_os(key))); + std::env::set_var(key, value); + } + Self { values } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, previous) in self.values.drain(..) { + if let Some(value) = previous { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } + } + } + } // 辅助函数:检查是否为 Valid 状态 fn is_valid(result: &TokenValidationResult) -> bool { @@ -2577,4 +2634,56 @@ mod tests { let error = result.unwrap_err(); assert!(error.requires_reauth()); } + + #[tokio::test] + async fn load_credentials_from_path_prefers_existing_explicit_path() { + let _guard = env_lock().lock().expect("env lock"); + let temp = tempdir().expect("create tempdir"); + let _env = EnvGuard::set(&[("HOME", temp.path().as_os_str().to_os_string())]); + let explicit_path = temp.path().join("explicit.json"); + std::fs::write( + &explicit_path, + r#"{"access_token":"explicit_token","refresh_token":"refresh","project_id":"explicit-project"}"#, + ) + .expect("write explicit creds"); + + let mut provider = AntigravityProvider::new(); + provider + .load_credentials_from_path(explicit_path.to_string_lossy().as_ref()) + .await + .expect("load explicit creds"); + + assert_eq!( + provider.credentials.access_token.as_deref(), + Some("explicit_token") + ); + assert_eq!(provider.project_id.as_deref(), Some("explicit-project")); + } + + #[tokio::test] + async fn load_credentials_from_path_falls_back_to_default_path_when_explicit_missing() { + let _guard = env_lock().lock().expect("env lock"); + let temp = tempdir().expect("create tempdir"); + let _env = EnvGuard::set(&[("HOME", temp.path().as_os_str().to_os_string())]); + let default_path = temp.path().join(".antigravity").join("oauth_creds.json"); + std::fs::create_dir_all(default_path.parent().expect("default parent")) + .expect("create default dir"); + std::fs::write( + &default_path, + r#"{"access_token":"fallback_token","refresh_token":"refresh","project_id":"fallback-project"}"#, + ) + .expect("write fallback creds"); + + let mut provider = AntigravityProvider::new(); + provider + .load_credentials_from_path(temp.path().join("missing.json").to_string_lossy().as_ref()) + .await + .expect("load fallback creds"); + + assert_eq!( + provider.credentials.access_token.as_deref(), + Some("fallback_token") + ); + assert_eq!(provider.project_id.as_deref(), Some("fallback-project")); + } } diff --git a/src-tauri/crates/server/src/handlers/image_api_provider.rs b/src-tauri/crates/server/src/handlers/image_api_provider.rs new file mode 100644 index 000000000..306f83684 --- /dev/null +++ b/src-tauri/crates/server/src/handlers/image_api_provider.rs @@ -0,0 +1,726 @@ +use std::time::Duration; + +use axum::http::StatusCode; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use lime_core::config::ConfigManager; +use lime_core::database::dao::api_key_provider::{ + ApiKeyProvider, ApiKeyProviderDao, ApiProviderType, +}; +use lime_core::models::openai::{ImageData, ImageGenerationRequest, ImageGenerationResponse}; +use reqwest::{header::CONTENT_TYPE, Client}; +use serde_json::{json, Value}; + +use crate::AppState; + +const FAL_DEFAULT_HOST: &str = "https://fal.run"; +const FAL_DEFAULT_MODEL: &str = "fal-ai/nano-banana-pro"; +const FAL_QUEUE_DEFAULT_HOST: &str = "https://queue.fal.run"; +const FAL_REQUEST_TIMEOUT_SECS: u64 = 180; +const FAL_QUEUE_TIMEOUT_SECS: u64 = 180; +const FAL_QUEUE_POLL_INTERVAL_MS: u64 = 1500; + +pub(crate) struct ConfiguredImageProviderError { + pub status: StatusCode, + pub code: &'static str, + pub message: String, +} + +struct ImageProviderRoutingConfig { + provider_id: String, + preferred_model_id: Option, + allow_fallback: bool, + default_size: Option, +} + +pub(crate) async fn try_generate_with_configured_provider( + state: &AppState, + request: &ImageGenerationRequest, +) -> Result, ConfiguredImageProviderError> { + let Some(routing) = load_image_provider_routing() else { + return Ok(None); + }; + + let db = state + .db + .as_ref() + .ok_or_else(|| ConfiguredImageProviderError { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: "database_unavailable", + message: "Database not available".to_string(), + })?; + + let provider = match load_api_key_provider(db, &routing.provider_id) { + Ok(Some(provider)) if provider.enabled => provider, + Ok(Some(_)) | Ok(None) => { + return handle_routing_failure( + state, + &routing, + format!("默认图片服务 {} 当前不可用", routing.provider_id), + "configured_provider_unavailable", + ) + } + Err(error) => { + return handle_routing_failure( + state, + &routing, + format!("读取默认图片服务失败: {error}"), + "configured_provider_lookup_failed", + ) + } + }; + + if !is_fal_provider(&provider) { + return handle_routing_failure( + state, + &routing, + format!( + "当前默认图片服务 {} 尚未接入 /v1/images/generations", + provider.id + ), + "configured_provider_not_supported", + ); + } + + let Some((key_id, api_key)) = state + .api_key_service + .get_next_api_key_entry(db, &provider.id) + .map_err(|error| ConfiguredImageProviderError { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: "configured_provider_key_failed", + message: format!("读取默认图片服务 API Key 失败: {error}"), + })? + else { + return handle_routing_failure( + state, + &routing, + format!("默认图片服务 {} 没有可用的 API Key", provider.id), + "configured_provider_missing_key", + ); + }; + + let client = Client::builder() + .timeout(Duration::from_secs(FAL_REQUEST_TIMEOUT_SECS)) + .build() + .unwrap_or_else(|_| Client::new()); + let request_size = request + .size + .clone() + .or_else(|| routing.default_size.clone()) + .unwrap_or_else(|| "1024x1024".to_string()); + let request_model = resolve_fal_model( + request.model.as_str(), + routing.preferred_model_id.as_deref(), + ); + + state.logs.write().await.add( + "info", + &format!( + "[IMAGE] 默认图片服务命中 API Provider: provider_id={}, model={}, size={}", + provider.id, request_model, request_size + ), + ); + + let image_urls = match request_fal_images( + &client, + &provider.api_host, + &api_key, + &request.prompt, + &request_model, + &request_size, + request.n.max(1), + ) + .await + { + Ok(urls) => urls, + Err(error) => { + let _ = state.api_key_service.record_error(db, &key_id); + return handle_routing_failure( + state, + &routing, + format!("默认图片服务调用失败: {error}"), + "configured_provider_request_failed", + ); + } + }; + + let _ = state.api_key_service.record_usage(db, &key_id); + + build_openai_response(&client, &image_urls, &request.response_format) + .await + .map(Some) + .map_err(|error| ConfiguredImageProviderError { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: "configured_provider_response_failed", + message: error, + }) +} + +fn handle_routing_failure( + _state: &AppState, + routing: &ImageProviderRoutingConfig, + message: String, + code: &'static str, +) -> Result, ConfiguredImageProviderError> { + if routing.allow_fallback { + tracing::warn!("[IMAGE] 默认图片服务失败,允许回退: {}", message); + return Ok(None); + } + + Err(ConfiguredImageProviderError { + status: StatusCode::SERVICE_UNAVAILABLE, + code, + message, + }) +} + +fn load_image_provider_routing() -> Option { + let config_path = ConfigManager::default_config_path(); + let manager = ConfigManager::load(&config_path).ok()?; + let image_preference = manager + .config() + .workspace_preferences + .media_defaults + .image + .clone(); + let provider_id = image_preference.preferred_provider_id?.trim().to_string(); + + if provider_id.is_empty() { + return None; + } + + Some(ImageProviderRoutingConfig { + provider_id, + preferred_model_id: normalize_optional_string(image_preference.preferred_model_id), + allow_fallback: image_preference.allow_fallback, + default_size: normalize_optional_string(manager.config().image_gen.default_size.clone()), + }) +} + +fn normalize_optional_string(value: Option) -> Option { + value.and_then(|item| { + let trimmed = item.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn load_api_key_provider( + db: &lime_core::database::DbConnection, + provider_id: &str, +) -> Result, String> { + let conn = lime_core::database::lock_db(db)?; + ApiKeyProviderDao::get_provider_by_id(&conn, provider_id).map_err(|error| error.to_string()) +} + +fn is_fal_provider(provider: &ApiKeyProvider) -> bool { + if provider.provider_type == ApiProviderType::Fal || provider.id == "fal" { + return true; + } + + let normalized_host = provider.api_host.trim().to_ascii_lowercase(); + normalized_host.contains("fal.run") || normalized_host.contains("queue.fal.run") +} + +fn resolve_fal_model(request_model: &str, preferred_model_id: Option<&str>) -> String { + let trimmed = request_model.trim(); + if trimmed.is_empty() { + return normalize_preferred_fal_model(preferred_model_id) + .unwrap_or_else(|| FAL_DEFAULT_MODEL.to_string()); + } + + if !looks_like_fal_model(trimmed) { + return normalize_preferred_fal_model(preferred_model_id) + .unwrap_or_else(|| FAL_DEFAULT_MODEL.to_string()); + } + + normalize_fal_model(trimmed) +} + +fn normalize_preferred_fal_model(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(normalize_fal_model) +} + +fn looks_like_fal_model(model: &str) -> bool { + let normalized = model.trim().to_ascii_lowercase(); + normalized.starts_with("fal-ai/") + || normalized.contains("nano-banana") + || normalized.contains("flux") + || normalized.contains("seedream") + || normalized.contains("recraft") + || normalized.contains("ideogram") + || normalized.contains("fal") +} + +fn normalize_fal_model(model: &str) -> String { + let trimmed = model.trim(); + if trimmed.is_empty() { + return FAL_DEFAULT_MODEL.to_string(); + } + + if trimmed.starts_with("fal-ai/") { + trimmed.to_string() + } else { + format!("fal-ai/{trimmed}") + } +} + +fn normalize_fal_api_host(api_host: &str) -> String { + let trimmed = api_host.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return FAL_DEFAULT_HOST.to_string(); + } + + let normalized = if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + trimmed.to_string() + } else { + format!("https://{trimmed}") + }; + + match reqwest::Url::parse(&normalized) { + Ok(url) if matches!(url.host_str(), Some("fal.run" | "queue.fal.run")) => { + format!("{}://{}", url.scheme(), url.host_str().unwrap_or("fal.run")) + } + Ok(url) => url.to_string().trim_end_matches('/').to_string(), + Err(_) => normalized, + } +} + +fn resolve_fal_queue_host(api_host: &str) -> String { + let normalized = normalize_fal_api_host(api_host); + match reqwest::Url::parse(&normalized) { + Ok(url) if url.host_str() == Some("queue.fal.run") => { + format!("{}://queue.fal.run", url.scheme()) + } + Ok(url) if url.host_str() == Some("fal.run") => format!("{}://queue.fal.run", url.scheme()), + _ => FAL_QUEUE_DEFAULT_HOST.to_string(), + } +} + +fn size_to_aspect_ratio(size: &str) -> Option { + let (width_raw, height_raw) = size.split_once('x')?; + let width = width_raw.parse::().ok()?; + let height = height_raw.parse::().ok()?; + if width == 0 || height == 0 { + return None; + } + + let gcd = greatest_common_divisor(width, height); + Some(format!("{}:{}", width / gcd, height / gcd)) +} + +fn greatest_common_divisor(mut left: u32, mut right: u32) -> u32 { + while right != 0 { + let temp = right; + right = left % right; + left = temp; + } + left.max(1) +} + +fn build_fal_payload(prompt: &str, size: &str, count: u32) -> Value { + let mut payload = json!({ + "prompt": prompt.trim(), + "num_images": count.max(1), + "output_format": "png", + "safety_tolerance": "4", + }); + + if let Some(aspect_ratio) = size_to_aspect_ratio(size) { + payload["aspect_ratio"] = Value::String(aspect_ratio); + } + + payload +} + +async fn request_fal_images( + client: &Client, + api_host: &str, + api_key: &str, + prompt: &str, + model: &str, + size: &str, + count: u32, +) -> Result, String> { + let endpoint_model = model.trim().trim_start_matches('/'); + let sync_endpoint = format!( + "{}/{}", + normalize_fal_api_host(api_host).trim_end_matches('/'), + endpoint_model + ); + let payload = build_fal_payload(prompt, size, count); + let sync_result = post_fal_json(client, &sync_endpoint, &payload, api_key).await; + + if let Ok(sync_response) = &sync_result { + let urls = collect_image_urls(sync_response); + if !urls.is_empty() { + return Ok(urls.into_iter().take(count as usize).collect()); + } + } + + let queue_error_context = sync_result + .err() + .unwrap_or_else(|| "Fal 同步接口未返回可解析图片,改走队列模式".to_string()); + tracing::warn!("[IMAGE] {}", queue_error_context); + + request_fal_queue_images(client, api_host, api_key, endpoint_model, &payload, count) + .await + .map_err(|queue_error| format!("{queue_error}; sync_context={queue_error_context}")) +} + +async fn post_fal_json( + client: &Client, + endpoint: &str, + payload: &Value, + api_key: &str, +) -> Result { + let response = client + .post(endpoint) + .header("Authorization", format!("Key {api_key}")) + .json(payload) + .send() + .await + .map_err(|error| format!("Fal 请求失败: {error}"))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| format!("Fal 响应读取失败: {error}"))?; + + if !status.is_success() { + return Err(format!( + "Fal HTTP {}: {}", + status.as_u16(), + preview_text(&body, 240) + )); + } + + serde_json::from_str::(&body).map_err(|error| { + format!( + "Fal JSON 解析失败: {error}; body={}", + preview_text(&body, 240) + ) + }) +} + +async fn request_fal_queue_images( + client: &Client, + api_host: &str, + api_key: &str, + endpoint_model: &str, + payload: &Value, + count: u32, +) -> Result, String> { + let queue_endpoint = format!( + "{}/{}", + resolve_fal_queue_host(api_host).trim_end_matches('/'), + endpoint_model + ); + let submit = post_fal_json(client, &queue_endpoint, payload, api_key).await?; + let mut urls = collect_image_urls(&submit); + if !urls.is_empty() { + return Ok(urls.into_iter().take(count as usize).collect()); + } + + let request_id = submit + .get("request_id") + .and_then(Value::as_str) + .map(str::to_string); + let mut status_url = submit + .get("status_url") + .and_then(Value::as_str) + .map(str::to_string); + let mut response_url = submit + .get("response_url") + .and_then(Value::as_str) + .map(str::to_string); + + if let Some(request_id) = request_id { + let request_base = format!( + "{queue_endpoint}/requests/{}", + urlencoding::encode(&request_id) + ); + if status_url.is_none() { + status_url = Some(format!("{request_base}/status")); + } + if response_url.is_none() { + response_url = Some(format!("{request_base}/response")); + } + } + + let Some(status_url) = status_url else { + return Err("Fal 队列提交成功,但缺少 status_url".to_string()); + }; + + let deadline = tokio::time::Instant::now() + Duration::from_secs(FAL_QUEUE_TIMEOUT_SECS); + while tokio::time::Instant::now() < deadline { + let status_payload = get_fal_json(client, &status_url, api_key).await?; + if let Some(next_response_url) = status_payload + .get("response_url") + .and_then(Value::as_str) + .map(str::to_string) + { + response_url = Some(next_response_url); + } + + let status = status_payload + .get("status") + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_uppercase(); + + if status == "COMPLETED" { + break; + } + + if matches!(status.as_str(), "FAILED" | "ERROR" | "CANCELLED") { + return Err(format!( + "Fal 队列任务失败: {}", + preview_text(&status_payload.to_string(), 240) + )); + } + + tokio::time::sleep(Duration::from_millis(FAL_QUEUE_POLL_INTERVAL_MS)).await; + } + + let Some(response_url) = response_url else { + return Err("Fal 队列完成后缺少 response_url".to_string()); + }; + + let result_payload = get_fal_json(client, &response_url, api_key).await?; + urls = collect_image_urls(&result_payload); + if urls.is_empty() { + return Err(format!( + "Fal 队列结果中未找到图片地址: {}", + preview_text(&result_payload.to_string(), 240) + )); + } + + Ok(urls.into_iter().take(count as usize).collect()) +} + +async fn get_fal_json(client: &Client, endpoint: &str, api_key: &str) -> Result { + let response = client + .get(endpoint) + .header("Authorization", format!("Key {api_key}")) + .send() + .await + .map_err(|error| format!("Fal GET 请求失败: {error}"))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| format!("Fal GET 响应读取失败: {error}"))?; + + if !status.is_success() { + return Err(format!( + "Fal GET HTTP {}: {}", + status.as_u16(), + preview_text(&body, 240) + )); + } + + serde_json::from_str::(&body).map_err(|error| { + format!( + "Fal GET JSON 解析失败: {error}; body={}", + preview_text(&body, 240) + ) + }) +} + +async fn build_openai_response( + client: &Client, + image_urls: &[String], + response_format: &str, +) -> Result { + if image_urls.is_empty() { + return Err("默认图片服务未返回图片地址".to_string()); + } + + let mut data = Vec::with_capacity(image_urls.len()); + for image_url in image_urls { + if response_format == "b64_json" { + let b64_json = download_image_as_base64(client, image_url).await?; + data.push(ImageData { + b64_json: Some(b64_json), + url: None, + revised_prompt: None, + }); + } else { + data.push(ImageData { + b64_json: None, + url: Some(image_url.clone()), + revised_prompt: None, + }); + } + } + + Ok(ImageGenerationResponse { + created: chrono::Utc::now().timestamp(), + data, + }) +} + +async fn download_image_as_base64(client: &Client, image_url: &str) -> Result { + let response = client + .get(image_url) + .send() + .await + .map_err(|error| format!("下载图片失败: {error}"))?; + + let status = response.status(); + if !status.is_success() { + return Err(format!("下载图片失败: HTTP {}", status.as_u16())); + } + + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/png") + .to_string(); + let bytes = response + .bytes() + .await + .map_err(|error| format!("读取图片字节失败: {error}"))?; + tracing::debug!( + "[IMAGE] 默认图片服务下载成功: content_type={}, bytes={}", + content_type, + bytes.len() + ); + Ok(BASE64.encode(bytes)) +} + +fn collect_image_urls(value: &Value) -> Vec { + let mut urls = Vec::new(); + collect_image_urls_inner(value, &mut urls); + urls +} + +fn collect_image_urls_inner(value: &Value, urls: &mut Vec) { + match value { + Value::String(text) => { + let trimmed = text.trim(); + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + push_unique(urls, trimmed.to_string()); + } + } + Value::Array(items) => { + for item in items { + collect_image_urls_inner(item, urls); + } + } + Value::Object(map) => { + for key in [ + "url", + "uri", + "href", + "image", + "image_url", + "imageUrl", + "image_uri", + "imageUri", + "file_url", + "fileUrl", + "download_url", + "downloadUrl", + ] { + if let Some(Value::String(url)) = map.get(key) { + let trimmed = url.trim(); + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + push_unique(urls, trimmed.to_string()); + } + } + } + + for nested in map.values() { + collect_image_urls_inner(nested, urls); + } + } + _ => {} + } +} + +fn push_unique(urls: &mut Vec, candidate: String) { + if !urls.iter().any(|existing| existing == &candidate) { + urls.push(candidate); + } +} + +fn preview_text(text: &str, max_len: usize) -> String { + let normalized = text.trim(); + let normalized_chars = normalized.chars().count(); + if normalized_chars <= max_len { + return normalized.to_string(); + } + let preview: String = normalized.chars().take(max_len).collect(); + format!("{preview}...") +} + +#[cfg(test)] +mod tests { + use super::{ + collect_image_urls, normalize_fal_api_host, resolve_fal_model, size_to_aspect_ratio, + }; + use serde_json::json; + + #[test] + fn normalize_fal_api_host_strips_builtin_path_suffix() { + assert_eq!( + normalize_fal_api_host("https://fal.run/fal-ai"), + "https://fal.run" + ); + assert_eq!( + normalize_fal_api_host("https://queue.fal.run/fal-ai"), + "https://queue.fal.run" + ); + } + + #[test] + fn resolve_fal_model_prefers_config_when_request_uses_openai_default() { + assert_eq!( + resolve_fal_model("dall-e-3", Some("fal-ai/nano-banana-pro")), + "fal-ai/nano-banana-pro" + ); + assert_eq!( + resolve_fal_model("nano-banana-pro", None), + "fal-ai/nano-banana-pro" + ); + } + + #[test] + fn size_to_aspect_ratio_reduces_fraction() { + assert_eq!(size_to_aspect_ratio("1024x1024"), Some("1:1".to_string())); + assert_eq!(size_to_aspect_ratio("1792x1024"), Some("7:4".to_string())); + assert_eq!(size_to_aspect_ratio("invalid"), None); + } + + #[test] + fn collect_image_urls_reads_nested_payloads() { + let payload = json!({ + "images": [ + { "url": "https://example.com/a.png" }, + { "imageUrl": "https://example.com/b.png" } + ], + "nested": { + "downloadUrl": "https://example.com/c.png" + } + }); + + assert_eq!( + collect_image_urls(&payload), + vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string(), + "https://example.com/c.png".to_string(), + ] + ); + } +} diff --git a/src-tauri/crates/server/src/handlers/image_handler.rs b/src-tauri/crates/server/src/handlers/image_handler.rs index a549d6402..49e3dd0da 100644 --- a/src-tauri/crates/server/src/handlers/image_handler.rs +++ b/src-tauri/crates/server/src/handlers/image_handler.rs @@ -23,6 +23,7 @@ use axum::{ Json, }; +use super::image_api_provider; use crate::handlers::verify_api_key; use crate::AppState; use lime_core::models::openai::ImageGenerationRequest; @@ -101,6 +102,42 @@ pub async fn handle_image_generation( ), ); + match image_api_provider::try_generate_with_configured_provider(&state, &request).await { + Ok(Some(response)) => { + state.logs.write().await.add( + "info", + &format!( + "[IMAGE] 默认图片服务生成成功: {} 张图片", + response.data.len() + ), + ); + return (StatusCode::OK, Json(response)).into_response(); + } + Ok(None) => { + state.logs.write().await.add( + "debug", + "[IMAGE] 默认图片服务未命中,继续回退到 Antigravity 兼容链路", + ); + } + Err(error) => { + state.logs.write().await.add( + "error", + &format!("[IMAGE] 默认图片服务失败: {}", error.message), + ); + return ( + error.status, + Json(serde_json::json!({ + "error": { + "message": error.message, + "type": "server_error", + "code": error.code, + } + })), + ) + .into_response(); + } + } + // 获取 Antigravity 凭证 let db = match &state.db { Some(db) => db, diff --git a/src-tauri/crates/server/src/handlers/mod.rs b/src-tauri/crates/server/src/handlers/mod.rs index eb493f3a2..1615f9871 100644 --- a/src-tauri/crates/server/src/handlers/mod.rs +++ b/src-tauri/crates/server/src/handlers/mod.rs @@ -6,6 +6,7 @@ pub mod api; pub mod api_key_provider_utils; pub mod chrome_bridge_ws; pub mod credentials_api; +pub(crate) mod image_api_provider; pub mod image_handler; pub mod kiro_credential; pub mod provider_calls; diff --git a/src-tauri/crates/services/src/file_browser_service.rs b/src-tauri/crates/services/src/file_browser_service.rs index f60c33220..60b5d4171 100644 --- a/src-tauri/crates/services/src/file_browser_service.rs +++ b/src-tauri/crates/services/src/file_browser_service.rs @@ -325,7 +325,11 @@ pub fn list_directory(path: &str) -> DirectoryListing { let canonical_path = match path_buf.canonicalize() { Ok(p) => p, Err(e) => { - error!("无法解析路径 {}: {}", path, e); + if e.kind() == std::io::ErrorKind::NotFound { + debug!("目录尚未创建,返回空列表: {}", path); + } else { + error!("无法解析路径 {}: {}", path, e); + } return DirectoryListing { path: path.to_string(), parent_path: None, diff --git a/src-tauri/crates/services/src/provider_pool_service.rs b/src-tauri/crates/services/src/provider_pool_service.rs index 5ce732c2c..b6c2ffd36 100644 --- a/src-tauri/crates/services/src/provider_pool_service.rs +++ b/src-tauri/crates/services/src/provider_pool_service.rs @@ -40,6 +40,7 @@ impl ProviderCredentialClientCompat for ProviderCredential { } } use std::collections::{HashMap, HashSet}; +use std::path::Path; use std::sync::atomic::AtomicUsize; use std::time::Duration; @@ -315,8 +316,30 @@ impl ProviderPoolService { credentials.extend(ai_provider_creds); } + let recoverable_antigravity: HashMap> = + if pt == PoolProviderType::Antigravity { + credentials + .iter() + .filter(|cred| Self::should_auto_recover_antigravity_credential(cred)) + .map(|cred| (cred.uuid.clone(), cred.check_model_name.clone())) + .collect() + } else { + HashMap::new() + }; + drop(conn); + if !recoverable_antigravity.is_empty() { + for (uuid, check_model_name) in &recoverable_antigravity { + self.mark_healthy(db, uuid, check_model_name.as_deref())?; + } + for cred in &mut credentials { + if let Some(check_model_name) = recoverable_antigravity.get(&cred.uuid) { + cred.mark_healthy(check_model_name.clone()); + } + } + } + eprintln!( "[SELECT_CREDENTIAL] total_credentials={}, model={:?}", credentials.len(), @@ -403,6 +426,28 @@ impl ProviderPoolService { Ok(Some(selected)) } + fn should_auto_recover_antigravity_credential(cred: &ProviderCredential) -> bool { + let CredentialData::AntigravityOAuth { + creds_file_path, .. + } = &cred.credential + else { + return false; + }; + + if cred.is_healthy || Path::new(creds_file_path).exists() { + return false; + } + + let last_error = cred.last_error_message.as_deref().unwrap_or_default(); + let is_missing_path_failure = last_error.contains("Failed to load credentials") + || last_error.contains("No such file or directory"); + if !is_missing_path_failure { + return false; + } + + lime_providers::providers::antigravity::AntigravityProvider::default_creds_path().exists() + } + /// 带智能降级的凭证选择 /// /// 当 Provider Pool 无可用凭证时,自动从 API Key Provider 降级查找 @@ -2084,6 +2129,49 @@ pub struct MigrationResult { mod tests { use super::*; use lime_core::database::dao::api_key_provider::ApiProviderType; + use lime_core::database::schema::create_tables; + use rusqlite::Connection; + use std::ffi::OsString; + use std::sync::{Arc, Mutex, OnceLock}; + use tempfile::tempdir; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + struct EnvGuard { + values: Vec<(&'static str, Option)>, + } + + impl EnvGuard { + fn set(entries: &[(&'static str, OsString)]) -> Self { + let mut values = Vec::new(); + for (key, value) in entries { + values.push((*key, std::env::var_os(key))); + std::env::set_var(key, value); + } + Self { values } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, previous) in self.values.drain(..) { + if let Some(value) = previous { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } + } + } + } + + fn setup_test_db() -> DbConnection { + let conn = Connection::open_in_memory().expect("open in memory db"); + create_tables(&conn).expect("create tables"); + Arc::new(Mutex::new(conn)) + } // ==================== Property 3: 不健康凭证排除 ==================== // Feature: antigravity-token-refresh, Property 3: 不健康凭证排除 @@ -2251,4 +2339,65 @@ mod tests { let urls = ProviderPoolService::build_openai_health_check_urls(None); assert_eq!(urls[0], "https://api.openai.com/v1/chat/completions"); } + + #[test] + fn select_credential_should_auto_recover_antigravity_from_default_path() { + let _guard = env_lock().lock().expect("env lock"); + let temp = tempdir().expect("create tempdir"); + let _env = EnvGuard::set(&[("HOME", temp.path().as_os_str().to_os_string())]); + let default_path = temp.path().join(".antigravity").join("oauth_creds.json"); + std::fs::create_dir_all(default_path.parent().expect("default parent")) + .expect("create default dir"); + std::fs::write( + &default_path, + r#"{"access_token":"fallback_token","refresh_token":"refresh","project_id":"fallback-project"}"#, + ) + .expect("write default creds"); + + let db = setup_test_db(); + let service = ProviderPoolService::new(); + let inserted = service + .add_credential( + &db, + "antigravity", + CredentialData::AntigravityOAuth { + creds_file_path: temp + .path() + .join("missing.json") + .to_string_lossy() + .to_string(), + project_id: Some("fallback-project".to_string()), + }, + Some("Recovered Antigravity".to_string()), + Some(true), + None, + ) + .expect("insert credential"); + + for _ in 0..3 { + service + .mark_unhealthy( + &db, + &inserted.uuid, + Some("Failed to load credentials: No such file or directory (os error 2)"), + ) + .expect("mark unhealthy"); + } + + let selected = service + .select_credential(&db, "antigravity", Some("gemini-3-pro-image-preview")) + .expect("select credential") + .expect("recovered credential should be selectable"); + + assert_eq!(selected.uuid, inserted.uuid); + assert!(selected.is_healthy); + + let recovered = service + .get_by_uuid(&db, &inserted.uuid) + .expect("query credential") + .expect("credential should exist"); + assert!(recovered.is_healthy); + assert_eq!(recovered.error_count, 0); + assert!(recovered.last_error_message.is_none()); + } } diff --git a/src-tauri/resources/default-skills/broadcast_generate/SKILL.md b/src-tauri/resources/default-skills/broadcast_generate/SKILL.md index 66f48f390..0c75a91e2 100644 --- a/src-tauri/resources/default-skills/broadcast_generate/SKILL.md +++ b/src-tauri/resources/default-skills/broadcast_generate/SKILL.md @@ -1,7 +1,7 @@ --- name: broadcast_generate description: 将文章整理为可转播客音频的源文本(下游负责真实音频合成)。 -allowed-tools: lime_create_broadcast_generation_task +allowed-tools: Bash, lime_create_broadcast_generation_task metadata: lime_argument_hint: 输入原文、目标听众、语气、预计时长、重点段落。 lime_when_to_use: 用户希望把现有文稿转成播客内容,但不要求你直接写主持稿。 @@ -22,7 +22,8 @@ metadata: - 保留原文核心观点与证据,不随意新增事实。 - 清理不利于朗读的内容(超长句、无意义链接堆叠、重复段)。 - 输出的是“可播报文本材料”,不是完整主持人口播脚本。 -- 必须调用 `lime_create_broadcast_generation_task` 创建任务。 +- 优先调用 `Bash` 执行 `lime task create broadcast --json` 创建任务。 +- 若当前环境暂时无法执行 `lime` CLI,再回退到 `lime_create_broadcast_generation_task`。 - `payload` 中至少包含:`title`、`audience`、`tone`、`durationHintMinutes`、`content`。 ## 输出格式(固定) diff --git a/src-tauri/resources/default-skills/cover_generate/SKILL.md b/src-tauri/resources/default-skills/cover_generate/SKILL.md index 20c832a50..59d5e0454 100644 --- a/src-tauri/resources/default-skills/cover_generate/SKILL.md +++ b/src-tauri/resources/default-skills/cover_generate/SKILL.md @@ -1,11 +1,11 @@ --- name: cover_generate description: 为文章或视频生成平台封面图,并写回主稿(封面场景优先使用本技能)。 -allowed-tools: social_generate_cover_image, lime_create_cover_generation_task +allowed-tools: social_generate_cover_image, Bash, lime_create_cover_generation_task metadata: lime_argument_hint: 输入平台、标题、受众、视觉风格、尺寸要求。 lime_when_to_use: 用户明确要求“封面图”时使用,不要被普通配图任务替代。 - lime_version: 1.1.0 + lime_version: 1.3.0 lime_execution_mode: prompt lime_surface: workbench lime_category: media @@ -23,8 +23,10 @@ metadata: - 根据平台特性控制视觉:主体清晰、构图简洁、避免密集小字。 - 默认尺寸 `1024x1024`,用户指定时优先按用户要求。 - 使用 `social_generate_cover_image` 生成封面。 -- 生成后必须调用 `lime_create_cover_generation_task` 创建任务。 -- 工具失败时不能中断:保留占位、给出重试建议并提交失败任务记录。 +- 生成成功后,优先调用 `Bash` 执行 `lime task create cover --json` 创建任务,并把 `social_generate_cover_image` 返回的 `image_url` 作为 `--image-url` 传入。 +- 若当前环境暂时无法执行 `lime` CLI,再回退到 `lime_create_cover_generation_task`。 +- 任务结果必须兼容 `lime task create cover --json` 的任务文件契约。 +- 封面生成失败时不能中断:保留占位、给出重试建议,并使用 `lime_create_cover_generation_task` 提交失败任务记录。 ## 输出格式(固定) diff --git a/src-tauri/resources/default-skills/image_generate/SKILL.md b/src-tauri/resources/default-skills/image_generate/SKILL.md index ba52e34dc..519823d1d 100644 --- a/src-tauri/resources/default-skills/image_generate/SKILL.md +++ b/src-tauri/resources/default-skills/image_generate/SKILL.md @@ -1,11 +1,11 @@ --- name: image_generate description: 根据文本描述生成配图素材(非封面场景)。 -allowed-tools: lime_create_image_generation_task +allowed-tools: Bash, lime_create_image_generation_task metadata: lime_argument_hint: 输入主题、画面主体、风格、构图、数量、尺寸。 lime_when_to_use: 用户需要普通配图、插图或概念图时使用;封面需求优先交给 cover_generate。 - lime_version: 1.1.0 + lime_version: 1.3.0 lime_execution_mode: prompt lime_surface: workbench lime_category: media @@ -22,7 +22,9 @@ metadata: - 先判断是否属于封面需求;封面需求请转 `cover_generate`。 - 提示词必须包含主体、场景、风格,不要空泛。 - 若用户给了参考素材,需体现在参数中。 -- 必须调用 `lime_create_image_generation_task` 创建任务。 +- 优先调用 `Bash` 执行 `lime task create image --json` 创建任务。 +- 若当前环境暂时无法执行 `lime` CLI,再回退到 `lime_create_image_generation_task`。 +- 任务结果必须兼容 `lime task create image --json` 的任务文件契约。 - `payload` 中至少包含:`prompt`、`style`、`size`、`count`、`usage`。 ## 输出格式(固定) diff --git a/src-tauri/resources/default-skills/modal_resource_search/SKILL.md b/src-tauri/resources/default-skills/modal_resource_search/SKILL.md index 275651c8c..bb6782723 100644 --- a/src-tauri/resources/default-skills/modal_resource_search/SKILL.md +++ b/src-tauri/resources/default-skills/modal_resource_search/SKILL.md @@ -1,7 +1,7 @@ --- name: modal_resource_search description: 提交资源检索任务(图片、背景音乐、音效等),供前端资源面板消费。 -allowed-tools: lime_create_modal_resource_search_task +allowed-tools: Bash, lime_create_modal_resource_search_task metadata: lime_argument_hint: 输入资源类型、关键词、风格、用途、数量与限制条件。 lime_when_to_use: 用户需要为当前内容补充外部素材资源时使用。 @@ -22,7 +22,8 @@ metadata: - 先明确资源类型(图片/BGM/音效)和使用场景。 - 检索关键词控制在 1-3 个核心词,避免长句。 - 优先给出高相关候选,不要堆无关结果。 -- 必须调用 `lime_create_modal_resource_search_task` 创建任务。 +- 优先调用 `Bash` 执行 `lime task create resource-search --json` 创建任务。 +- 若当前环境暂时无法执行 `lime` CLI,再回退到 `lime_create_modal_resource_search_task`。 - `payload` 中至少包含:`resourceType`、`query`、`usage`、`count`。 ## 输出格式(固定) diff --git a/src-tauri/resources/default-skills/typesetting/SKILL.md b/src-tauri/resources/default-skills/typesetting/SKILL.md index df1a435f3..3e09eda45 100644 --- a/src-tauri/resources/default-skills/typesetting/SKILL.md +++ b/src-tauri/resources/default-skills/typesetting/SKILL.md @@ -1,7 +1,7 @@ --- name: typesetting description: 优化文稿排版与可读性,不改变原始事实与核心表达。 -allowed-tools: lime_create_typesetting_task +allowed-tools: Bash, lime_create_typesetting_task metadata: lime_argument_hint: 输入目标平台、语气要求、段落长度偏好、标题层级规范。 lime_when_to_use: 用户希望提升文本可读性、结构清晰度、发布观感时使用。 @@ -23,7 +23,8 @@ metadata: - 不改变原文立场,仅做结构化与可读性优化。 - 控制段落长度,优先移动端阅读体验。 - 标题层级清晰,列表格式统一。 -- 必须调用 `lime_create_typesetting_task` 创建任务。 +- 优先调用 `Bash` 执行 `lime task create typesetting --json` 创建任务。 +- 若当前环境暂时无法执行 `lime` CLI,再回退到 `lime_create_typesetting_task`。 - `payload` 中至少包含:`targetPlatform`、`rules`、`content`。 ## 输出格式(固定) diff --git a/src-tauri/resources/default-skills/url_parse/SKILL.md b/src-tauri/resources/default-skills/url_parse/SKILL.md index 35f00fa46..f7b51ad5b 100644 --- a/src-tauri/resources/default-skills/url_parse/SKILL.md +++ b/src-tauri/resources/default-skills/url_parse/SKILL.md @@ -1,7 +1,7 @@ --- name: url_parse description: 解析外部 URL 内容,并沉淀为可阅读的文本结果。 -allowed-tools: lime_create_url_parse_task +allowed-tools: Bash, lime_create_url_parse_task metadata: lime_argument_hint: 输入 URL、抽取目标(摘要/要点/全文清洗)、输出格式要求。 lime_when_to_use: 用户提供链接并希望抽取正文、要点或可引用信息时使用。 @@ -22,7 +22,8 @@ metadata: - 先校验 URL 是否完整可读;不完整时先提示补全。 - 若当前会话存在可用抓取工具,则优先工具抓取;否则明确降级为“基于用户提供内容整理”。 - 提炼时区分“原文信息”与“你的归纳”,避免混淆。 -- 必须调用 `lime_create_url_parse_task` 创建任务。 +- 优先调用 `Bash` 执行 `lime task create url-parse --json` 创建任务。 +- 若当前环境暂时无法执行 `lime` CLI,再回退到 `lime_create_url_parse_task`。 - `payload` 中至少包含:`url`、`summary`、`keyPoints`、`extractStatus`。 ## 输出格式(固定) diff --git a/src-tauri/resources/default-skills/video_generate/SKILL.md b/src-tauri/resources/default-skills/video_generate/SKILL.md index 79e525002..0e712bdc8 100644 --- a/src-tauri/resources/default-skills/video_generate/SKILL.md +++ b/src-tauri/resources/default-skills/video_generate/SKILL.md @@ -1,11 +1,11 @@ --- name: video_generate description: 提交视频生成任务,并触发前端视频生成流程。 -allowed-tools: lime_create_video_generation_task +allowed-tools: Bash, lime_create_video_generation_task metadata: lime_argument_hint: 输入主题、受众、平台、时长、画幅、风格、素材来源。 lime_when_to_use: 用户要求生成视频,或将现有文稿改编为短视频。 - lime_version: 1.1.0 + lime_version: 1.2.0 lime_execution_mode: prompt lime_surface: workbench lime_category: media @@ -22,8 +22,10 @@ metadata: - 先吸收用户输入、当前会话上下文、已有文稿与素材引用。 - 上下文不足时,最多补问 1 个关键问题(例如时长或画幅)。 - 输出聚焦“镜头意图 + 生成参数”,不要写成长文。 -- 必须调用 `lime_create_video_generation_task` 创建真实任务。 +- 优先调用 `Bash` 执行 `lime task create video --json` 创建真实任务。 +- 若当前环境暂时无法执行 `lime` CLI,再回退到 `lime_create_video_generation_task`。 - `projectId` 必须来自当前工作区项目;不要虚构 providerId/model。 +- 任务结果必须兼容 `lime task create video --json` 的任务文件契约。 - 禁止伪造“视频已生成完成”。 ## 输出格式(固定) diff --git a/src-tauri/src/agent/README.md b/src-tauri/src/agent/README.md index bca2f6500..0e5e2a5b8 100644 --- a/src-tauri/src/agent/README.md +++ b/src-tauri/src/agent/README.md @@ -101,7 +101,7 @@ let stream = agent.reply(user_message, session_config, Some(cancel_token)).await | `agent_runtime_submit_turn` | 统一提交 turn | | `agent_runtime_interrupt_turn` | 统一中断 turn | | `agent_runtime_create/list/get/update/delete_session` | 统一会话管理 | -| `agent_runtime_spawn/send_input/wait/resume/close_subagent` | subagent 控制面 | +| `agent_runtime_spawn_subagent / agent_runtime_send_subagent_input / agent_runtime_wait_subagents / agent_runtime_resume_subagent / agent_runtime_close_subagent` | subagent 控制面 | | `agent_runtime_respond_action` | 统一响应工具确认 / ask / elicitation | ## 凭证池桥接 diff --git a/src-tauri/src/agent_tools/catalog.rs b/src-tauri/src/agent_tools/catalog.rs index af9609368..189fbb21d 100644 --- a/src-tauri/src/agent_tools/catalog.rs +++ b/src-tauri/src/agent_tools/catalog.rs @@ -135,13 +135,13 @@ const SKILL_CAP: &[ToolCapability] = &[ToolCapability::SkillExecution]; const CONTENT_CAP: &[ToolCapability] = &[ToolCapability::ContentCreation]; const BROWSER_CAP: &[ToolCapability] = &[ToolCapability::BrowserRuntime]; const SITE_CAP: &[ToolCapability] = &[ToolCapability::BrowserRuntime, ToolCapability::WebSearch]; +const SESSION_CAP: &[ToolCapability] = &[ToolCapability::SessionControl]; const WORKSPACE_IO_CAP: &[ToolCapability] = &[ToolCapability::WorkspaceIo]; const EXECUTION_CAP: &[ToolCapability] = &[ToolCapability::Execution]; -const VISION_CAP: &[ToolCapability] = &[ToolCapability::Vision]; static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ ToolCatalogEntry { - name: "read", + name: "Read", profiles: CORE_PROFILES, capabilities: WORKSPACE_IO_CAP, lifecycle: ToolLifecycle::Current, @@ -150,7 +150,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ workspace_default_allow: false, }, ToolCatalogEntry { - name: "write", + name: "Write", profiles: CORE_PROFILES, capabilities: WORKSPACE_IO_CAP, lifecycle: ToolLifecycle::Current, @@ -159,7 +159,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ workspace_default_allow: false, }, ToolCatalogEntry { - name: "edit", + name: "Edit", profiles: CORE_PROFILES, capabilities: WORKSPACE_IO_CAP, lifecycle: ToolLifecycle::Current, @@ -168,7 +168,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ workspace_default_allow: false, }, ToolCatalogEntry { - name: "glob", + name: "Glob", profiles: CORE_PROFILES, capabilities: WORKSPACE_IO_CAP, lifecycle: ToolLifecycle::Current, @@ -177,7 +177,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ workspace_default_allow: false, }, ToolCatalogEntry { - name: "grep", + name: "Grep", profiles: CORE_PROFILES, capabilities: WORKSPACE_IO_CAP, lifecycle: ToolLifecycle::Current, @@ -186,7 +186,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ workspace_default_allow: false, }, ToolCatalogEntry { - name: "bash", + name: "Bash", profiles: CORE_PROFILES, capabilities: EXECUTION_CAP, lifecycle: ToolLifecycle::Current, @@ -195,7 +195,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ workspace_default_allow: false, }, ToolCatalogEntry { - name: "lsp", + name: "LSP", profiles: CORE_PROFILES, capabilities: WORKSPACE_IO_CAP, lifecycle: ToolLifecycle::Current, @@ -212,6 +212,15 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ permission_plane: ToolPermissionPlane::SessionAllowlist, workspace_default_allow: true, }, + ToolCatalogEntry { + name: "Workflow", + profiles: CORE_PROFILES, + capabilities: EXECUTION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, ToolCatalogEntry { name: "TaskCreate", profiles: CORE_PROFILES, @@ -293,6 +302,24 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ permission_plane: ToolPermissionPlane::SessionAllowlist, workspace_default_allow: true, }, + ToolCatalogEntry { + name: "EnterWorktree", + profiles: CORE_PROFILES, + capabilities: WORKSPACE_IO_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "ExitWorktree", + profiles: CORE_PROFILES, + capabilities: WORKSPACE_IO_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, ToolCatalogEntry { name: "WebFetch", profiles: CORE_PROFILES, @@ -312,16 +339,88 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ workspace_default_allow: true, }, ToolCatalogEntry { - name: "analyze_image", + name: "AskUserQuestion", profiles: CORE_PROFILES, - capabilities: VISION_CAP, + capabilities: PLAN_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "SendUserMessage", + profiles: CORE_PROFILES, + capabilities: SESSION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "StructuredOutput", + profiles: CORE_PROFILES, + capabilities: SESSION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: false, + }, + ToolCatalogEntry { + name: "Config", + profiles: CORE_PROFILES, + capabilities: SESSION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "Sleep", + profiles: CORE_PROFILES, + capabilities: EXECUTION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "PowerShell", + profiles: CORE_PROFILES, + capabilities: EXECUTION_CAP, lifecycle: ToolLifecycle::Current, source: ToolSourceKind::AsterBuiltin, permission_plane: ToolPermissionPlane::ParameterRestricted, workspace_default_allow: false, }, ToolCatalogEntry { - name: "ask", + name: "RemoteTrigger", + profiles: CORE_PROFILES, + capabilities: EXECUTION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "CronCreate", + profiles: CORE_PROFILES, + capabilities: PLAN_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "CronList", + profiles: CORE_PROFILES, + capabilities: PLAN_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "CronDelete", profiles: CORE_PROFILES, capabilities: PLAN_CAP, lifecycle: ToolLifecycle::Current, @@ -357,7 +456,7 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ workspace_default_allow: true, }, ToolCatalogEntry { - name: "spawn_agent", + name: "Agent", profiles: CORE_PROFILES, capabilities: DELEGATION_CAP, lifecycle: ToolLifecycle::Current, @@ -366,38 +465,38 @@ static NATIVE_TOOL_CATALOG: &[ToolCatalogEntry] = &[ workspace_default_allow: true, }, ToolCatalogEntry { - name: "send_input", + name: "SendMessage", profiles: CORE_PROFILES, capabilities: DELEGATION_CAP, lifecycle: ToolLifecycle::Current, - source: ToolSourceKind::LimeInjected, + source: ToolSourceKind::AsterBuiltin, permission_plane: ToolPermissionPlane::SessionAllowlist, workspace_default_allow: true, }, ToolCatalogEntry { - name: "wait_agent", + name: "TeamCreate", profiles: CORE_PROFILES, capabilities: DELEGATION_CAP, lifecycle: ToolLifecycle::Current, - source: ToolSourceKind::LimeInjected, + source: ToolSourceKind::AsterBuiltin, permission_plane: ToolPermissionPlane::SessionAllowlist, workspace_default_allow: true, }, ToolCatalogEntry { - name: "resume_agent", + name: "TeamDelete", profiles: CORE_PROFILES, capabilities: DELEGATION_CAP, lifecycle: ToolLifecycle::Current, - source: ToolSourceKind::LimeInjected, + source: ToolSourceKind::AsterBuiltin, permission_plane: ToolPermissionPlane::SessionAllowlist, workspace_default_allow: true, }, ToolCatalogEntry { - name: "close_agent", + name: "ListPeers", profiles: CORE_PROFILES, capabilities: DELEGATION_CAP, lifecycle: ToolLifecycle::Current, - source: ToolSourceKind::LimeInjected, + source: ToolSourceKind::AsterBuiltin, permission_plane: ToolPermissionPlane::SessionAllowlist, workspace_default_allow: true, }, @@ -542,15 +641,91 @@ pub fn native_tool_catalog() -> &'static [ToolCatalogEntry] { NATIVE_TOOL_CATALOG } +fn normalize_tool_catalog_alias(tool_name: &str) -> &str { + match tool_catalog_reference_lookup_key(tool_name).as_str() { + "ask" | "requestuserinput" | "askuserquestiontool" => "AskUserQuestion", + "brief" | "brieftool" | "sendusermessagetool" => "SendUserMessage", + "spawnagent" | "subagenttask" | "agenttool" => "Agent", + "sendinput" | "sendmessagetool" => "SendMessage", + "bashtool" => "Bash", + "configtool" => "Config", + "enterplanmodetool" => "EnterPlanMode", + "exitplanmodetool" => "ExitPlanMode", + "enterworktreetool" => "EnterWorktree", + "exitworktreetool" => "ExitWorktree", + "filereadtool" | "readfiletool" => "Read", + "filewritetool" | "writefiletool" | "createfiletool" => "Write", + "fileedittool" => "Edit", + "globtool" => "Glob", + "greptool" => "Grep", + "lsptool" => "LSP", + "listmcpresourcestool" => "ListMcpResourcesTool", + "readmcpresourcetool" => "ReadMcpResourceTool", + "notebookedittool" => "NotebookEdit", + "powershelltool" => "PowerShell", + "remotetriggertool" => "RemoteTrigger", + "schedulecrontool" | "croncreatetool" => "CronCreate", + "cronlisttool" => "CronList", + "crondeletetool" => "CronDelete", + "skilltool" => "Skill", + "sleeptool" => "Sleep", + "syntheticoutputtool" => "StructuredOutput", + "taskcreatetool" => "TaskCreate", + "taskgettool" => "TaskGet", + "tasklisttool" => "TaskList", + "taskoutputtool" | "agentoutputtool" | "bashoutputtool" => "TaskOutput", + "taskstoptool" => "TaskStop", + "taskupdatetool" => "TaskUpdate", + "teamcreatetool" => "TeamCreate", + "teamdeletetool" => "TeamDelete", + "toolsearchtool" => "ToolSearch", + "webfetchtool" => "WebFetch", + "websearchtool" => "WebSearch", + _ => tool_name.trim(), + } +} + +fn tool_catalog_reference_lookup_key(tool_name: &str) -> String { + tool_name + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .map(|character| character.to_ascii_lowercase()) + .collect() +} + +fn tool_catalog_lookup_key(tool_name: &str) -> String { + normalize_tool_catalog_alias(tool_name) + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .map(|character| character.to_ascii_lowercase()) + .collect() +} + +pub fn tool_catalog_names_match(left: &str, right: &str) -> bool { + let left_key = tool_catalog_lookup_key(left); + let right_key = tool_catalog_lookup_key(right); + !left_key.is_empty() && left_key == right_key +} + pub fn tool_catalog_entry(tool_name: &str) -> Option<&'static ToolCatalogEntry> { - let normalized_name = tool_name.trim(); + let requested_name = tool_name.trim(); + let canonical_name = normalize_tool_catalog_alias(requested_name); + if let Some(entry) = native_tool_catalog() + .iter() + .find(|entry| entry.name == canonical_name) + { + return Some(entry); + } + let normalized_key = tool_catalog_lookup_key(canonical_name); native_tool_catalog() .iter() .filter(|entry| { if entry.name.ends_with("__") { - normalized_name.starts_with(entry.name) + requested_name.starts_with(entry.name) + || (!normalized_key.is_empty() + && normalized_key.starts_with(&tool_catalog_lookup_key(entry.name))) } else { - entry.name == normalized_name + tool_catalog_names_match(entry.name, canonical_name) } }) .max_by_key(|entry| entry.name.len()) @@ -600,6 +775,17 @@ pub fn browser_runtime_tool_prefix() -> &'static str { BROWSER_RUNTIME_TOOL_PREFIX } +pub fn mcp_extension_runtime_name(server_name: &str) -> String { + format!("mcp__{server_name}") +} + +fn mcp_extension_inner_tool_name<'a>(extension_name: &str, tool_name: &'a str) -> &'a str { + tool_name + .strip_prefix(extension_name) + .and_then(|rest| rest.strip_prefix("__")) + .unwrap_or(tool_name) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct McpExtensionSurface { pub extension_name: String, @@ -623,7 +809,7 @@ pub fn build_mcp_extension_surface( ) -> McpExtensionSurface { let mut available_tools = tools .iter() - .map(|tool| tool.name.clone()) + .map(|tool| mcp_extension_inner_tool_name(extension_name, &tool.name).to_string()) .collect::>(); available_tools.sort(); available_tools.dedup(); @@ -633,7 +819,7 @@ pub fn build_mcp_extension_surface( .filter(|tool| { tool.always_visible.unwrap_or(false) || !tool.deferred_loading.unwrap_or(false) }) - .map(|tool| tool.name.clone()) + .map(|tool| mcp_extension_inner_tool_name(extension_name, &tool.name).to_string()) .collect::>(); always_expose_tools.sort(); always_expose_tools.dedup(); @@ -715,14 +901,115 @@ mod tests { #[test] fn test_workspace_default_allowed_tool_names_excludes_parameter_restricted_tools() { let names = workspace_default_allowed_tool_names(WorkspaceToolSurface::core()); - assert!(names.contains(&"spawn_agent")); + assert!(names.contains(&"Agent")); + assert!(names.contains(&"SendUserMessage")); + assert!(names.contains(&"TeamCreate")); + assert!(names.contains(&"TeamDelete")); + assert!(names.contains(&"Workflow")); assert!(names.contains(&"WebSearch")); assert!(!names.contains(&"SubAgentTask")); - assert!(!names.contains(&"read")); - assert!(!names.contains(&"bash")); + assert!(!names.contains(&"Read")); + assert!(!names.contains(&"Bash")); assert!(!names.contains(&SOCIAL_IMAGE_TOOL_NAME)); } + #[test] + fn test_tool_catalog_entry_normalizes_legacy_aliases_to_current_surface() { + assert_eq!( + tool_catalog_entry("spawn_agent") + .expect("legacy spawn_agent should normalize") + .name, + "Agent" + ); + assert_eq!( + tool_catalog_entry("brief") + .expect("legacy brief should normalize") + .name, + "SendUserMessage" + ); + assert_eq!( + tool_catalog_entry("send_input") + .expect("legacy send_input should normalize") + .name, + "SendMessage" + ); + assert_eq!( + tool_catalog_entry("ask") + .expect("legacy ask should normalize") + .name, + "AskUserQuestion" + ); + assert_eq!( + tool_catalog_entry("remote_trigger") + .expect("snake_case current name should normalize") + .name, + "RemoteTrigger" + ); + } + + #[test] + fn test_tool_catalog_entry_normalizes_reference_js_tool_names_to_current_surface() { + let cases = [ + ("AgentTool", "Agent"), + ("AskUserQuestionTool", "AskUserQuestion"), + ("BashTool", "Bash"), + ("BriefTool", "SendUserMessage"), + ("ConfigTool", "Config"), + ("EnterPlanModeTool", "EnterPlanMode"), + ("EnterWorktreeTool", "EnterWorktree"), + ("ExitPlanModeTool", "ExitPlanMode"), + ("ExitWorktreeTool", "ExitWorktree"), + ("FileEditTool", "Edit"), + ("FileReadTool", "Read"), + ("FileWriteTool", "Write"), + ("GlobTool", "Glob"), + ("GrepTool", "Grep"), + ("LSPTool", "LSP"), + ("ListMcpResourcesTool", "ListMcpResourcesTool"), + ("NotebookEditTool", "NotebookEdit"), + ("PowerShellTool", "PowerShell"), + ("ReadMcpResourceTool", "ReadMcpResourceTool"), + ("RemoteTriggerTool", "RemoteTrigger"), + ("ScheduleCronTool", "CronCreate"), + ("SendMessageTool", "SendMessage"), + ("SkillTool", "Skill"), + ("SleepTool", "Sleep"), + ("SyntheticOutputTool", "StructuredOutput"), + ("TaskCreateTool", "TaskCreate"), + ("TaskGetTool", "TaskGet"), + ("TaskListTool", "TaskList"), + ("TaskOutputTool", "TaskOutput"), + ("AgentOutputTool", "TaskOutput"), + ("BashOutputTool", "TaskOutput"), + ("TaskStopTool", "TaskStop"), + ("TaskUpdateTool", "TaskUpdate"), + ("TeamCreateTool", "TeamCreate"), + ("TeamDeleteTool", "TeamDelete"), + ("ToolSearchTool", "ToolSearch"), + ("WebFetchTool", "WebFetch"), + ("WebSearchTool", "WebSearch"), + ]; + + for (input, expected) in cases { + assert_eq!( + tool_catalog_entry(input) + .unwrap_or_else(|| panic!("reference tool '{input}' should normalize")) + .name, + expected + ); + } + } + + #[test] + fn test_tool_catalog_entry_leaves_intentional_reference_exceptions_unmapped() { + for name in ["MCPTool", "McpAuthTool", "REPLTool"] { + assert!( + tool_catalog_entry(name).is_none(), + "reference exception '{name}' should stay outside current catalog" + ); + } + } + #[test] fn test_workspace_default_allowed_tool_names_includes_workbench_surface() { let names = workspace_default_allowed_tool_names(WorkspaceToolSurface::workbench()); @@ -741,12 +1028,12 @@ mod tests { .iter() .filter(|entry| entry.profiles.contains(&ToolSurfaceProfile::BrowserAssist)) .count(); - assert_eq!(core.len(), 30); + assert_eq!(core.len(), 41); assert_eq!( core.iter() .filter(|entry| entry.lifecycle == ToolLifecycle::Current) .count(), - 29 + 40 ); assert_eq!( core.iter() @@ -800,11 +1087,14 @@ mod tests { let names = workspace_default_allowed_tool_names( WorkspaceToolSurface::workbench_with_browser_assist(), ); - assert_eq!(names.len(), 32); + assert_eq!(names.len(), 42); assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME)); assert!(names.contains(&TOOL_SEARCH_TOOL_NAME)); assert!(names.contains(&LIST_MCP_RESOURCES_TOOL_NAME)); assert!(names.contains(&READ_MCP_RESOURCE_TOOL_NAME)); + assert!(names.contains(&"SendUserMessage")); + assert!(names.contains(&"TeamCreate")); + assert!(names.contains(&"TeamDelete")); assert!(names.contains(&LIME_SITE_RECOMMEND_TOOL_NAME)); assert!(names.contains(&LIME_SITE_RUN_TOOL_NAME)); assert!(!names @@ -907,4 +1197,29 @@ mod tests { let surface = build_mcp_extension_surface("docs", "docs tools", &tools); assert_eq!(surface.allowed_caller, None); } + + #[test] + fn test_build_mcp_extension_surface_strips_runtime_prefix_from_prefixed_tools() { + let tools = vec![ + sample_tool( + "mcp__docs__search_docs", + Some(true), + Some(false), + Some(vec!["assistant"]), + ), + sample_tool( + "mcp__docs__read_docs", + Some(false), + Some(true), + Some(vec!["assistant"]), + ), + ]; + + let surface = build_mcp_extension_surface("mcp__docs", "docs tools", &tools); + assert_eq!( + surface.available_tools, + vec!["read_docs".to_string(), "search_docs".to_string()] + ); + assert_eq!(surface.always_expose_tools, vec!["read_docs".to_string()]); + } } diff --git a/src-tauri/src/agent_tools/execution.rs b/src-tauri/src/agent_tools/execution.rs index 983576bc4..f56cef25d 100644 --- a/src-tauri/src/agent_tools/execution.rs +++ b/src-tauri/src/agent_tools/execution.rs @@ -1,6 +1,6 @@ use crate::agent_tools::catalog::{ - tool_catalog_entries_for_surface, tool_catalog_entry, workspace_default_allowed_tool_names, - ToolPermissionPlane, WorkspaceToolSurface, + tool_catalog_entries_for_surface, tool_catalog_entry, tool_catalog_names_match, + workspace_default_allowed_tool_names, ToolPermissionPlane, WorkspaceToolSurface, }; use aster::permission::{ParameterRestriction, PermissionScope, RestrictionType, ToolPermission}; use lime_core::config::{ @@ -113,15 +113,15 @@ pub fn tool_execution_policy(tool_name: &str) -> ToolExecutionPolicy { }; match catalog_entry.name { - "read" | "write" | "edit" | "lsp" => ToolExecutionPolicy { + "Read" | "Write" | "Edit" | "LSP" => ToolExecutionPolicy { restriction_profile: ToolExecutionRestrictionProfile::WorkspacePathRequired, ..ToolExecutionPolicy::default() }, - "glob" | "grep" => ToolExecutionPolicy { + "Glob" | "Grep" => ToolExecutionPolicy { restriction_profile: ToolExecutionRestrictionProfile::WorkspacePathOptional, ..ToolExecutionPolicy::default() }, - "bash" => ToolExecutionPolicy { + "Bash" | "PowerShell" => ToolExecutionPolicy { warning_policy: ToolExecutionWarningPolicy::ShellCommandRisk, restriction_profile: ToolExecutionRestrictionProfile::WorkspaceShellCommand, sandbox_profile: ToolExecutionSandboxProfile::WorkspaceCommand, @@ -343,18 +343,11 @@ fn find_case_insensitive_object<'a>( key: &str, ) -> Option<&'a JsonMap> { let normalized_key = key.trim(); - object - .get(normalized_key) - .and_then(JsonValue::as_object) - .or_else(|| { - object.iter().find_map(|(candidate, value)| { - candidate - .trim() - .eq_ignore_ascii_case(normalized_key) - .then_some(value) - .and_then(JsonValue::as_object) - }) - }) + object.iter().find_map(|(candidate, value)| { + tool_catalog_names_match(candidate, normalized_key) + .then_some(value) + .and_then(JsonValue::as_object) + }) } fn find_tool_override_config<'a>( @@ -362,16 +355,11 @@ fn find_tool_override_config<'a>( tool_name: &str, ) -> Option<&'a ConfigToolExecutionOverrideConfig> { let normalized_name = tool_name.trim(); - tool_overrides.get(normalized_name).or_else(|| { - tool_overrides - .iter() - .find_map(|(candidate, override_config)| { - candidate - .trim() - .eq_ignore_ascii_case(normalized_name) - .then_some(override_config) - }) - }) + tool_overrides + .iter() + .find_map(|(candidate, override_config)| { + tool_catalog_names_match(candidate, normalized_name).then_some(override_config) + }) } fn extract_named_string<'a>( @@ -629,8 +617,8 @@ fn pattern_restriction( fn permission_priority(tool_name: &str) -> i32 { match tool_name { - "read" | "write" | "edit" | "glob" | "grep" => 100, - "bash" => 90, + "Read" | "Write" | "Edit" | "Glob" | "Grep" => 100, + "Bash" | "PowerShell" => 90, _ => 88, } } @@ -699,7 +687,7 @@ mod tests { #[test] fn test_tool_execution_policy_marks_bash_as_sandboxed_shell_risk() { - let policy = tool_execution_policy("bash"); + let policy = tool_execution_policy("Bash"); assert_eq!( policy.warning_policy, ToolExecutionWarningPolicy::ShellCommandRisk @@ -726,8 +714,8 @@ mod tests { let read = permissions .iter() - .find(|permission| permission.tool == "read") - .expect("read permission should exist"); + .find(|permission| permission.tool == "Read") + .expect("Read permission should exist"); assert_eq!(read.parameter_restrictions.len(), 1); assert_eq!(read.parameter_restrictions[0].parameter, "path"); assert!(read.parameter_restrictions[0] @@ -738,8 +726,8 @@ mod tests { let bash = permissions .iter() - .find(|permission| permission.tool == "bash") - .expect("bash permission should exist"); + .find(|permission| permission.tool == "Bash") + .expect("Bash permission should exist"); assert_eq!(bash.parameter_restrictions.len(), 2); assert!(permissions .iter() @@ -761,8 +749,8 @@ mod tests { let bash = permissions .iter() - .find(|permission| permission.tool == "bash") - .expect("bash permission should exist"); + .find(|permission| permission.tool == "Bash") + .expect("Bash permission should exist"); assert!(bash.parameter_restrictions.is_empty()); assert!(permissions .iter() @@ -773,9 +761,9 @@ mod tests { fn test_should_auto_approve_tool_warnings_only_for_shell_risk_tools() { let input = ToolExecutionResolverInput::default(); - assert!(should_auto_approve_tool_warnings("bash", true, input)); - assert!(!should_auto_approve_tool_warnings("read", true, input)); - assert!(!should_auto_approve_tool_warnings("bash", false, input)); + assert!(should_auto_approve_tool_warnings("Bash", true, input)); + assert!(!should_auto_approve_tool_warnings("Read", true, input)); + assert!(!should_auto_approve_tool_warnings("Bash", false, input)); } #[test] @@ -803,7 +791,7 @@ mod tests { }; let policy = resolve_tool_execution_policy( - "bash", + "Bash", ToolExecutionResolverInput { persisted_policy: Some(&persisted_policy), request_metadata: None, @@ -847,7 +835,7 @@ mod tests { }); let policy = resolve_tool_execution_policy( - "bash", + "Bash", ToolExecutionResolverInput { persisted_policy: Some(&persisted_policy), request_metadata: Some(&request_metadata), @@ -893,7 +881,7 @@ mod tests { }); let resolution = resolve_tool_execution_policy_resolution( - "bash", + "Bash", ToolExecutionResolverInput { persisted_policy: Some(&persisted_policy), request_metadata: Some(&request_metadata), @@ -953,8 +941,8 @@ mod tests { let bash = permissions .iter() - .find(|permission| permission.tool == "bash") - .expect("bash permission should exist"); + .find(|permission| permission.tool == "Bash") + .expect("Bash permission should exist"); assert!(bash.parameter_restrictions.is_empty()); } } diff --git a/src-tauri/src/agent_tools/inventory.rs b/src-tauri/src/agent_tools/inventory.rs index 2f9f90c42..2fa8099a1 100644 --- a/src-tauri/src/agent_tools/inventory.rs +++ b/src-tauri/src/agent_tools/inventory.rs @@ -1,7 +1,7 @@ use crate::agent_tools::catalog::{ - tool_catalog_entries_for_surface, tool_catalog_entry, workspace_default_allowed_tool_names, - ToolCapability, ToolLifecycle, ToolPermissionPlane, ToolSourceKind, ToolSurfaceProfile, - WorkspaceToolSurface, BROWSER_RUNTIME_TOOL_PREFIX, + mcp_extension_runtime_name, tool_catalog_entries_for_surface, tool_catalog_entry, + workspace_default_allowed_tool_names, ToolCapability, ToolLifecycle, ToolPermissionPlane, + ToolSourceKind, ToolSurfaceProfile, WorkspaceToolSurface, BROWSER_RUNTIME_TOOL_PREFIX, }; use crate::agent_tools::execution::{ resolve_tool_execution_policy_resolution, ToolExecutionPolicySource, @@ -270,7 +270,10 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv .into_iter() .collect::>(); mcp_servers.sort(); - let mcp_server_lookup = mcp_servers.iter().cloned().collect::>(); + let mcp_extension_lookup = mcp_servers + .iter() + .map(|server_name| mcp_extension_runtime_name(server_name)) + .collect::>(); let mut default_allowed_tools = workspace_default_allowed_tool_names(surface) .into_iter() @@ -315,14 +318,14 @@ pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInv &extension_configs, &visible_extension_tools, &searchable_extension_tools, - &mcp_server_lookup, + &mcp_extension_lookup, ); let extension_tools = build_extension_tool_inventory( &extension_configs, &visible_extension_tools, &searchable_extension_tools, &caller, - &mcp_server_lookup, + &mcp_extension_lookup, ); let mcp_tools = build_mcp_inventory(&mcp_tools, &caller); @@ -795,13 +798,18 @@ mod tests { registry_definitions: vec![ definition("ToolSearch", "search tools", json!({ "type": "object" })), definition( - "read", + "Read", "read file", json!({ "type": "object", "x-lime": { "allowed_callers": ["assistant"] } }), ), + definition( + "StructuredOutput", + "structured response", + json!({ "type": "object" }), + ), definition( "admin_secret", "secret", @@ -815,33 +823,33 @@ mod tests { ), ], extension_configs: vec![builtin_extension( - "docs", + "mcp__docs", vec!["search_docs", "read_docs"], true, vec!["search_docs"], Some("assistant"), )], visible_extension_tools: vec![ExtensionToolInventorySeed { - name: "docs__search_docs".to_string(), + name: "mcp__docs__search_docs".to_string(), description: "visible docs tool".to_string(), }], searchable_extension_tools: vec![ ExtensionToolInventorySeed { - name: "docs__search_docs".to_string(), + name: "mcp__docs__search_docs".to_string(), description: "visible docs tool".to_string(), }, ExtensionToolInventorySeed { - name: "docs__read_docs".to_string(), + name: "mcp__docs__read_docs".to_string(), description: "deferred docs tool".to_string(), }, ], }); - assert_eq!(inventory.counts.catalog_total, 30); - assert_eq!(inventory.counts.catalog_current_total, 29); + assert_eq!(inventory.counts.catalog_total, 41); + assert_eq!(inventory.counts.catalog_current_total, 40); assert_eq!(inventory.counts.catalog_compat_total, 1); - assert_eq!(inventory.counts.registry_total, 3); - assert_eq!(inventory.counts.registry_visible_total, 2); + assert_eq!(inventory.counts.registry_total, 4); + assert_eq!(inventory.counts.registry_visible_total, 3); assert_eq!(inventory.counts.registry_catalog_unmapped_total, 1); assert_eq!(inventory.counts.extension_surface_total, 1); assert_eq!(inventory.counts.extension_mcp_bridge_total, 1); @@ -855,8 +863,8 @@ mod tests { let bash_catalog = inventory .catalog_tools .iter() - .find(|entry| entry.name == "bash") - .expect("bash catalog entry should exist"); + .find(|entry| entry.name == "Bash") + .expect("Bash catalog entry should exist"); assert_eq!( bash_catalog.execution_warning_policy, ToolExecutionWarningPolicy::ShellCommandRisk @@ -875,10 +883,28 @@ mod tests { assert!(!admin_tool.visible_in_context); assert!(admin_tool.catalog_entry_name.is_none()); + let structured_output_tool = inventory + .registry_tools + .iter() + .find(|entry| entry.name == "StructuredOutput") + .expect("StructuredOutput should exist"); + assert_eq!( + structured_output_tool.catalog_entry_name.as_deref(), + Some("StructuredOutput") + ); + assert_eq!( + structured_output_tool.catalog_source, + Some(ToolSourceKind::AsterBuiltin) + ); + assert_eq!( + structured_output_tool.catalog_lifecycle, + Some(ToolLifecycle::Current) + ); + let docs_surface = inventory .extension_surfaces .iter() - .find(|entry| entry.extension_name == "docs") + .find(|entry| entry.extension_name == "mcp__docs") .expect("docs surface should exist"); assert_eq!( docs_surface.source_kind, @@ -886,20 +912,20 @@ mod tests { ); assert_eq!( docs_surface.loaded_tools, - vec!["docs__search_docs".to_string()] + vec!["mcp__docs__search_docs".to_string()] ); assert_eq!( docs_surface.searchable_tools, vec![ - "docs__read_docs".to_string(), - "docs__search_docs".to_string() + "mcp__docs__read_docs".to_string(), + "mcp__docs__search_docs".to_string() ] ); let deferred_extension_tool = inventory .extension_tools .iter() - .find(|entry| entry.name == "docs__read_docs") + .find(|entry| entry.name == "mcp__docs__read_docs") .expect("deferred extension tool should exist"); assert_eq!(deferred_extension_tool.status, "deferred"); assert!(!deferred_extension_tool.visible_in_context); @@ -975,8 +1001,8 @@ mod tests { .map(ToString::to_string) .collect::>(); - assert_eq!(inventory.counts.catalog_total, 44); - assert_eq!(inventory.counts.catalog_current_total, 43); + assert_eq!(inventory.counts.catalog_total, 55); + assert_eq!(inventory.counts.catalog_current_total, 54); assert_eq!(inventory.counts.catalog_compat_total, 1); assert_eq!(inventory.default_allowed_tools, expected_default_allowed); assert_eq!( @@ -1092,7 +1118,7 @@ mod tests { mcp_server_names: Vec::new(), mcp_tools: Vec::new(), registry_definitions: vec![definition( - "bash", + "Bash", "workspace bash", json!({ "type": "object", @@ -1107,8 +1133,8 @@ mod tests { let bash_catalog = inventory .catalog_tools .iter() - .find(|entry| entry.name == "bash") - .expect("bash catalog entry should exist"); + .find(|entry| entry.name == "Bash") + .expect("Bash catalog entry should exist"); assert_eq!( bash_catalog.execution_warning_policy, ToolExecutionWarningPolicy::None @@ -1137,8 +1163,8 @@ mod tests { let bash_registry = inventory .registry_tools .iter() - .find(|entry| entry.name == "bash") - .expect("bash registry entry should exist"); + .find(|entry| entry.name == "Bash") + .expect("Bash registry entry should exist"); assert_eq!( bash_registry.catalog_execution_warning_policy, Some(ToolExecutionWarningPolicy::None) @@ -1179,7 +1205,7 @@ mod tests { registry_definitions: Vec::new(), extension_configs: vec![ builtin_extension( - "docs", + "mcp__docs", vec!["search_docs", "read_docs"], true, vec!["search_docs"], @@ -1187,10 +1213,10 @@ mod tests { ), builtin_extension("fs", vec!["list"], false, vec![], Some("code_execution")), ], - visible_extension_tools: vec![seed("docs__read_docs", "loaded docs tool")], + visible_extension_tools: vec![seed("mcp__docs__read_docs", "loaded docs tool")], searchable_extension_tools: vec![ - seed("docs__search_docs", "search docs"), - seed("docs__read_docs", "loaded docs tool"), + seed("mcp__docs__search_docs", "search docs"), + seed("mcp__docs__read_docs", "loaded docs tool"), seed("fs__list", "list files"), ], }); @@ -1198,7 +1224,7 @@ mod tests { let docs_surface = inventory .extension_surfaces .iter() - .find(|entry| entry.extension_name == "docs") + .find(|entry| entry.extension_name == "mcp__docs") .expect("docs surface should exist"); assert_eq!( docs_surface.source_kind, @@ -1206,13 +1232,13 @@ mod tests { ); assert_eq!( docs_surface.loaded_tools, - vec!["docs__read_docs".to_string()] + vec!["mcp__docs__read_docs".to_string()] ); assert_eq!( docs_surface.searchable_tools, vec![ - "docs__read_docs".to_string(), - "docs__search_docs".to_string() + "mcp__docs__read_docs".to_string(), + "mcp__docs__search_docs".to_string() ] ); @@ -1229,7 +1255,7 @@ mod tests { let visible_tool = inventory .extension_tools .iter() - .find(|entry| entry.name == "docs__search_docs") + .find(|entry| entry.name == "mcp__docs__search_docs") .expect("visible tool should exist"); assert_eq!(visible_tool.status, "visible"); assert!(!visible_tool.deferred_loading); @@ -1238,7 +1264,7 @@ mod tests { let loaded_tool = inventory .extension_tools .iter() - .find(|entry| entry.name == "docs__read_docs") + .find(|entry| entry.name == "mcp__docs__read_docs") .expect("loaded tool should exist"); assert_eq!(loaded_tool.status, "loaded"); assert!(!loaded_tool.deferred_loading); @@ -1275,9 +1301,9 @@ mod tests { mcp_tools: Vec::new(), registry_definitions: Vec::new(), extension_configs: vec![ - builtin_extension("docs", vec!["search"], true, vec![], Some("assistant")), + builtin_extension("mcp__docs", vec!["search"], true, vec![], Some("assistant")), builtin_extension( - "docs__admin", + "mcp__docs__admin", vec!["search"], true, vec![], @@ -1285,15 +1311,15 @@ mod tests { ), ], visible_extension_tools: Vec::new(), - searchable_extension_tools: vec![seed("docs__admin__search", "admin search")], + searchable_extension_tools: vec![seed("mcp__docs__admin__search", "admin search")], }); let tool = inventory .extension_tools .iter() - .find(|entry| entry.name == "docs__admin__search") + .find(|entry| entry.name == "mcp__docs__admin__search") .expect("nested extension tool should exist"); - assert_eq!(tool.extension_name.as_deref(), Some("docs__admin")); + assert_eq!(tool.extension_name.as_deref(), Some("mcp__docs__admin")); assert_eq!(tool.allowed_caller.as_deref(), Some("code_execution")); assert_eq!(tool.status, "deferred"); assert!(tool.deferred_loading); @@ -1304,7 +1330,7 @@ mod tests { fn test_resolve_extension_tool_runtime_status_defaults_unknown_tools_visible() { let status = resolve_extension_tool_runtime_status( &[builtin_extension( - "docs", + "mcp__docs", vec!["search"], true, vec![], @@ -1338,7 +1364,7 @@ mod tests { mcp_tools: Vec::new(), registry_definitions: vec![ definition( - "bash", + "Bash", "workspace bash", json!({ "type": "object", @@ -1402,7 +1428,7 @@ mod tests { inventory .registry_tools .iter() - .find(|entry| entry.name == "bash") + .find(|entry| entry.name == "Bash") .and_then(|entry| entry.catalog_execution_sandbox_profile), Some(ToolExecutionSandboxProfile::WorkspaceCommand) ); diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 9db9cc27e..c22ab5aca 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -1157,10 +1157,6 @@ pub fn run() { commands::openclaw_cmd::openclaw_check_node_version, commands::openclaw_cmd::openclaw_check_git_available, commands::openclaw_cmd::openclaw_get_node_download_url, - commands::claw_solution_cmd::claw_solution_list, - commands::claw_solution_cmd::claw_solution_detail, - commands::claw_solution_cmd::claw_solution_check_readiness, - commands::claw_solution_cmd::claw_solution_prepare, commands::openclaw_cmd::openclaw_get_git_download_url, commands::openclaw_cmd::openclaw_get_command_preview, commands::openclaw_cmd::openclaw_get_progress_logs, @@ -1733,6 +1729,11 @@ pub fn run() { commands::video_generation_cmd::get_video_generation_task, commands::video_generation_cmd::list_video_generation_tasks, commands::video_generation_cmd::cancel_video_generation_task, + commands::media_task_cmd::create_image_generation_task_artifact, + commands::media_task_cmd::get_media_task_artifact, + commands::media_task_cmd::list_media_task_artifacts, + commands::media_task_cmd::retry_media_task_artifact, + commands::media_task_cmd::cancel_media_task_artifact, // Gallery material commands commands::gallery_material_cmd::create_gallery_material_metadata, commands::gallery_material_cmd::get_gallery_material_metadata, diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index 76326a8dc..9acef21c8 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -1477,6 +1477,8 @@ pub struct AgentRuntimeSpawnSubagentRequest { pub agent_type: Option, #[serde(default)] pub model: Option, + #[serde(default, alias = "runInBackground")] + pub run_in_background: bool, #[serde(default, alias = "reasoningEffort")] pub reasoning_effort: Option, #[serde(default, alias = "forkContext")] @@ -1504,6 +1506,10 @@ pub struct AgentRuntimeSpawnSubagentRequest { #[serde(default, alias = "outputContract")] pub output_contract: Option, #[serde(default)] + pub mode: Option, + #[serde(default)] + pub isolation: Option, + #[serde(default)] pub cwd: Option, } @@ -1745,6 +1751,9 @@ mod tests { "name": "verifier", "teamName": "delivery-team", "agentType": "explorer", + "runInBackground": true, + "mode": "plan", + "isolation": "worktree", "cwd": "/tmp/workspace" })) .expect("spawn subagent request should deserialize"); @@ -1754,6 +1763,9 @@ mod tests { assert_eq!(request.name.as_deref(), Some("verifier")); assert_eq!(request.team_name.as_deref(), Some("delivery-team")); assert_eq!(request.agent_type.as_deref(), Some("explorer")); + assert!(request.run_in_background); + assert_eq!(request.mode.as_deref(), Some("plan")); + assert_eq!(request.isolation.as_deref(), Some("worktree")); assert_eq!(request.cwd.as_deref(), Some("/tmp/workspace")); } diff --git a/src-tauri/src/commands/aster_agent_cmd/mcp_bridge.rs b/src-tauri/src/commands/aster_agent_cmd/mcp_bridge.rs index 029293fed..c2bbfd46d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mcp_bridge.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mcp_bridge.rs @@ -1,4 +1,5 @@ use super::*; +use crate::agent_tools::catalog::mcp_extension_runtime_name; pub(crate) async fn inject_mcp_extensions( state: &AsterAgentState, @@ -43,10 +44,18 @@ pub(crate) async fn inject_mcp_extensions( let mut fail_count = 0usize; for server_name in &running_servers { + let runtime_extension_name = mcp_extension_runtime_name(server_name); + // 检查是否已注册(避免重复注册) let ext_configs = agent.get_extension_configs().await; - if ext_configs.iter().any(|c| c.name() == *server_name) { - tracing::debug!("[AsterAgent] MCP extension '{}' 已注册,跳过", server_name); + if ext_configs + .iter() + .any(|c| c.name() == runtime_extension_name) + { + tracing::debug!( + "[AsterAgent] MCP extension '{}' 已注册,跳过", + runtime_extension_name + ); success_count += 1; continue; } @@ -68,13 +77,13 @@ pub(crate) async fn inject_mcp_extensions( .cloned() .unwrap_or_default(); let surface = build_mcp_extension_surface( - server_name, + &runtime_extension_name, format!("Lime MCP Bridge: {server_name}"), &server_tools, ); let extension = ExtensionConfig::Builtin { - name: server_name.clone(), + name: surface.extension_name.clone(), display_name: Some(server_name.clone()), description: surface.description.clone(), timeout: None, @@ -97,7 +106,7 @@ pub(crate) async fn inject_mcp_extensions( agent .extension_manager .add_client( - server_name.clone(), + runtime_extension_name.clone(), extension, client, running_service.peer_info().cloned(), @@ -106,7 +115,8 @@ pub(crate) async fn inject_mcp_extensions( .await; tracing::info!( - "[AsterAgent] 已桥接 MCP extension: name={}, tool_count={}, deferred={}, always_expose={}", + "[AsterAgent] 已桥接 MCP extension: name={}, display_name={}, tool_count={}, deferred={}, always_expose={}", + runtime_extension_name, server_name, surface.available_tools.len(), surface.deferred_loading, diff --git a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs index 28e25d6a8..7f3011ef9 100644 --- a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs +++ b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs @@ -723,7 +723,7 @@ pub(crate) fn build_team_preference_system_prompt( "- 用户偏好的 Team Preset:{preset_label} ({team_preset_id})。" )); lines.push( - "- 当你判断当前任务适合多代理时,优先沿用该 preset 的 profile / skill 组合去调用 spawn_agent。" + "- 当你判断当前任务适合多代理时,优先沿用该 preset 的 profile / skill 组合去调用 Agent。" .to_string(), ); } @@ -748,7 +748,7 @@ pub(crate) fn build_team_preference_system_prompt( lines.push("- 当前 Team 角色参考:".to_string()); lines.extend(rendered_roles); lines.push( - "- 如果你决定调用 spawn_agent,请优先把上述 profile / roleKey / skillIds 映射到对应结构化字段;若该角色带有 id,优先同步写入 blueprintRoleId / blueprintRoleLabel,保持 GUI Team 画布与实际分工一致。" + "- 如果你决定调用 Agent,请优先把上述角色信息映射到对应结构化字段,例如 description / subagent_type / name / team_name;需要保留 Team 蓝图角色锚点时,再补充 blueprintRoleId / profile_id / role_key / skill_ids,保持 GUI Team 画布与实际分工一致。" .to_string(), ); } @@ -769,7 +769,7 @@ pub(crate) fn build_team_preference_system_prompt( } lines.push( - "- spawn_agent 支持这些结构化字段:name、teamName、cwd、blueprintRoleId、blueprintRoleLabel、teamPresetId、profileId、profileName、roleKey、skillIds、skillDirectories、theme、systemOverlay、outputContract。teamName 需要与 name 搭配,并依附现有 team 上下文。" + "- Agent 支持这些结构化字段:description、prompt、subagent_type、model、run_in_background、name、team_name(别名 teamName)、reasoning_effort、fork_context、blueprint_role_id(别名 blueprintRoleId)、blueprint_role_label、profile_id、profile_name、role_key、skill_ids、skill_directories、team_preset_id、theme、system_overlay、output_contract、cwd、mode、isolation。team_name 需要与 name 搭配,并依附现有 team 上下文;当前 runtime 仍会拒绝非空 mode / isolation。" .to_string(), ); lines.push( diff --git a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs index 93129619b..d1dd4051d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs @@ -171,6 +171,19 @@ fn normalize_optional_vec(values: &[String]) -> Vec { normalized } +fn validate_spawn_request_surface( + request: &AgentRuntimeSpawnSubagentRequest, +) -> Result<(), String> { + if normalize_optional_text(request.mode.clone()).is_some() { + return Err("mode is not supported in the current runtime".to_string()); + } + if normalize_optional_text(request.isolation.clone()).is_some() { + return Err("isolation is not supported in the current runtime".to_string()); + } + + Ok(()) +} + fn build_subagent_session_name( explicit_name: Option<&str>, message: &str, @@ -675,7 +688,7 @@ async fn create_runtime_subagent_session( .with_task_summary(build_subagent_task_summary(&message)) .with_role_hint(role_hint.clone()) .with_created_from_turn_id(resolve_action_scope_turn_id(&parent_session_id)); - metadata.origin_tool = "spawn_agent".to_string(); + metadata.origin_tool = "Agent".to_string(); let mut extension_data = session.extension_data.clone(); metadata .to_extension_data(&mut extension_data) @@ -750,6 +763,7 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal( request: AgentRuntimeSpawnSubagentRequest, ) -> Result { runtime.ensure_initialized().await?; + validate_spawn_request_surface(&request)?; let PreparedRuntimeSubagentSession { session: child_session, customization, @@ -784,10 +798,13 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal( "name": request.name, "team_name": request.team_name, "agent_type": request.agent_type, + "run_in_background": request.run_in_background, "reasoning_effort": request.reasoning_effort, "fork_context": request.fork_context, + "mode": request.mode, + "isolation": request.isolation, "cwd": request.cwd, - "origin_tool": "spawn_agent", + "origin_tool": "Agent", "blueprint_role_id": customization.as_ref().and_then(|state| state.blueprint_role_id.clone()), "blueprint_role_label": customization.as_ref().and_then(|state| state.blueprint_role_label.clone()), "profile_id": customization.as_ref().and_then(|state| state.profile_id.clone()), @@ -861,7 +878,7 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal( system_prompt, metadata: Some(serde_json::json!({ "subagent": { - "origin_tool": "send_input", + "origin_tool": "SendMessage", "interrupt": request.interrupt, "blueprint_role_id": customization.as_ref().and_then(|state| state.blueprint_role_id.clone()), "blueprint_role_label": customization.as_ref().and_then(|state| state.blueprint_role_label.clone()), @@ -1075,6 +1092,7 @@ mod tests { team_name: Some("delivery-team".to_string()), agent_type: Some("explorer".to_string()), model: None, + run_in_background: false, reasoning_effort: None, fork_context: false, blueprint_role_id: None, @@ -1088,6 +1106,8 @@ mod tests { theme: None, system_overlay: None, output_contract: None, + mode: None, + isolation: None, cwd: None, }; @@ -1108,4 +1128,49 @@ mod tests { assert_eq!(resolved, child.path()); } + + #[test] + fn test_validate_spawn_request_surface_rejects_mode_and_isolation() { + let request = AgentRuntimeSpawnSubagentRequest { + parent_session_id: "parent-1".to_string(), + message: "定位当前 team runtime 差异".to_string(), + name: None, + team_name: None, + agent_type: None, + model: None, + run_in_background: false, + reasoning_effort: None, + fork_context: false, + blueprint_role_id: None, + blueprint_role_label: None, + profile_id: None, + profile_name: None, + role_key: None, + skill_ids: Vec::new(), + skill_directories: Vec::new(), + team_preset_id: None, + theme: None, + system_overlay: None, + output_contract: None, + mode: Some("plan".to_string()), + isolation: None, + cwd: None, + }; + + assert_eq!( + validate_spawn_request_surface(&request).unwrap_err(), + "mode is not supported in the current runtime" + ); + + let isolation_request = AgentRuntimeSpawnSubagentRequest { + mode: None, + isolation: Some("worktree".to_string()), + ..request + }; + + assert_eq!( + validate_spawn_request_surface(&isolation_request).unwrap_err(), + "isolation is not supported in the current runtime" + ); + } } diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index d3bfb86bc..f087430fc 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -2358,11 +2358,12 @@ mod tests { ); for tool_name in [ - "spawn_agent", - "send_input", - "wait_agent", - "resume_agent", - "close_agent", + "Agent", + "SendUserMessage", + "SendMessage", + "TeamCreate", + "TeamDelete", + "ListPeers", ] { assert!( tool_names.contains(&tool_name), @@ -2387,7 +2388,7 @@ mod tests { assert!(prompt.contains(TEAM_PREFERENCE_PROMPT_MARKER)); assert!(prompt.contains("代码排障团队")); - assert!(prompt.contains("spawn_agent")); + assert!(prompt.contains("Agent")); let disabled = build_team_preference_system_prompt( Some(&serde_json::json!({ @@ -2525,7 +2526,7 @@ mod tests { .expect("team prompt should exist"); assert!(prompt.contains("代码排障团队")); - assert!(prompt.contains("spawn_agent")); + assert!(prompt.contains("Agent")); } #[test] @@ -2608,6 +2609,7 @@ mod tests { team_name: None, agent_type: Some("Image #1".to_string()), model: None, + run_in_background: false, reasoning_effort: None, fork_context: false, blueprint_role_id: Some("runtime-explorer".to_string()), @@ -2621,6 +2623,8 @@ mod tests { theme: None, system_overlay: None, output_contract: None, + mode: None, + isolation: None, cwd: None, }) .expect("build customization state") @@ -3105,35 +3109,35 @@ mod tests { #[test] fn test_tool_search_extension_tool_status_marks_default_visible_and_loaded_tools() { let configs = vec![builtin_extension_config( - "docs", + "mcp__docs", vec!["search_docs", "read_docs"], true, vec!["search_docs"], Some("assistant"), )]; - let visible_tool_names = HashSet::from(["docs__read_docs".to_string()]); + let visible_tool_names = HashSet::from(["mcp__docs__read_docs".to_string()]); let visible = ToolSearchBridgeTool::extension_tool_status( &configs, &visible_tool_names, - "docs__search_docs", + "mcp__docs__search_docs", ); - assert_eq!(visible, ("visible", false, Some("docs".to_string()))); + assert_eq!(visible, ("visible", false, Some("mcp__docs".to_string()))); let loaded = ToolSearchBridgeTool::extension_tool_status( &configs, &visible_tool_names, - "docs__read_docs", + "mcp__docs__read_docs", ); - assert_eq!(loaded, ("loaded", false, Some("docs".to_string()))); + assert_eq!(loaded, ("loaded", false, Some("mcp__docs".to_string()))); } #[test] fn test_tool_search_extension_tool_status_prefers_longest_extension_name() { let configs = vec![ - builtin_extension_config("docs", vec!["search"], true, vec![], Some("assistant")), + builtin_extension_config("mcp__docs", vec!["search"], true, vec![], Some("assistant")), builtin_extension_config( - "docs__admin", + "mcp__docs__admin", vec!["search"], true, vec![], @@ -3144,9 +3148,12 @@ mod tests { let status = ToolSearchBridgeTool::extension_tool_status( &configs, &HashSet::new(), - "docs__admin__search", + "mcp__docs__admin__search", + ); + assert_eq!( + status, + ("deferred", true, Some("mcp__docs__admin".to_string())) ); - assert_eq!(status, ("deferred", true, Some("docs__admin".to_string()))); } #[test] diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs index a47109f6d..4020564ef 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs @@ -4,8 +4,12 @@ use super::*; mod browser_tools; #[path = "tool_runtime/creation_tools.rs"] mod creation_tools; +#[path = "tool_runtime/lime_cli_runtime.rs"] +mod lime_cli_runtime; #[path = "tool_runtime/mcp_resource_tools.rs"] mod mcp_resource_tools; +#[path = "tool_runtime/media_cli_bridge.rs"] +pub(crate) mod media_cli_bridge; #[path = "tool_runtime/search_bridge.rs"] mod search_bridge; #[path = "tool_runtime/site_tools.rs"] @@ -140,7 +144,8 @@ pub(crate) async fn apply_workspace_sandbox_permissions( } else { match WorkspaceSandboxedBashTool::new( workspace_root, - should_auto_approve_tool_warnings("bash", auto_mode, execution_policy_input), + should_auto_approve_tool_warnings("Bash", auto_mode, execution_policy_input), + app_handle.clone(), ) { Ok(tool) => { let sandbox_type = tool.sandbox_type().to_string(); @@ -200,7 +205,8 @@ pub(crate) async fn apply_workspace_sandbox_permissions( workspace_tools::register_workspace_runtime_tools( &mut registry, task_manager, - should_auto_approve_tool_warnings("bash", auto_mode, execution_policy_input), + should_auto_approve_tool_warnings("Bash", auto_mode, execution_policy_input), + app_handle.clone(), sandboxed_bash_tool, ); diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs index 72a407d9c..13522cab9 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs @@ -1,106 +1,56 @@ use super::*; - -fn is_safe_relative_path(path: &Path) -> bool { - if path.is_absolute() { - return false; - } - !path.components().any(|component| { - matches!( - component, - std::path::Component::ParentDir - | std::path::Component::RootDir - | std::path::Component::Prefix(_) - ) - }) -} - -fn resolve_output_relative_path( - task_type: &str, - output_path: Option<&str>, -) -> Result { - if let Some(raw) = output_path { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(ToolError::invalid_params( - "outputPath 不能为空字符串".to_string(), - )); - } - let candidate = PathBuf::from(trimmed); - if !is_safe_relative_path(&candidate) { - return Err(ToolError::invalid_params( - "outputPath 必须是安全的相对路径,且不能包含 '..'".to_string(), - )); - } - return Ok(candidate); - } - - let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string(); - let suffix = uuid::Uuid::new_v4().simple().to_string(); - Ok(PathBuf::from(".lime") - .join("tasks") - .join(task_type) - .join(format!("{timestamp}-{suffix}.json"))) -} +use lime_media_runtime::{write_task_artifact, MediaTaskType, TaskType, TaskWriteOptions}; fn submit_creation_task_record( app_handle: &AppHandle, context: &ToolContext, - task_type: &str, + task_type: TaskType, title: Option, payload: serde_json::Value, + status: Option, output_path: Option<&str>, ) -> Result { - let output_rel_path = resolve_output_relative_path(task_type, output_path)?; - let output_abs_path = context.working_directory.join(&output_rel_path); + let output = write_task_artifact( + context.working_directory.as_path(), + task_type, + title, + payload, + TaskWriteOptions { + status, + output_path, + artifact_dir: None, + idempotency_key: None, + }, + ) + .map_err(media_cli_bridge::tool_error_from_media_runtime)?; - let parent = output_abs_path - .parent() - .ok_or_else(|| ToolError::execution_failed("无法解析任务文件父目录".to_string()))?; - std::fs::create_dir_all(parent) - .map_err(|error| ToolError::execution_failed(format!("创建任务目录失败: {error}")))?; + media_cli_bridge::emit_media_creation_task_event(app_handle, &output); + let serialized = serde_json::to_string_pretty(&output) + .unwrap_or_else(|_| serde_json::json!(&output).to_string()); + Ok(media_cli_bridge::attach_media_task_metadata( + ToolResult::success(serialized), + &output, + )) +} - let task_id = uuid::Uuid::new_v4().to_string(); - let task_record = serde_json::json!({ - "task_id": task_id, - "task_type": task_type, - "title": title, - "payload": payload, - "status": "pending_submit", - "created_at": chrono::Utc::now().to_rfc3339() - }); - let task_content = - serde_json::to_string_pretty(&task_record).unwrap_or_else(|_| task_record.to_string()); - - std::fs::write(&output_abs_path, task_content.as_bytes()) - .map_err(|error| ToolError::execution_failed(format!("写入任务文件失败: {error}")))?; - - let emitted_payload = serde_json::json!({ - "task_id": task_id, - "task_type": task_type, - "path": output_rel_path.to_string_lossy().to_string(), - "absolute_path": output_abs_path.to_string_lossy().to_string() - }); - if let Err(error) = app_handle.emit("lime://creation_task_submitted", &emitted_payload) { - tracing::warn!( - "[AsterAgent] creation_task_submitted 事件发送失败: {}", - error - ); - } - - let output_payload = serde_json::json!({ - "success": true, - "task_id": task_id, - "task_type": task_type, - "path": output_rel_path.to_string_lossy().to_string(), - "absolute_path": output_abs_path.to_string_lossy().to_string(), - "record": task_record - }); - let output = serde_json::to_string_pretty(&output_payload) - .unwrap_or_else(|_| output_payload.to_string()); - Ok(ToolResult::success(output) - .with_metadata("task_id", serde_json::json!(task_id)) - .with_metadata("task_type", serde_json::json!(task_type)) - .with_metadata("path", serde_json::json!(output_abs_path.to_string_lossy()))) +fn submit_media_generation_task_record( + app_handle: &AppHandle, + context: &ToolContext, + task_type: MediaTaskType, + title: Option, + payload: serde_json::Value, + status: Option, + output_path: Option<&str>, +) -> Result { + submit_creation_task_record( + app_handle, + context, + task_type, + title, + payload, + status, + output_path, + ) } #[derive(Debug, Deserialize)] @@ -182,9 +132,10 @@ impl Tool for LimeCreateBroadcastTaskTool { submit_creation_task_record( &self.app_handle, context, - "broadcast_generate", + TaskType::BroadcastGenerate, input.title, payload, + None, input.output_path.as_deref(), ) } @@ -268,18 +219,21 @@ impl Tool for LimeCreateCoverTaskTool { } let payload = serde_json::json!({ "prompt": input.prompt, + "model": "lime-cover-cli", "platform": input.platform, "size": input.size, "imageUrl": input.image_url, + "usage": "cover", "status": input.status, "remark": input.remark }); - submit_creation_task_record( + submit_media_generation_task_record( &self.app_handle, context, - "cover_generate", + MediaTaskType::CoverGenerate, input.title, payload, + Some("pending_submit".to_string()), input.output_path.as_deref(), ) } @@ -367,9 +321,10 @@ impl Tool for LimeCreateResourceSearchTaskTool { submit_creation_task_record( &self.app_handle, context, - "modal_resource_search", + TaskType::ModalResourceSearch, input.title, payload, + None, input.output_path.as_deref(), ) } @@ -450,17 +405,19 @@ impl Tool for LimeCreateImageTaskTool { } let payload = serde_json::json!({ "prompt": input.prompt, + "model": "lime-image-cli", "style": input.style, "size": input.size, "count": input.count, "usage": input.usage }); - submit_creation_task_record( + submit_media_generation_task_record( &self.app_handle, context, - "image_generate", + MediaTaskType::ImageGenerate, input.title, payload, + Some("pending_submit".to_string()), input.output_path.as_deref(), ) } @@ -543,9 +500,10 @@ impl Tool for LimeCreateUrlParseTaskTool { submit_creation_task_record( &self.app_handle, context, - "url_parse", + TaskType::UrlParse, input.title, payload, + None, input.output_path.as_deref(), ) } @@ -626,9 +584,10 @@ impl Tool for LimeCreateTypesettingTaskTool { submit_creation_task_record( &self.app_handle, context, - "typesetting", + TaskType::Typesetting, input.title, payload, + None, input.output_path.as_deref(), ) } @@ -638,13 +597,19 @@ impl Tool for LimeCreateTypesettingTaskTool { struct LimeCreateVideoGenerationTaskTool { db: DbConnection, api_key_provider_service: Arc, + app_handle: AppHandle, } impl LimeCreateVideoGenerationTaskTool { - fn new(db: DbConnection, api_key_provider_service: Arc) -> Self { + fn new( + db: DbConnection, + api_key_provider_service: Arc, + app_handle: AppHandle, + ) -> Self { Self { db, api_key_provider_service, + app_handle, } } } @@ -699,7 +664,7 @@ impl Tool for LimeCreateVideoGenerationTaskTool { async fn execute( &self, params: serde_json::Value, - _context: &ToolContext, + context: &ToolContext, ) -> Result { let request: CreateVideoGenerationRequest = serde_json::from_value(params) .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; @@ -719,12 +684,46 @@ impl Tool for LimeCreateVideoGenerationTaskTool { .await .map_err(|error| ToolError::execution_failed(format!("创建视频任务失败: {error}")))?; + let task_json = serde_json::to_value(&created).unwrap_or_else(|_| serde_json::json!({})); + let artifact_output = write_task_artifact( + context.working_directory.as_path(), + MediaTaskType::VideoGenerate, + Some(created.prompt.clone()), + serde_json::json!({ + "projectId": created.project_id.clone(), + "providerId": created.provider_id.clone(), + "model": created.model.clone(), + "prompt": created.prompt.clone(), + "status": created.status.to_string(), + "task": task_json.clone(), + }), + TaskWriteOptions { + status: Some(created.status.to_string()), + output_path: None, + artifact_dir: None, + idempotency_key: None, + }, + ) + .map_err(media_cli_bridge::tool_error_from_media_runtime)?; + + media_cli_bridge::emit_media_creation_task_event(&self.app_handle, &artifact_output); let payload = serde_json::json!({ "success": true, - "task": created + "task_id": artifact_output.task_id, + "task_type": artifact_output.task_type, + "status": artifact_output.status, + "path": artifact_output.path, + "absolute_path": artifact_output.absolute_path, + "artifact_path": artifact_output.artifact_path, + "absolute_artifact_path": artifact_output.absolute_artifact_path, + "task": created, + "record": artifact_output.record }); let output = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string()); - Ok(ToolResult::success(output)) + Ok(media_cli_bridge::attach_media_task_metadata( + ToolResult::success(output), + &artifact_output, + )) } } @@ -738,6 +737,7 @@ pub(super) fn register_creation_task_tools_to_registry( registry.register(Box::new(LimeCreateVideoGenerationTaskTool::new( db.clone(), api_key_provider_service.clone(), + app_handle.clone(), ))); } if !registry.contains(LIME_CREATE_BROADCAST_TASK_TOOL_NAME) { diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/lime_cli_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/lime_cli_runtime.rs new file mode 100644 index 000000000..7051b1086 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/lime_cli_runtime.rs @@ -0,0 +1,325 @@ +use super::*; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use tauri::Manager; + +const LIME_CLI_PATH_ENV: &str = "LIME_CLI_PATH"; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum LimeCliInvocation { + Binary(PathBuf), + Cargo { manifest_path: PathBuf }, +} + +#[derive(Debug, Default)] +struct LimeCliDiscoveryContext { + env_override: Option, + resource_dir: Option, + current_exe: Option, + current_dir: Option, + path_var: Option, +} + +pub(crate) fn prefix_shell_command_with_lime_cli( + command: &str, + app_handle: Option<&AppHandle>, +) -> String { + let Some(invocation) = resolve_lime_cli_invocation(app_handle) else { + return command.to_string(); + }; + + match invocation { + LimeCliInvocation::Binary(path) => prefix_command_with_binary(command, &path), + LimeCliInvocation::Cargo { manifest_path } => { + prefix_command_with_cargo_fallback(command, &manifest_path) + } + } +} + +fn resolve_lime_cli_invocation(app_handle: Option<&AppHandle>) -> Option { + let current_dir = std::env::current_dir().ok(); + let context = LimeCliDiscoveryContext { + env_override: std::env::var_os(LIME_CLI_PATH_ENV) + .map(PathBuf::from) + .map(|path| resolve_relative_path(path, current_dir.as_deref())), + resource_dir: app_handle.and_then(|handle| handle.path().resource_dir().ok()), + current_exe: std::env::current_exe().ok(), + current_dir, + path_var: std::env::var_os("PATH"), + }; + resolve_lime_cli_invocation_from_context(&context) +} + +fn resolve_lime_cli_invocation_from_context( + context: &LimeCliDiscoveryContext, +) -> Option { + for candidate in binary_candidates(context) { + if is_existing_file(&candidate) { + return Some(LimeCliInvocation::Binary(candidate)); + } + } + + resolve_cargo_invocation(context) +} + +fn binary_candidates(context: &LimeCliDiscoveryContext) -> Vec { + let mut candidates = Vec::new(); + + if let Some(path) = context.env_override.as_ref() { + push_unique_path(&mut candidates, path.clone()); + } + + if let Some(resource_dir) = context.resource_dir.as_ref() { + for relative in [ + PathBuf::from("resources") + .join("bin") + .join(binary_file_name()), + PathBuf::from("bin").join(binary_file_name()), + ] { + push_unique_path(&mut candidates, resource_dir.join(relative)); + } + } + + if let Some(current_exe) = context.current_exe.as_ref() { + if let Some(parent) = current_exe.parent() { + push_unique_path(&mut candidates, parent.join(binary_file_name())); + } + } + + if let Some(current_dir) = context.current_dir.as_ref() { + for relative in [ + PathBuf::from("src-tauri") + .join("target") + .join("debug") + .join(binary_file_name()), + PathBuf::from("src-tauri") + .join("target") + .join("release") + .join(binary_file_name()), + PathBuf::from("target") + .join("debug") + .join(binary_file_name()), + PathBuf::from("target") + .join("release") + .join(binary_file_name()), + PathBuf::from("src-tauri") + .join("resources") + .join("bin") + .join(binary_file_name()), + PathBuf::from("resources") + .join("bin") + .join(binary_file_name()), + ] { + push_unique_path(&mut candidates, current_dir.join(relative)); + } + } + + if let Some(path_var) = context.path_var.as_ref() { + for entry in std::env::split_paths(path_var) { + push_unique_path(&mut candidates, entry.join(binary_file_name())); + } + } + + candidates +} + +fn resolve_cargo_invocation(context: &LimeCliDiscoveryContext) -> Option { + if !cargo_available(context.path_var.as_ref()) { + return None; + } + + for manifest_path in cargo_manifest_candidates(context) { + if is_existing_file(&manifest_path) { + return Some(LimeCliInvocation::Cargo { manifest_path }); + } + } + + None +} + +fn cargo_manifest_candidates(context: &LimeCliDiscoveryContext) -> Vec { + let mut candidates = Vec::new(); + + if let Some(current_dir) = context.current_dir.as_ref() { + for relative in [ + PathBuf::from("src-tauri").join("Cargo.toml"), + PathBuf::from("Cargo.toml"), + ] { + push_unique_path(&mut candidates, current_dir.join(relative)); + } + } + + candidates +} + +fn cargo_available(path_var: Option<&OsString>) -> bool { + if let Some(cargo_env) = std::env::var_os("CARGO") { + let cargo_path = PathBuf::from(cargo_env); + if is_existing_file(&cargo_path) { + return true; + } + } + + let Some(path_var) = path_var else { + return false; + }; + + std::env::split_paths(path_var) + .map(|entry| entry.join(cargo_binary_name())) + .any(|candidate| is_existing_file(&candidate)) +} + +fn resolve_relative_path(path: PathBuf, base_dir: Option<&Path>) -> PathBuf { + if path.is_absolute() { + return path; + } + + base_dir.map(|base| base.join(path.clone())).unwrap_or(path) +} + +fn push_unique_path(target: &mut Vec, path: PathBuf) { + if target.iter().any(|existing| existing == &path) { + return; + } + target.push(path); +} + +fn is_existing_file(path: &Path) -> bool { + path.is_file() +} + +fn prefix_command_with_binary(command: &str, binary_path: &Path) -> String { + let binary_str = binary_path.to_string_lossy().to_string(); + let binary_dir = binary_path + .parent() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_default(); + + #[cfg(target_os = "windows")] + { + let binary = escape_powershell_string(&binary_str); + let dir = escape_powershell_string(&binary_dir); + return format!( + "function global:lime {{ & '{binary}' @args }}\n$env:{LIME_CLI_PATH_ENV} = '{binary}'\n$env:PATH = '{dir};' + $env:PATH\n{command}" + ); + } + + #[cfg(not(target_os = "windows"))] + { + let binary = shell_escape(&binary_str); + let dir = shell_escape(&binary_dir); + format!( + "lime() {{ {binary} \"$@\"; }}\nexport {LIME_CLI_PATH_ENV}={binary}\nexport PATH={dir}:\"$PATH\"\n{command}" + ) + } +} + +fn prefix_command_with_cargo_fallback(command: &str, manifest_path: &Path) -> String { + let manifest = manifest_path.to_string_lossy().to_string(); + + #[cfg(target_os = "windows")] + { + let manifest = escape_powershell_string(&manifest); + return format!( + "function global:lime {{ & cargo run --quiet --manifest-path '{manifest}' -p lime-cli -- @args }}\n{command}" + ); + } + + #[cfg(not(target_os = "windows"))] + { + let manifest = shell_escape(&manifest); + format!( + "lime() {{ cargo run --quiet --manifest-path {manifest} -p lime-cli -- \"$@\"; }}\n{command}" + ) + } +} + +#[cfg(target_os = "windows")] +fn binary_file_name() -> &'static str { + "lime.exe" +} + +#[cfg(not(target_os = "windows"))] +fn binary_file_name() -> &'static str { + "lime" +} + +#[cfg(target_os = "windows")] +fn cargo_binary_name() -> &'static str { + "cargo.exe" +} + +#[cfg(not(target_os = "windows"))] +fn cargo_binary_name() -> &'static str { + "cargo" +} + +#[cfg(not(target_os = "windows"))] +fn shell_escape(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +#[cfg(target_os = "windows")] +fn escape_powershell_string(value: &str) -> String { + value.replace('\'', "''") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_lime_cli_invocation_prefers_explicit_binary_override() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let binary_path = temp_dir.path().join(binary_file_name()); + std::fs::write(&binary_path, b"binary").expect("write binary"); + + let context = LimeCliDiscoveryContext { + env_override: Some(binary_path.clone()), + ..Default::default() + }; + + let invocation = resolve_lime_cli_invocation_from_context(&context).expect("invocation"); + assert_eq!(invocation, LimeCliInvocation::Binary(binary_path)); + } + + #[test] + fn resolve_lime_cli_invocation_falls_back_to_dev_cargo_manifest() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let src_tauri_dir = temp_dir.path().join("src-tauri"); + std::fs::create_dir_all(&src_tauri_dir).expect("create src-tauri dir"); + let manifest_path = src_tauri_dir.join("Cargo.toml"); + std::fs::write(&manifest_path, b"[workspace]\n").expect("write manifest"); + + let cargo_dir = temp_dir.path().join("bin"); + std::fs::create_dir_all(&cargo_dir).expect("create cargo dir"); + let cargo_path = cargo_dir.join(cargo_binary_name()); + std::fs::write(&cargo_path, b"cargo").expect("write cargo binary"); + + let context = LimeCliDiscoveryContext { + current_dir: Some(temp_dir.path().to_path_buf()), + path_var: Some(std::env::join_paths([cargo_dir]).expect("join paths")), + ..Default::default() + }; + + let invocation = resolve_lime_cli_invocation_from_context(&context).expect("invocation"); + assert_eq!(invocation, LimeCliInvocation::Cargo { manifest_path }); + } + + #[test] + fn prefix_shell_command_with_cargo_fallback_defines_lime_function() { + let command = prefix_command_with_cargo_fallback( + "lime media image generate --prompt demo", + Path::new("/tmp/src-tauri/Cargo.toml"), + ); + + #[cfg(target_os = "windows")] + assert!(command.contains("function global:lime")); + + #[cfg(not(target_os = "windows"))] + assert!(command.contains("lime()")); + + assert!(command.contains("cargo run --quiet")); + assert!(command.contains("lime media image generate --prompt demo")); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/media_cli_bridge.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/media_cli_bridge.rs new file mode 100644 index 000000000..54d76bbab --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/media_cli_bridge.rs @@ -0,0 +1,203 @@ +use super::*; +use lime_media_runtime::{parse_media_task_output, MediaRuntimeError, MediaTaskOutput}; + +const CREATION_TASK_EVENT_NAME: &str = "lime://creation_task_submitted"; + +fn read_payload_string(payload: &serde_json::Value, keys: &[&str]) -> Option { + for key in keys { + let Some(value) = payload.get(*key) else { + continue; + }; + if let Some(text) = value.as_str() { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +fn read_payload_u64(payload: &serde_json::Value, keys: &[&str]) -> Option { + for key in keys { + let Some(value) = payload.get(*key) else { + continue; + }; + if let Some(number) = value.as_u64() { + return Some(number); + } + if let Some(text) = value.as_str() { + let trimmed = text.trim(); + if trimmed.is_empty() { + continue; + } + if let Ok(parsed) = trimmed.parse::() { + return Some(parsed); + } + } + } + None +} + +pub(crate) fn tool_error_from_media_runtime(error: MediaRuntimeError) -> ToolError { + match error { + MediaRuntimeError::InvalidParams(message) => ToolError::invalid_params(message), + MediaRuntimeError::Io(message) + | MediaRuntimeError::Conflict(message) + | MediaRuntimeError::InvalidState(message) + | MediaRuntimeError::NotRetryable(message) => ToolError::execution_failed(message), + MediaRuntimeError::TaskNotFound { task_ref } => { + ToolError::execution_failed(format!("未找到任务: {task_ref}")) + } + } +} + +pub(crate) fn emit_media_creation_task_event(app_handle: &AppHandle, output: &MediaTaskOutput) { + let payload_record = &output.record.payload; + let payload = serde_json::json!({ + "task_id": output.task_id, + "task_type": output.task_type, + "task_family": output.task_family, + "status": output.status, + "current_attempt_id": output.current_attempt_id, + "path": output.path, + "absolute_path": output.absolute_path, + "reused_existing": output.reused_existing, + "prompt": read_payload_string(payload_record, &["prompt"]), + "size": read_payload_string(payload_record, &["size", "resolution"]), + "mode": read_payload_string(payload_record, &["mode"]), + "count": read_payload_u64(payload_record, &["count", "image_count", "imageCount"]), + "raw_text": read_payload_string(payload_record, &["raw_text", "rawText"]), + "session_id": read_payload_string(payload_record, &["session_id", "sessionId"]), + "project_id": read_payload_string(payload_record, &["project_id", "projectId"]), + "content_id": read_payload_string(payload_record, &["content_id", "contentId"]), + "entry_source": read_payload_string(payload_record, &["entry_source", "entrySource"]), + "requested_target": read_payload_string(payload_record, &["requested_target", "requestedTarget"]), + }); + + if let Err(error) = app_handle.emit(CREATION_TASK_EVENT_NAME, &payload) { + tracing::warn!( + "[AsterAgent] media creation_task_submitted 事件发送失败: {}", + error + ); + } +} + +pub(crate) fn attach_media_task_metadata( + result: ToolResult, + output: &MediaTaskOutput, +) -> ToolResult { + result + .with_metadata("task_id", serde_json::json!(output.task_id)) + .with_metadata("task_type", serde_json::json!(output.task_type)) + .with_metadata("task_family", serde_json::json!(output.task_family)) + .with_metadata("path", serde_json::json!(output.absolute_path)) + .with_metadata("artifact_path", serde_json::json!(output.path)) + .with_metadata( + "absolute_artifact_path", + serde_json::json!(output.absolute_artifact_path), + ) + .with_metadata("status", serde_json::json!(output.status)) + .with_metadata( + "current_attempt_id", + serde_json::json!(output.current_attempt_id), + ) + .with_metadata("artifact_paths", serde_json::json!(output.artifact_paths())) +} + +pub(crate) fn enrich_tool_result_from_media_cli_output( + result: ToolResult, + raw_output: &str, + app_handle: Option<&AppHandle>, +) -> ToolResult { + let Some(parsed_output) = parse_media_task_output(raw_output) else { + return result; + }; + + if let Some(app_handle) = app_handle { + emit_media_creation_task_event(app_handle, &parsed_output); + } + + attach_media_task_metadata(result, &parsed_output) +} + +#[cfg(test)] +mod tests { + use super::*; + use lime_media_runtime::{MediaTaskArtifactRecord, MediaTaskOutput}; + + #[test] + fn enrich_tool_result_from_media_cli_output_should_attach_artifact_metadata() { + let output = MediaTaskOutput { + success: true, + task_id: "task-1".to_string(), + task_type: "image_generate".to_string(), + task_family: "image".to_string(), + status: "pending_submit".to_string(), + normalized_status: "pending".to_string(), + current_attempt_id: Some("attempt-1".to_string()), + attempt_count: 1, + last_error: None, + progress: lime_media_runtime::TaskProgress::default(), + ui_hints: lime_media_runtime::TaskUiHints::default(), + path: ".lime/tasks/image_generate/demo.json".to_string(), + absolute_path: "/tmp/demo.json".to_string(), + artifact_path: ".lime/tasks/image_generate/demo.json".to_string(), + absolute_artifact_path: "/tmp/demo.json".to_string(), + reused_existing: false, + idempotency_key: None, + record: MediaTaskArtifactRecord { + task_id: "task-1".to_string(), + task_type: "image_generate".to_string(), + task_family: "image".to_string(), + title: None, + summary: Some("demo".to_string()), + payload: serde_json::json!({ "prompt": "demo" }), + status: "pending_submit".to_string(), + normalized_status: "pending".to_string(), + created_at: "2026-04-03T00:00:00Z".to_string(), + updated_at: None, + submitted_at: None, + started_at: None, + completed_at: None, + cancelled_at: None, + idempotency_key: None, + retry_count: 0, + source_task_id: None, + result: None, + last_error: None, + current_attempt_id: Some("attempt-1".to_string()), + attempts: vec![lime_media_runtime::TaskAttemptRecord { + attempt_id: "attempt-1".to_string(), + attempt_index: 1, + status: "pending_submit".to_string(), + input_snapshot: serde_json::json!({ "prompt": "demo" }), + ..lime_media_runtime::TaskAttemptRecord::default() + }], + relationships: lime_media_runtime::TaskRelationships::default(), + progress: lime_media_runtime::TaskProgress::default(), + ui_hints: lime_media_runtime::TaskUiHints::default(), + }, + }; + let serialized = serde_json::to_string(&output).expect("serialize output"); + + let result = enrich_tool_result_from_media_cli_output( + ToolResult::success(serialized.clone()), + &serialized, + None, + ); + + assert_eq!( + result.metadata.get("task_type"), + Some(&serde_json::json!("image_generate")) + ); + assert_eq!( + result.metadata.get("task_family"), + Some(&serde_json::json!("image")) + ); + assert_eq!( + result.metadata.get("artifact_paths"), + Some(&serde_json::json!([".lime/tasks/image_generate/demo.json"])) + ); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs index d9d69094a..4ca70c82c 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs @@ -326,7 +326,7 @@ impl Tool for SubAgentTaskTool { } fn description(&self) -> &str { - "兼容入口。仅用于兼容仍输出旧 SubAgentTask schema 的历史提示词或旧技能;内部会退化为串行的 spawn_agent + wait_agent,不适合作为新的多代理并发主路径。新实现优先直接使用 spawn_agent / send_input / wait_agent / resume_agent / close_agent。" + "兼容入口。仅用于兼容仍输出旧 SubAgentTask schema 的历史提示词或旧技能;内部会退化为串行 team runtime 调用,不适合作为新的多代理并发主路径。新实现优先直接使用 Agent / TeamCreate / TeamDelete / SendMessage / ListPeers。" } fn input_schema(&self) -> serde_json::Value { @@ -412,6 +412,7 @@ impl Tool for SubAgentTaskTool { team_name: None, agent_type: Some(role.to_string()), model: input.model.clone(), + run_in_background: false, reasoning_effort: None, fork_context: false, blueprint_role_id: None, @@ -425,6 +426,8 @@ impl Tool for SubAgentTaskTool { theme: None, system_overlay: None, output_contract: None, + mode: None, + isolation: None, cwd: None, }, ) @@ -499,7 +502,7 @@ impl Tool for SubAgentTaskTool { "role": role.to_string(), "status": status, "timed_out": wait_result.timed_out, - "compat_mode": "subagent_task->spawn_agent", + "compat_mode": "SubAgentTask->Agent", "compat_warnings": compat_warnings, }); @@ -535,6 +538,7 @@ fn build_agent_control_tool_config( team_name: request.team_name, agent_type: request.agent_type, model: request.model, + run_in_background: false, reasoning_effort: request.reasoning_effort, fork_context: request.fork_context, blueprint_role_id: request.blueprint_role_id, @@ -548,6 +552,8 @@ fn build_agent_control_tool_config( theme: request.theme, system_overlay: request.system_overlay, output_contract: request.output_contract, + mode: None, + isolation: None, cwd: request.cwd, }, ) @@ -587,4 +593,7 @@ pub(super) fn register_subagent_runtime_tools( ) { registry.register(Box::new(SubAgentTaskTool::new(runtime.clone()))); aster::tools::register_agent_control_tools(registry, &build_agent_control_tool_config(runtime)); + registry.register(Box::new(aster::tools::TeamCreateTool::new())); + registry.register(Box::new(aster::tools::TeamDeleteTool::new())); + registry.register(Box::new(aster::tools::ListPeersTool::new())); } diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs index 29dda887a..18521ed62 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs @@ -2,13 +2,18 @@ use super::*; pub(super) struct WorkspaceSandboxedBashTool { delegate: BashTool, + app_handle: AppHandle, sandbox_type_name: String, base_sandbox_config: ProcessSandboxConfig, auto_approve_warnings: bool, } impl WorkspaceSandboxedBashTool { - pub(super) fn new(workspace_root: &str, auto_approve_warnings: bool) -> Result { + pub(super) fn new( + workspace_root: &str, + auto_approve_warnings: bool, + app_handle: AppHandle, + ) -> Result { let workspace_root = workspace_root.trim(); if workspace_root.is_empty() { return Err("workspace 根目录为空".to_string()); @@ -68,6 +73,7 @@ impl WorkspaceSandboxedBashTool { Ok(Self { delegate: BashTool::new(), + app_handle, sandbox_type_name, base_sandbox_config, auto_approve_warnings, @@ -114,6 +120,9 @@ impl WorkspaceSandboxedBashTool { } fn build_shell_command(&self, command: &str, _context: &ToolContext) -> (String, Vec) { + let command = + lime_cli_runtime::prefix_shell_command_with_lime_cli(command, Some(&self.app_handle)); + #[cfg(target_os = "windows")] { return ( @@ -378,10 +387,10 @@ pub(crate) fn normalize_params_for_durable_memory_support( let mut changed = false; match tool_name { - "read" | "write" | "edit" | "grep" => { + "Read" | "Write" | "Edit" | "Grep" | "read" | "write" | "edit" | "grep" => { changed |= remap_virtual_memory_path_param(&mut normalized, "path")?; } - "glob" => { + "Glob" | "glob" => { changed |= remap_virtual_memory_path_param(&mut normalized, "path")?; changed |= remap_virtual_memory_glob_pattern(&mut normalized)?; } @@ -531,7 +540,7 @@ fn wrap_registry_native_tools_for_harness_observability(registry: &mut aster::to } fn wrap_registry_native_tools_for_durable_memory_fs(registry: &mut aster::tools::ToolRegistry) { - for tool_name in ["read", "write", "edit", "glob", "grep"] { + for tool_name in ["Read", "Write", "Edit", "Glob", "Grep"] { let Some(tool) = registry.unregister(tool_name) else { continue; }; @@ -639,7 +648,7 @@ impl Tool for WorkspaceSandboxedBashTool { &format!("{:?}", execution.sandbox_type), ); if execution.exit_code == 0 { - Ok(ToolResult::success(output) + let result = ToolResult::success(output) .with_metadata("exit_code", serde_json::json!(execution.exit_code)) .with_metadata("stdout_length", serde_json::json!(execution.stdout.len())) .with_metadata("stderr_length", serde_json::json!(execution.stderr.len())) @@ -647,9 +656,14 @@ impl Tool for WorkspaceSandboxedBashTool { .with_metadata( "sandbox_type", serde_json::json!(format!("{:?}", execution.sandbox_type)), - )) + ); + Ok(media_cli_bridge::enrich_tool_result_from_media_cli_output( + result, + &execution.stdout, + Some(&self.app_handle), + )) } else { - Ok(ToolResult::success(output) + let result = ToolResult::success(output) .with_metadata("exit_code", serde_json::json!(execution.exit_code)) .with_metadata("stdout_length", serde_json::json!(execution.stdout.len())) .with_metadata("stderr_length", serde_json::json!(execution.stderr.len())) @@ -658,7 +672,12 @@ impl Tool for WorkspaceSandboxedBashTool { "sandbox_type", serde_json::json!(format!("{:?}", execution.sandbox_type)), ) - .with_metadata("reported_success", serde_json::json!(false))) + .with_metadata("reported_success", serde_json::json!(false)); + Ok(media_cli_bridge::enrich_tool_result_from_media_cli_output( + result, + &execution.stdout, + Some(&self.app_handle), + )) } } } @@ -666,13 +685,19 @@ impl Tool for WorkspaceSandboxedBashTool { /// 统一处理 bash 工具的风险提示与共享任务管理器 struct WorkspaceBashTool { delegate: BashTool, + app_handle: AppHandle, auto_approve_warnings: bool, } impl WorkspaceBashTool { - fn new(auto_approve_warnings: bool, task_manager: Arc) -> Self { + fn new( + auto_approve_warnings: bool, + task_manager: Arc, + app_handle: AppHandle, + ) -> Self { Self { delegate: BashTool::with_task_manager(task_manager), + app_handle, auto_approve_warnings, } } @@ -714,8 +739,27 @@ impl Tool for WorkspaceBashTool { params: serde_json::Value, context: &ToolContext, ) -> Result { - let normalized_params = normalize_shell_command_params(¶ms); - self.delegate.execute(normalized_params, context).await + let mut normalized_params = normalize_shell_command_params(¶ms); + if let Some(object) = normalized_params.as_object_mut() { + if let Some(command) = object.get("command").and_then(|value| value.as_str()) { + object.insert( + "command".to_string(), + serde_json::Value::String( + lime_cli_runtime::prefix_shell_command_with_lime_cli( + command, + Some(&self.app_handle), + ), + ), + ); + } + } + let result = self.delegate.execute(normalized_params, context).await?; + let raw_output = result.output.as_deref().unwrap_or_default().to_string(); + Ok(media_cli_bridge::enrich_tool_result_from_media_cli_output( + result, + &raw_output, + Some(&self.app_handle), + )) } } @@ -799,11 +843,13 @@ pub(super) fn register_workspace_runtime_tools( registry: &mut aster::tools::ToolRegistry, task_manager: Arc, auto_approve_warnings: bool, + app_handle: AppHandle, sandboxed_bash_tool: Option, ) { registry.register(Box::new(WorkspaceBashTool::new( auto_approve_warnings, task_manager.clone(), + app_handle, ))); registry.register(Box::new(WorkspaceTaskOutputTool::new(task_manager.clone()))); registry.register(Box::new(TaskStopTool::with_task_manager(task_manager))); diff --git a/src-tauri/src/commands/claw_solution_cmd.rs b/src-tauri/src/commands/claw_solution_cmd.rs deleted file mode 100644 index bb2b1becf..000000000 --- a/src-tauri/src/commands/claw_solution_cmd.rs +++ /dev/null @@ -1,45 +0,0 @@ -use crate::database::DbConnection; -use crate::services::claw_solution_service::{ - ClawSolutionContext, ClawSolutionDetail, ClawSolutionPreparation, ClawSolutionReadinessResult, - ClawSolutionService, ClawSolutionSummary, -}; -use tauri::State; - -#[tauri::command] -pub async fn claw_solution_list( - db: State<'_, DbConnection>, -) -> Result, String> { - ClawSolutionService::default().list(&db).await -} - -#[tauri::command] -pub async fn claw_solution_detail( - db: State<'_, DbConnection>, - solution_id: String, -) -> Result { - ClawSolutionService::default() - .detail(&db, &solution_id) - .await -} - -#[tauri::command] -pub async fn claw_solution_check_readiness( - db: State<'_, DbConnection>, - solution_id: String, - _context: Option, -) -> Result { - ClawSolutionService::default() - .check_readiness(&db, &solution_id) - .await -} - -#[tauri::command] -pub async fn claw_solution_prepare( - db: State<'_, DbConnection>, - solution_id: String, - context: Option, -) -> Result { - ClawSolutionService::default() - .prepare(&db, &solution_id, context) - .await -} diff --git a/src-tauri/src/commands/mcp_cmd.rs b/src-tauri/src/commands/mcp_cmd.rs index d7c0d25f8..8555c5638 100644 --- a/src-tauri/src/commands/mcp_cmd.rs +++ b/src-tauri/src/commands/mcp_cmd.rs @@ -469,7 +469,7 @@ pub async fn mcp_search_tools( /// 调用 MCP 工具 /// /// 根据工具名称和参数调用指定的 MCP 工具。 -/// 工具名称可能包含服务器前缀(格式为 "server_toolname")。 +/// current 工具名称格式为 `mcp____`。 /// /// # Arguments /// diff --git a/src-tauri/src/commands/media_task_cmd.rs b/src-tauri/src/commands/media_task_cmd.rs new file mode 100644 index 000000000..4ace03338 --- /dev/null +++ b/src-tauri/src/commands/media_task_cmd.rs @@ -0,0 +1,534 @@ +//! 媒体任务 artifact 命令 +//! +//! 当前主链先承接 Claw `@配图` 的图片任务创建,统一落到 task file 协议。 + +use lime_media_runtime::{ + list_task_outputs, load_task_output, retry_task_artifact, update_task_status, + write_task_artifact, MediaTaskOutput, MediaTaskType, TaskWriteOptions, DEFAULT_ARTIFACT_ROOT, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use tauri::AppHandle; + +use crate::commands::aster_agent_cmd::tool_runtime::media_cli_bridge; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateImageGenerationTaskArtifactRequest { + pub project_root_path: String, + pub prompt: String, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub mode: Option, + #[serde(default)] + pub raw_text: Option, + #[serde(default)] + pub size: Option, + #[serde(default)] + pub aspect_ratio: Option, + #[serde(default)] + pub count: Option, + #[serde(default)] + pub usage: Option, + #[serde(default)] + pub style: Option, + #[serde(default)] + pub provider_id: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub project_id: Option, + #[serde(default)] + pub content_id: Option, + #[serde(default)] + pub entry_source: Option, + #[serde(default)] + pub requested_target: Option, + #[serde(default)] + pub target_output_id: Option, + #[serde(default)] + pub target_output_ref_id: Option, + #[serde(default)] + pub reference_images: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaTaskLookupRequest { + pub project_root_path: String, + pub task_ref: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListMediaTaskArtifactsRequest { + pub project_root_path: String, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub task_family: Option, + #[serde(default)] + pub task_type: Option, + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, Serialize)] +pub struct MediaTaskListFilters { + pub status: Option, + pub task_family: Option, + pub task_type: Option, + pub limit: Option, +} + +#[derive(Debug, Serialize)] +pub struct ListMediaTaskArtifactsResponse { + pub success: bool, + pub workspace_root: String, + pub artifact_root: String, + pub filters: MediaTaskListFilters, + pub total: usize, + pub tasks: Vec, +} + +fn normalize_optional_string(value: Option) -> Option { + value.and_then(|raw| { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn normalize_required_string(value: &str, field_name: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(format!("{field_name} 不能为空")); + } + Ok(trimmed.to_string()) +} + +fn normalize_reference_images(reference_images: Vec) -> Vec { + let mut normalized = Vec::new(); + for image in reference_images { + let trimmed = image.trim(); + if trimmed.is_empty() { + continue; + } + if normalized.iter().any(|existing| existing == trimmed) { + continue; + } + normalized.push(trimmed.to_string()); + } + normalized +} + +fn normalize_mode(value: Option) -> Result { + match value + .as_deref() + .map(str::trim) + .filter(|mode| !mode.is_empty()) + .unwrap_or("generate") + .to_ascii_lowercase() + .as_str() + { + "generate" => Ok("generate".to_string()), + "edit" => Ok("edit".to_string()), + "variation" | "variant" => Ok("variation".to_string()), + other => Err(format!("不支持的图片任务模式: {other}")), + } +} + +fn normalize_positive_count(value: Option) -> Result { + let count = value.unwrap_or(1); + if count == 0 { + return Err("count 必须大于 0".to_string()); + } + Ok(count.min(8)) +} + +fn build_image_task_idempotency_key( + request: &CreateImageGenerationTaskArtifactRequest, + mode: &str, + prompt: &str, + count: u32, + size: Option<&str>, + usage: Option<&str>, + reference_images: &[String], + target_output_ref_id: Option<&str>, +) -> Result { + let fingerprint = json!({ + "session_id": normalize_optional_string(request.session_id.clone()), + "project_id": normalize_optional_string(request.project_id.clone()), + "content_id": normalize_optional_string(request.content_id.clone()), + "entry_source": normalize_optional_string(request.entry_source.clone()), + "mode": mode, + "prompt": prompt, + "size": size, + "count": count, + "usage": usage, + "target_output_ref_id": target_output_ref_id, + "reference_images": reference_images, + }); + let serialized = serde_json::to_vec(&fingerprint) + .map_err(|error| format!("序列化图片任务幂等指纹失败: {error}"))?; + let mut hasher = Sha256::new(); + hasher.update(&serialized); + let digest = hasher.finalize(); + Ok(format!("image-task-{}", hex::encode(&digest[..16]))) +} + +fn emit_creation_task_event_if_needed(app: Option<&AppHandle>, output: &MediaTaskOutput) { + if let Some(app_handle) = app { + media_cli_bridge::emit_media_creation_task_event(app_handle, output); + } +} + +pub(crate) fn create_image_generation_task_artifact_inner( + request: CreateImageGenerationTaskArtifactRequest, +) -> Result { + let project_root_path = + normalize_required_string(&request.project_root_path, "projectRootPath")?; + let prompt = normalize_required_string(&request.prompt, "prompt")?; + + let mode = normalize_mode(request.mode.clone())?; + let count = normalize_positive_count(request.count)?; + let size = normalize_optional_string(request.size.clone()); + let aspect_ratio = normalize_optional_string(request.aspect_ratio.clone()); + let usage = normalize_optional_string(request.usage.clone()); + let style = normalize_optional_string(request.style.clone()); + let provider_id = normalize_optional_string(request.provider_id.clone()); + let model = normalize_optional_string(request.model.clone()); + let raw_text = normalize_optional_string(request.raw_text.clone()); + let session_id = normalize_optional_string(request.session_id.clone()); + let project_id = normalize_optional_string(request.project_id.clone()); + let content_id = normalize_optional_string(request.content_id.clone()); + let entry_source = normalize_optional_string(request.entry_source.clone()); + let requested_target = normalize_optional_string(request.requested_target.clone()); + let target_output_id = normalize_optional_string(request.target_output_id.clone()); + let target_output_ref_id = normalize_optional_string(request.target_output_ref_id.clone()); + let normalized_reference_images = normalize_reference_images(request.reference_images.clone()); + + let idempotency_key = build_image_task_idempotency_key( + &request, + &mode, + &prompt, + count, + size.as_deref(), + usage.as_deref(), + &normalized_reference_images, + target_output_ref_id.as_deref(), + )?; + + write_task_artifact( + std::path::Path::new(project_root_path.as_str()), + MediaTaskType::ImageGenerate, + normalize_optional_string(request.title), + json!({ + "prompt": prompt.as_str(), + "mode": mode, + "raw_text": raw_text, + "provider_id": provider_id, + "model": model, + "style": style, + "size": size, + "aspect_ratio": aspect_ratio, + "count": count, + "usage": usage, + "session_id": session_id, + "project_id": project_id, + "content_id": content_id, + "entry_source": entry_source, + "requested_target": requested_target, + "target_output_id": target_output_id, + "target_output_ref_id": target_output_ref_id, + "reference_images": normalized_reference_images, + }), + TaskWriteOptions { + status: Some("pending_submit".to_string()), + output_path: None, + artifact_dir: None, + idempotency_key: Some(idempotency_key.as_str()), + }, + ) + .map_err(|error| format!("创建图片任务 artifact 失败: {error}")) +} + +pub(crate) fn get_media_task_artifact_inner( + request: MediaTaskLookupRequest, +) -> Result { + let project_root_path = + normalize_required_string(&request.project_root_path, "projectRootPath")?; + let task_ref = normalize_required_string(&request.task_ref, "taskRef")?; + load_task_output( + std::path::Path::new(project_root_path.as_str()), + task_ref.as_str(), + None, + ) + .map_err(|error| format!("读取媒体任务 artifact 失败: {error}")) +} + +pub(crate) fn list_media_task_artifacts_inner( + request: ListMediaTaskArtifactsRequest, +) -> Result { + let project_root_path = + normalize_required_string(&request.project_root_path, "projectRootPath")?; + let status_filter = normalize_optional_string(request.status); + let task_family_filter = normalize_optional_string(request.task_family); + let task_type_filter = normalize_optional_string(request.task_type); + let parsed_task_type = task_type_filter + .as_deref() + .map(|value| { + value + .parse::() + .map_err(|_| format!("不支持的 taskType: {value}")) + }) + .transpose()?; + let tasks = list_task_outputs( + std::path::Path::new(project_root_path.as_str()), + None, + status_filter.as_deref(), + task_family_filter.as_deref(), + parsed_task_type, + request.limit, + ) + .map_err(|error| format!("列出媒体任务 artifact 失败: {error}"))?; + + Ok(ListMediaTaskArtifactsResponse { + success: true, + workspace_root: project_root_path.clone(), + artifact_root: std::path::Path::new(project_root_path.as_str()) + .join(DEFAULT_ARTIFACT_ROOT) + .to_string_lossy() + .to_string(), + filters: MediaTaskListFilters { + status: status_filter, + task_family: task_family_filter, + task_type: parsed_task_type.map(|value| value.as_str().to_string()), + limit: request.limit, + }, + total: tasks.len(), + tasks, + }) +} + +pub(crate) fn retry_media_task_artifact_inner( + request: MediaTaskLookupRequest, +) -> Result { + let project_root_path = + normalize_required_string(&request.project_root_path, "projectRootPath")?; + let task_ref = normalize_required_string(&request.task_ref, "taskRef")?; + retry_task_artifact( + std::path::Path::new(project_root_path.as_str()), + task_ref.as_str(), + None, + ) + .map_err(|error| format!("重试媒体任务 artifact 失败: {error}")) +} + +pub(crate) fn cancel_media_task_artifact_inner( + request: MediaTaskLookupRequest, +) -> Result { + let project_root_path = + normalize_required_string(&request.project_root_path, "projectRootPath")?; + let task_ref = normalize_required_string(&request.task_ref, "taskRef")?; + update_task_status( + std::path::Path::new(project_root_path.as_str()), + task_ref.as_str(), + None, + "cancelled", + ) + .map_err(|error| format!("取消媒体任务 artifact 失败: {error}")) +} + +#[tauri::command] +pub fn create_image_generation_task_artifact( + app: AppHandle, + request: CreateImageGenerationTaskArtifactRequest, +) -> Result { + let output = create_image_generation_task_artifact_inner(request)?; + emit_creation_task_event_if_needed(Some(&app), &output); + Ok(output) +} + +#[tauri::command] +pub fn get_media_task_artifact(request: MediaTaskLookupRequest) -> Result { + get_media_task_artifact_inner(request) +} + +#[tauri::command] +pub fn list_media_task_artifacts( + request: ListMediaTaskArtifactsRequest, +) -> Result { + list_media_task_artifacts_inner(request) +} + +#[tauri::command] +pub fn retry_media_task_artifact( + app: AppHandle, + request: MediaTaskLookupRequest, +) -> Result { + let output = retry_media_task_artifact_inner(request)?; + emit_creation_task_event_if_needed(Some(&app), &output); + Ok(output) +} + +#[tauri::command] +pub fn cancel_media_task_artifact( + request: MediaTaskLookupRequest, +) -> Result { + cancel_media_task_artifact_inner(request) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_image_generation_task_artifact_inner_should_write_context_payload_and_idempotency() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let request = CreateImageGenerationTaskArtifactRequest { + project_root_path: temp_dir.path().to_string_lossy().to_string(), + prompt: "未来感青柠实验室".to_string(), + title: Some("青柠主视觉".to_string()), + mode: Some("variation".to_string()), + raw_text: Some("@配图 变体 #img-1 未来感青柠实验室".to_string()), + size: Some("1024x1024".to_string()), + aspect_ratio: Some("1:1".to_string()), + count: Some(2), + usage: Some("claw-image-workbench".to_string()), + style: Some("cinematic".to_string()), + provider_id: Some("fal".to_string()), + model: Some("fal-ai/nano-banana".to_string()), + session_id: Some("session-1".to_string()), + project_id: Some("project-1".to_string()), + content_id: Some("content-1".to_string()), + entry_source: Some("at_image_command".to_string()), + requested_target: Some("generate".to_string()), + target_output_id: Some("task-a:output:1".to_string()), + target_output_ref_id: Some("img-1".to_string()), + reference_images: vec![ + "https://example.com/reference-a.png".to_string(), + "https://example.com/reference-a.png".to_string(), + "".to_string(), + ], + }; + + let first = create_image_generation_task_artifact_inner(request).expect("create first"); + let second = + create_image_generation_task_artifact_inner(CreateImageGenerationTaskArtifactRequest { + project_root_path: temp_dir.path().to_string_lossy().to_string(), + prompt: "未来感青柠实验室".to_string(), + title: Some("青柠主视觉".to_string()), + mode: Some("variation".to_string()), + raw_text: Some("@配图 变体 #img-1 未来感青柠实验室".to_string()), + size: Some("1024x1024".to_string()), + aspect_ratio: Some("1:1".to_string()), + count: Some(2), + usage: Some("claw-image-workbench".to_string()), + style: Some("cinematic".to_string()), + provider_id: Some("fal".to_string()), + model: Some("fal-ai/nano-banana".to_string()), + session_id: Some("session-1".to_string()), + project_id: Some("project-1".to_string()), + content_id: Some("content-1".to_string()), + entry_source: Some("at_image_command".to_string()), + requested_target: Some("generate".to_string()), + target_output_id: Some("task-a:output:1".to_string()), + target_output_ref_id: Some("img-1".to_string()), + reference_images: vec![ + "https://example.com/reference-a.png".to_string(), + "https://example.com/reference-a.png".to_string(), + ], + }) + .expect("create second"); + + assert_eq!(first.task_id, second.task_id); + assert!(second.reused_existing); + assert_eq!(first.record.payload.get("mode"), Some(&json!("variation"))); + assert_eq!( + first.record.payload.get("session_id"), + Some(&json!("session-1")) + ); + assert_eq!( + first.record.payload.get("entry_source"), + Some(&json!("at_image_command")) + ); + assert_eq!( + first.record.payload.get("reference_images"), + Some(&json!(["https://example.com/reference-a.png"])) + ); + } + + #[test] + fn media_task_artifact_controls_should_share_same_task_file_protocol() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let created = + create_image_generation_task_artifact_inner(CreateImageGenerationTaskArtifactRequest { + project_root_path: temp_dir.path().to_string_lossy().to_string(), + prompt: "用于正文的未来感实验室配图".to_string(), + title: Some("正文配图".to_string()), + mode: Some("generate".to_string()), + raw_text: Some("@配图 生成 用于正文的未来感实验室配图".to_string()), + size: Some("1024x1024".to_string()), + aspect_ratio: None, + count: Some(1), + usage: Some("claw-image-workbench".to_string()), + style: None, + provider_id: Some("fal".to_string()), + model: Some("fal-ai/flux-pro".to_string()), + session_id: Some("session-2".to_string()), + project_id: Some("project-2".to_string()), + content_id: Some("content-2".to_string()), + entry_source: Some("at_image_command".to_string()), + requested_target: Some("generate".to_string()), + target_output_id: None, + target_output_ref_id: None, + reference_images: Vec::new(), + }) + .expect("create task"); + + let loaded = get_media_task_artifact_inner(MediaTaskLookupRequest { + project_root_path: temp_dir.path().to_string_lossy().to_string(), + task_ref: created.task_id.clone(), + }) + .expect("load task"); + assert_eq!(loaded.task_id, created.task_id); + + let listed = list_media_task_artifacts_inner(ListMediaTaskArtifactsRequest { + project_root_path: temp_dir.path().to_string_lossy().to_string(), + status: Some("pending".to_string()), + task_family: Some("image".to_string()), + task_type: Some("image_generate".to_string()), + limit: Some(10), + }) + .expect("list tasks"); + assert_eq!(listed.total, 1); + assert_eq!(listed.tasks[0].task_id, created.task_id); + + let cancelled = cancel_media_task_artifact_inner(MediaTaskLookupRequest { + project_root_path: temp_dir.path().to_string_lossy().to_string(), + task_ref: created.task_id.clone(), + }) + .expect("cancel task"); + assert_eq!(cancelled.normalized_status, "cancelled"); + assert!(cancelled.record.cancelled_at.is_some()); + + let retried = retry_media_task_artifact_inner(MediaTaskLookupRequest { + project_root_path: temp_dir.path().to_string_lossy().to_string(), + task_ref: created.task_id.clone(), + }) + .expect("retry task"); + assert_eq!(retried.task_id, created.task_id); + assert_eq!(retried.attempt_count, 2); + assert_eq!(retried.record.attempts.len(), 2); + assert_eq!(retried.normalized_status, "pending"); + assert_eq!(retried.record.retry_count, 1); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d58893e04..6b8980b70 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -10,7 +10,6 @@ pub mod browser_environment_cmd; pub mod browser_profile_cmd; pub mod browser_runtime_cmd; pub mod channels_cmd; -pub mod claw_solution_cmd; pub mod companion_cmd; pub mod config_cmd; pub mod connect_cmd; @@ -32,6 +31,7 @@ pub mod kiro_local; pub mod machine_id_cmd; pub mod material_cmd; pub mod mcp_cmd; +pub mod media_task_cmd; pub mod memory_cmd; pub mod memory_feedback_cmd; pub mod memory_management_cmd; diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 258c0f0de..af7f349d4 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -9,6 +9,7 @@ mod browser; mod companion; mod content; mod logs; +mod media_tasks; mod memory; mod memory_runtime; mod models; @@ -104,6 +105,10 @@ pub async fn handle_command( return Ok(result); } + if let Some(result) = media_tasks::try_handle(state, cmd, args.as_ref()).await? { + return Ok(result); + } + if let Some(result) = providers::try_handle(state, cmd, args.as_ref()).await? { return Ok(result); } @@ -299,19 +304,6 @@ mod tests { assert_eq!(list[0].id, created.id); } - #[tokio::test] - async fn claw_solution_list_bridge_query_available() { - let state = make_test_state(); - let list_value = handle_command(&state, "claw_solution_list", None) - .await - .unwrap(); - let list = list_value.as_array().unwrap(); - - assert!(!list.is_empty()); - assert!(list[0]["id"].is_string()); - assert!(list[0]["readiness"].is_string()); - } - #[tokio::test] async fn voice_shortcut_status_bridge_query_available() { let state = make_test_state(); @@ -538,4 +530,47 @@ mod tests { .unwrap(); assert_eq!(active_list_after_restore.as_array().unwrap().len(), 1); } + + #[tokio::test] + async fn skill_execution_catalog_commands_are_bridged() { + let state = make_test_state(); + + let list_value = handle_command(&state, "list_executable_skills", None) + .await + .unwrap(); + let skills = list_value.as_array().expect("skills should be array"); + assert!(!skills.is_empty()); + assert!(skills.iter().any(|item| item["name"] == "image_generate")); + + let detail_value = handle_command( + &state, + "get_skill_detail", + Some(serde_json::json!({ + "skillName": "image_generate" + })), + ) + .await + .unwrap(); + + assert_eq!(detail_value["name"], "image_generate"); + assert_eq!(detail_value["execution_mode"], "prompt"); + } + + #[tokio::test] + async fn execute_skill_bridge_requires_app_handle_in_test_state() { + let state = make_test_state(); + + let error = handle_command( + &state, + "execute_skill", + Some(serde_json::json!({ + "skillName": "image_generate", + "userInput": "画一张春日花园海报" + })), + ) + .await + .expect_err("execute_skill without app handle should fail"); + + assert!(error.to_string().contains("Dev Bridge 未持有 AppHandle")); + } } diff --git a/src-tauri/src/dev_bridge/dispatcher/media_tasks.rs b/src-tauri/src/dev_bridge/dispatcher/media_tasks.rs new file mode 100644 index 000000000..5c51c8523 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/media_tasks.rs @@ -0,0 +1,64 @@ +use super::{args_or_default, parse_nested_arg}; +use crate::commands::aster_agent_cmd::tool_runtime::media_cli_bridge; +use crate::commands::media_task_cmd::{ + cancel_media_task_artifact_inner, create_image_generation_task_artifact_inner, + get_media_task_artifact_inner, list_media_task_artifacts_inner, + retry_media_task_artifact_inner, CreateImageGenerationTaskArtifactRequest, + ListMediaTaskArtifactsRequest, MediaTaskLookupRequest, +}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; +use std::io; + +type DynError = Box; + +fn to_dyn_error(message: String) -> DynError { + io::Error::other(message).into() +} + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + let result = match cmd { + "create_image_generation_task_artifact" => { + let args = args_or_default(args); + let request: CreateImageGenerationTaskArtifactRequest = + parse_nested_arg(&args, "request")?; + let output = + create_image_generation_task_artifact_inner(request).map_err(to_dyn_error)?; + if let Some(app_handle) = state.app_handle.as_ref() { + media_cli_bridge::emit_media_creation_task_event(app_handle, &output); + } + serde_json::to_value(output)? + } + "get_media_task_artifact" => { + let args = args_or_default(args); + let request: MediaTaskLookupRequest = parse_nested_arg(&args, "request")?; + serde_json::to_value(get_media_task_artifact_inner(request).map_err(to_dyn_error)?)? + } + "list_media_task_artifacts" => { + let args = args_or_default(args); + let request: ListMediaTaskArtifactsRequest = parse_nested_arg(&args, "request")?; + serde_json::to_value(list_media_task_artifacts_inner(request).map_err(to_dyn_error)?)? + } + "retry_media_task_artifact" => { + let args = args_or_default(args); + let request: MediaTaskLookupRequest = parse_nested_arg(&args, "request")?; + let output = retry_media_task_artifact_inner(request).map_err(to_dyn_error)?; + if let Some(app_handle) = state.app_handle.as_ref() { + media_cli_bridge::emit_media_creation_task_event(app_handle, &output); + } + serde_json::to_value(output)? + } + "cancel_media_task_artifact" => { + let args = args_or_default(args); + let request: MediaTaskLookupRequest = parse_nested_arg(&args, "request")?; + serde_json::to_value(cancel_media_task_artifact_inner(request).map_err(to_dyn_error)?)? + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs b/src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs index 133cec041..30e787022 100644 --- a/src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs +++ b/src-tauri/src/dev_bridge/dispatcher/openclaw/queries.rs @@ -1,51 +1,13 @@ -use super::super::{args_or_default, get_db, get_string_arg, parse_optional_nested_arg}; use super::{openclaw_context, DynError}; use crate::dev_bridge::DevBridgeState; -use crate::services::claw_solution_service::{ClawSolutionContext, ClawSolutionService}; use serde_json::Value as JsonValue; pub(super) async fn try_handle( state: &DevBridgeState, cmd: &str, - args: Option<&JsonValue>, + _args: Option<&JsonValue>, ) -> Result, DynError> { let result = match cmd { - "claw_solution_list" => { - let db = get_db(state)?; - serde_json::to_value(ClawSolutionService::default().list(db).await?)? - } - "claw_solution_detail" => { - let args = args_or_default(args); - let solution_id = get_string_arg(&args, "solutionId", "solution_id")?; - let db = get_db(state)?; - serde_json::to_value( - ClawSolutionService::default() - .detail(db, &solution_id) - .await?, - )? - } - "claw_solution_check_readiness" => { - let args = args_or_default(args); - let solution_id = get_string_arg(&args, "solutionId", "solution_id")?; - let _: Option = parse_optional_nested_arg(&args, "context")?; - let db = get_db(state)?; - serde_json::to_value( - ClawSolutionService::default() - .check_readiness(db, &solution_id) - .await?, - )? - } - "claw_solution_prepare" => { - let args = args_or_default(args); - let solution_id = get_string_arg(&args, "solutionId", "solution_id")?; - let context: Option = parse_optional_nested_arg(&args, "context")?; - let db = get_db(state)?; - serde_json::to_value( - ClawSolutionService::default() - .prepare(db, &solution_id, context) - .await?, - )? - } "openclaw_check_installed" => { let (_app_handle, service) = openclaw_context(state)?; let service = service.lock().await; diff --git a/src-tauri/src/dev_bridge/dispatcher/skills.rs b/src-tauri/src/dev_bridge/dispatcher/skills.rs index e2317ec99..117a7f01b 100644 --- a/src-tauri/src/dev_bridge/dispatcher/skills.rs +++ b/src-tauri/src/dev_bridge/dispatcher/skills.rs @@ -1,9 +1,17 @@ -use super::{args_or_default, get_string_arg}; +use super::{args_or_default, get_string_arg, require_app_handle}; use crate::dev_bridge::DevBridgeState; use serde_json::Value as JsonValue; +use tauri::Manager; type DynError = Box; +fn get_optional_string_arg(args: &JsonValue, primary: &str, secondary: &str) -> Option { + args.get(primary) + .or_else(|| args.get(secondary)) + .and_then(|value| value.as_str()) + .map(ToString::to_string) +} + pub(super) async fn try_handle( state: &DevBridgeState, cmd: &str, @@ -123,6 +131,52 @@ pub(super) async fn try_handle( .map_err(|e| format!("检查远程 Skill 失败: {e}"))?; serde_json::to_value(inspection)? } + "list_executable_skills" => serde_json::to_value( + crate::commands::skill_exec_cmd::list_executable_skills() + .await + .map_err(|e| format!("获取可执行 Skill 列表失败: {e}"))?, + )?, + "get_skill_detail" => { + let args = args_or_default(args); + let skill_name = get_string_arg(&args, "skillName", "skill_name") + .or_else(|_| get_string_arg(&args, "skill_name", "skillName"))?; + serde_json::to_value( + crate::commands::skill_exec_cmd::get_skill_detail(skill_name) + .await + .map_err(|e| format!("获取 Skill 详情失败: {e}"))?, + )? + } + "execute_skill" => { + let app_handle = require_app_handle(state)?; + let args = args_or_default(args); + let skill_name = get_string_arg(&args, "skillName", "skill_name") + .or_else(|_| get_string_arg(&args, "skill_name", "skillName"))?; + let user_input = get_string_arg(&args, "userInput", "user_input") + .or_else(|_| get_string_arg(&args, "user_input", "userInput"))?; + let db = app_handle.state::(); + let api_key_provider_service = + app_handle + .state::(); + let config_manager = app_handle.state::(); + let aster_state = app_handle.state::(); + serde_json::to_value( + crate::commands::skill_exec_cmd::execute_skill( + app_handle.clone(), + db, + api_key_provider_service, + config_manager, + aster_state, + skill_name, + user_input, + get_optional_string_arg(&args, "providerOverride", "provider_override"), + get_optional_string_arg(&args, "modelOverride", "model_override"), + get_optional_string_arg(&args, "executionId", "execution_id"), + get_optional_string_arg(&args, "sessionId", "session_id"), + ) + .await + .map_err(|e| format!("执行 Skill 失败: {e}"))?, + )? + } _ => return Ok(None), }; diff --git a/src-tauri/src/dev_bridge/dispatcher/voice.rs b/src-tauri/src/dev_bridge/dispatcher/voice.rs index 50379f51c..5ddbb6cec 100644 --- a/src-tauri/src/dev_bridge/dispatcher/voice.rs +++ b/src-tauri/src/dev_bridge/dispatcher/voice.rs @@ -95,8 +95,10 @@ pub(super) async fn try_handle( JsonValue::Null } "open_voice_window" => { + let args = args_or_default(args); + let target = get_optional_string_arg(&args, "target", "target"); let app_handle = require_app_handle(state)?; - crate::voice::commands::open_voice_window(app_handle).await?; + crate::voice::commands::open_voice_window(app_handle, target).await?; JsonValue::Null } "close_voice_window" => { diff --git a/src-tauri/src/services/claw_solution_service.rs b/src-tauri/src/services/claw_solution_service.rs deleted file mode 100644 index b2f5c0da9..000000000 --- a/src-tauri/src/services/claw_solution_service.rs +++ /dev/null @@ -1,526 +0,0 @@ -use crate::commands::provider_pool_cmd::check_playwright_available; -use crate::database::DbConnection; -use lime_services::api_key_provider_service::ApiKeyProviderService; -use lime_services::model_service::ModelService; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ClawSolutionReadiness { - Ready, - NeedsSetup, - NeedsCapability, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ClawSolutionActionType { - FillInput, - NavigateTheme, - LaunchBrowserAssist, - EnableTeamMode, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ClawSolutionReasonCode { - MissingModel, - MissingBrowserCapability, - MissingSkillDependency, - TeamRecommended, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClawSolutionSummary { - pub id: String, - pub title: String, - pub summary: String, - pub output_hint: String, - pub recommended_capabilities: Vec, - pub readiness: ClawSolutionReadiness, - pub readiness_message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason_code: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClawSolutionDetail { - #[serde(flatten)] - pub summary: ClawSolutionSummary, - pub starter_prompt: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub theme_target: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub followup_mode: Option, - pub capability_tags: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClawSolutionReadinessResult { - pub solution_id: String, - pub readiness: ClawSolutionReadiness, - pub readiness_message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason_code: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct ClawSolutionContext { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub project_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub user_input: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClawSolutionPreparation { - pub solution_id: String, - pub action_type: ClawSolutionActionType, - pub prompt: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub theme_target: Option, - pub should_launch_browser_assist: bool, - pub should_enable_team_mode: bool, - pub readiness: ClawSolutionReadiness, - pub readiness_message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason_code: Option, -} - -#[derive(Debug, Clone, Copy)] -struct ClawSolutionDefinition { - id: &'static str, - title: &'static str, - summary: &'static str, - output_hint: &'static str, - starter_prompt: &'static str, - theme_target: Option<&'static str>, - followup_mode: Option<&'static str>, - capability_tags: &'static [&'static str], - recommended_capabilities: &'static [&'static str], - action_type: ClawSolutionActionType, - needs_browser_capability: bool, - should_enable_team_mode: bool, -} - -#[derive(Debug, Clone, Copy)] -struct CapabilitySnapshot { - has_available_models: bool, - browser_available: bool, -} - -const CLAW_SOLUTIONS: [ClawSolutionDefinition; 6] = [ - ClawSolutionDefinition { - id: "web-research-brief", - title: "网页研究简报", - summary: "快速整理调研范围、关键信息与结论框架,适合先把研究任务落成一版结构化简报。", - output_hint: "研究提纲 + 结论简报", - starter_prompt: "请围绕这个主题先给我做一版网页研究简报:明确研究目标、关键信息来源、核心发现、风险点,以及接下来最值得继续追踪的问题。", - theme_target: None, - followup_mode: Some("iterative"), - capability_tags: &["research", "brief"], - recommended_capabilities: &["模型", "研究"], - action_type: ClawSolutionActionType::FillInput, - needs_browser_capability: false, - should_enable_team_mode: false, - }, - ClawSolutionDefinition { - id: "social-post-starter", - title: "社媒主稿生成", - summary: "先进入社媒专项工作台,再围绕平台语境、结构和表达生成可继续迭代的首稿。", - output_hint: "社媒首稿 + 平台结构", - starter_prompt: "请先帮我起草一版社媒内容首稿:明确目标受众、平台语境、标题方向、正文结构和可继续扩写的角度。", - theme_target: Some("social-media"), - followup_mode: Some("gui"), - capability_tags: &["social-media", "draft"], - recommended_capabilities: &["模型", "社媒主题"], - action_type: ClawSolutionActionType::NavigateTheme, - needs_browser_capability: false, - should_enable_team_mode: false, - }, - ClawSolutionDefinition { - id: "frontend-concept", - title: "前端概念方案", - summary: "快速产出信息架构、关键模块与页面关系,适合产品概念、后台台架或工作台原型讨论。", - output_hint: "IA + 模块方案", - starter_prompt: "请帮我先整理一版前端概念方案:输出信息架构、核心页面、关键模块、交互流程和第一轮组件拆分建议。", - theme_target: None, - followup_mode: Some("iterative"), - capability_tags: &["frontend", "architecture"], - recommended_capabilities: &["模型", "结构化输出"], - action_type: ClawSolutionActionType::FillInput, - needs_browser_capability: false, - should_enable_team_mode: false, - }, - ClawSolutionDefinition { - id: "slide-outline", - title: "演示提纲草案", - summary: "先拿到一版可讲述的演示结构,覆盖封面、问题、观点、案例与行动建议。", - output_hint: "PPT 大纲 + 讲述线", - starter_prompt: "请基于这个目标先生成一版演示提纲:包含封面定位、目录、核心论点、案例支撑、结论和下一步行动。", - theme_target: None, - followup_mode: Some("iterative"), - capability_tags: &["slides", "outline"], - recommended_capabilities: &["模型", "结构化输出"], - action_type: ClawSolutionActionType::FillInput, - needs_browser_capability: false, - should_enable_team_mode: false, - }, - ClawSolutionDefinition { - id: "browser-assist-task", - title: "浏览器协助办事", - summary: "适合登录、表单、网页操作和信息采集任务,进入工作区后直接接管浏览器协助链路。", - output_hint: "浏览器任务执行", - starter_prompt: "请协助我完成一个浏览器任务:先明确目标网页、目标动作、约束条件和预期结果,再进入执行。", - theme_target: None, - followup_mode: Some("browser_assist"), - capability_tags: &["browser", "automation"], - recommended_capabilities: &["模型", "浏览器协助"], - action_type: ClawSolutionActionType::LaunchBrowserAssist, - needs_browser_capability: true, - should_enable_team_mode: false, - }, - ClawSolutionDefinition { - id: "team-breakdown", - title: "多代理拆任务", - summary: "适合需要并行调研、方案拆解或多角色协作的任务,进入后默认启用 team runtime 偏好。", - output_hint: "任务拆解 + 分工执行", - starter_prompt: "请把这个任务按多代理方式拆解:先定义目标和约束,再拆成并行子任务,明确每个子代理的职责、产出和回收方式。", - theme_target: None, - followup_mode: Some("team_runtime"), - capability_tags: &["team", "decomposition"], - recommended_capabilities: &["模型", "多代理"], - action_type: ClawSolutionActionType::EnableTeamMode, - needs_browser_capability: false, - should_enable_team_mode: true, - }, -]; - -#[derive(Debug, Default)] -pub struct ClawSolutionService; - -impl ClawSolutionService { - pub async fn list(&self, db: &DbConnection) -> Result, String> { - let snapshot = self.build_capability_snapshot(db, true).await?; - - Ok(CLAW_SOLUTIONS - .iter() - .map(|definition| self.to_summary(definition, snapshot)) - .collect()) - } - - pub async fn detail( - &self, - db: &DbConnection, - solution_id: &str, - ) -> Result { - let definition = self.find_definition(solution_id)?; - let snapshot = self - .build_capability_snapshot(db, definition.needs_browser_capability) - .await?; - - Ok(self.to_detail(definition, snapshot)) - } - - pub async fn check_readiness( - &self, - db: &DbConnection, - solution_id: &str, - ) -> Result { - let definition = self.find_definition(solution_id)?; - let snapshot = self - .build_capability_snapshot(db, definition.needs_browser_capability) - .await?; - let readiness = self.resolve_readiness(definition, snapshot); - - Ok(ClawSolutionReadinessResult { - solution_id: definition.id.to_string(), - readiness: readiness.readiness, - readiness_message: readiness.message, - reason_code: readiness.reason_code, - }) - } - - pub async fn prepare( - &self, - db: &DbConnection, - solution_id: &str, - context: Option, - ) -> Result { - let definition = self.find_definition(solution_id)?; - let snapshot = self - .build_capability_snapshot(db, definition.needs_browser_capability) - .await?; - let readiness = self.resolve_readiness(definition, snapshot); - let context = context.unwrap_or_default(); - - Ok(ClawSolutionPreparation { - solution_id: definition.id.to_string(), - action_type: definition.action_type, - prompt: self.build_prompt(definition, &context), - theme_target: definition.theme_target.map(str::to_string), - should_launch_browser_assist: definition.needs_browser_capability, - should_enable_team_mode: definition.should_enable_team_mode, - readiness: readiness.readiness, - readiness_message: readiness.message, - reason_code: readiness.reason_code, - }) - } - - async fn build_capability_snapshot( - &self, - db: &DbConnection, - needs_browser_capability: bool, - ) -> Result { - let has_available_models = - self.has_current_llm_configuration(db)? || self.has_compat_available_models(db)?; - let browser_available = if needs_browser_capability { - check_playwright_available().await?.available - } else { - true - }; - - Ok(CapabilitySnapshot { - has_available_models, - browser_available, - }) - } - - fn has_current_llm_configuration(&self, db: &DbConnection) -> Result { - let providers = ApiKeyProviderService::new().get_all_providers(db)?; - - Ok(providers - .into_iter() - .any(|item| item.provider.enabled && item.api_keys.iter().any(|key| key.enabled))) - } - - fn has_compat_available_models(&self, db: &DbConnection) -> Result { - Ok(!ModelService::new().get_all_available_models(db)?.is_empty()) - } - - fn find_definition( - &self, - solution_id: &str, - ) -> Result<&'static ClawSolutionDefinition, String> { - CLAW_SOLUTIONS - .iter() - .find(|definition| definition.id == solution_id) - .ok_or_else(|| format!("未找到 Claw 方案: {solution_id}")) - } - - fn to_summary( - &self, - definition: &ClawSolutionDefinition, - snapshot: CapabilitySnapshot, - ) -> ClawSolutionSummary { - let readiness = self.resolve_readiness(definition, snapshot); - ClawSolutionSummary { - id: definition.id.to_string(), - title: definition.title.to_string(), - summary: definition.summary.to_string(), - output_hint: definition.output_hint.to_string(), - recommended_capabilities: definition - .recommended_capabilities - .iter() - .map(|item| (*item).to_string()) - .collect(), - readiness: readiness.readiness, - readiness_message: readiness.message, - reason_code: readiness.reason_code, - } - } - - fn to_detail( - &self, - definition: &ClawSolutionDefinition, - snapshot: CapabilitySnapshot, - ) -> ClawSolutionDetail { - ClawSolutionDetail { - summary: self.to_summary(definition, snapshot), - starter_prompt: definition.starter_prompt.to_string(), - theme_target: definition.theme_target.map(str::to_string), - followup_mode: definition.followup_mode.map(str::to_string), - capability_tags: definition - .capability_tags - .iter() - .map(|item| (*item).to_string()) - .collect(), - } - } - - fn build_prompt( - &self, - definition: &ClawSolutionDefinition, - context: &ClawSolutionContext, - ) -> String { - let user_input = context - .user_input - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - - match user_input { - Some(value) => format!("{}\n\n补充上下文:{}", definition.starter_prompt, value), - None => definition.starter_prompt.to_string(), - } - } - - fn resolve_readiness( - &self, - definition: &ClawSolutionDefinition, - snapshot: CapabilitySnapshot, - ) -> ResolvedReadiness { - if !snapshot.has_available_models { - return ResolvedReadiness::new( - ClawSolutionReadiness::NeedsSetup, - "先配置可用模型后再开始", - Some(ClawSolutionReasonCode::MissingModel), - ); - } - - if definition.needs_browser_capability && !snapshot.browser_available { - return ResolvedReadiness::new( - ClawSolutionReadiness::NeedsCapability, - "先安装或连接可用浏览器能力", - Some(ClawSolutionReasonCode::MissingBrowserCapability), - ); - } - - if definition.should_enable_team_mode { - return ResolvedReadiness::new( - ClawSolutionReadiness::Ready, - "可直接开始,进入后会启用多代理偏好", - Some(ClawSolutionReasonCode::TeamRecommended), - ); - } - - ResolvedReadiness::new(ClawSolutionReadiness::Ready, "可直接开始", None) - } -} - -#[derive(Debug, Clone)] -struct ResolvedReadiness { - readiness: ClawSolutionReadiness, - message: String, - reason_code: Option, -} - -impl ResolvedReadiness { - fn new( - readiness: ClawSolutionReadiness, - message: impl Into, - reason_code: Option, - ) -> Self { - Self { - readiness, - message: message.into(), - reason_code, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::database::schema::create_tables; - use lime_core::database::dao::api_key_provider::ApiProviderType; - use lime_core::database::dao::provider_pool::ProviderPoolDao; - use lime_core::models::provider_pool_model::{ - CredentialData, PoolProviderType, ProviderCredential, - }; - use lime_services::api_key_provider_service::ApiKeyProviderService; - use rusqlite::Connection; - use std::sync::{Arc, Mutex}; - - fn setup_db() -> DbConnection { - let conn = Connection::open_in_memory().expect("创建内存数据库失败"); - create_tables(&conn).expect("创建数据表失败"); - Arc::new(Mutex::new(conn)) - } - - #[tokio::test] - async fn list_should_mark_ready_when_api_key_provider_is_configured() { - let db = setup_db(); - let api_key_service = ApiKeyProviderService::new(); - let provider = api_key_service - .add_custom_provider( - &db, - "测试 Anthropic 兼容 Provider".to_string(), - ApiProviderType::AnthropicCompatible, - "https://example.com/v1".to_string(), - None, - None, - None, - None, - ) - .expect("创建自定义 Provider 失败"); - - api_key_service - .update_provider( - &db, - &provider.id, - None, - None, - None, - Some(true), - None, - None, - None, - None, - None, - Some(vec!["glm-4.7".to_string()]), - ) - .expect("更新 Provider 失败"); - - api_key_service - .add_api_key(&db, &provider.id, "sk-test", Some("主 Key".to_string())) - .expect("添加 API Key 失败"); - - let summaries = ClawSolutionService::default() - .list(&db) - .await - .expect("获取 Claw 方案列表失败"); - - assert!( - summaries - .iter() - .all(|summary| summary.readiness == ClawSolutionReadiness::Ready), - "当前 API Key Provider 已配置时,Claw 不应继续提示先配置模型" - ); - } - - #[tokio::test] - async fn list_should_keep_legacy_provider_pool_as_compat_fallback() { - let db = setup_db(); - let mut credential = ProviderCredential::new( - PoolProviderType::OpenAI, - CredentialData::OpenAIKey { - api_key: "sk-test".to_string(), - base_url: None, - }, - ); - credential.supported_models = vec!["gpt-4o".to_string()]; - - { - let conn = db.lock().expect("锁定数据库失败"); - ProviderPoolDao::insert(&conn, &credential).expect("插入旧 provider_pool 凭证失败"); - } - - let summaries = ClawSolutionService::default() - .list(&db) - .await - .expect("获取 Claw 方案列表失败"); - - assert!( - summaries - .iter() - .all(|summary| summary.readiness == ClawSolutionReadiness::Ready), - "compat 口径下的 provider_pool 可用模型仍应保持可启动" - ); - } -} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 264313057..8daec25e2 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -19,7 +19,6 @@ pub mod browser_environment_service; pub mod browser_profile_service; pub mod browser_runtime_window; pub mod chat_history_service; -pub mod claw_solution_service; pub mod companion_service; pub mod conversation_statistics_service; pub mod environment_service; diff --git a/src-tauri/src/skills/default_skills.rs b/src-tauri/src/skills/default_skills.rs index 24d8e1978..0a7d1e983 100644 --- a/src-tauri/src/skills/default_skills.rs +++ b/src-tauri/src/skills/default_skills.rs @@ -317,8 +317,17 @@ mod tests { assert!(VIDEO_GENERATE_SKILL_CONTENT.contains("name: video_generate")); assert!(BROADCAST_GENERATE_SKILL_CONTENT.contains("name: broadcast_generate")); assert!(COVER_GENERATE_SKILL_CONTENT.contains("name: cover_generate")); + assert!(COVER_GENERATE_SKILL_CONTENT.contains( + "allowed-tools: social_generate_cover_image, Bash, lime_create_cover_generation_task" + )); + assert!(COVER_GENERATE_SKILL_CONTENT + .contains("优先调用 `Bash` 执行 `lime task create cover --json` 创建任务")); assert!(MODAL_RESOURCE_SEARCH_SKILL_CONTENT.contains("name: modal_resource_search")); assert!(IMAGE_GENERATE_SKILL_CONTENT.contains("name: image_generate")); + assert!(IMAGE_GENERATE_SKILL_CONTENT + .contains("allowed-tools: Bash, lime_create_image_generation_task")); + assert!(IMAGE_GENERATE_SKILL_CONTENT + .contains("优先调用 `Bash` 执行 `lime task create image --json` 创建任务。")); assert!(LIBRARY_SKILL_CONTENT.contains("name: library")); assert!(URL_PARSE_SKILL_CONTENT.contains("name: url_parse")); assert!(RESEARCH_SKILL_CONTENT.contains("name: research")); diff --git a/src-tauri/src/voice/commands.rs b/src-tauri/src/voice/commands.rs index 4aa8641b4..3de768607 100644 --- a/src-tauri/src/voice/commands.rs +++ b/src-tauri/src/voice/commands.rs @@ -137,8 +137,8 @@ pub async fn delete_voice_instruction(id: String) -> Result<(), String> { /// 打开语音输入窗口 #[command] -pub async fn open_voice_window(app: AppHandle) -> Result<(), String> { - super::window::open_voice_window(&app) +pub async fn open_voice_window(app: AppHandle, target: Option) -> Result<(), String> { + super::window::open_voice_window(&app, target.as_deref()) } /// 关闭语音输入窗口 diff --git a/src-tauri/src/voice/window.rs b/src-tauri/src/voice/window.rs index 8aeb7341b..942488f84 100644 --- a/src-tauri/src/voice/window.rs +++ b/src-tauri/src/voice/window.rs @@ -8,13 +8,25 @@ const VOICE_WINDOW_LABEL: &str = "voice-input"; const VOICE_WINDOW_WIDTH: f64 = 500.0; const VOICE_WINDOW_HEIGHT: f64 = 80.0; +#[derive(Clone, serde::Serialize)] +struct VoiceWindowResetPayload { + target: Option, +} + /// 打开语音输入窗口 -pub fn open_voice_window(app: &AppHandle) -> Result<(), String> { +pub fn open_voice_window(app: &AppHandle, target: Option<&str>) -> Result<(), String> { + let target = normalize_voice_window_target(target); + // 检查窗口是否已存在 if let Some(window) = app.get_webview_window(VOICE_WINDOW_LABEL) { // 发送重置事件,让前端重新开始录音 window - .emit("voice-reset", ()) + .emit( + "voice-reset", + VoiceWindowResetPayload { + target: target.clone(), + }, + ) .map_err(|e| format!("发送重置事件失败: {e}"))?; // 移动到鼠标所在屏幕 @@ -30,7 +42,7 @@ pub fn open_voice_window(app: &AppHandle) -> Result<(), String> { let window = WebviewWindowBuilder::new( app, VOICE_WINDOW_LABEL, - WebviewUrl::App("/smart-input?voice=true".into()), + WebviewUrl::App(build_voice_window_route(target.as_deref()).into()), ) .title("语音输入") .inner_size(VOICE_WINDOW_WIDTH, VOICE_WINDOW_HEIGHT) @@ -52,6 +64,20 @@ pub fn open_voice_window(app: &AppHandle) -> Result<(), String> { Ok(()) } +fn normalize_voice_window_target(target: Option<&str>) -> Option { + target + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn build_voice_window_route(target: Option<&str>) -> String { + match normalize_voice_window_target(target).as_deref() { + Some(target) => format!("/smart-input?voice=true&target={target}"), + None => "/smart-input?voice=true".to_string(), + } +} + /// 将窗口定位到鼠标所在屏幕的中央 fn position_window_on_cursor_screen(window: &tauri::WebviewWindow) -> Result<(), String> { use tauri::PhysicalPosition; diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 9b10320b4..d4a41f8fa 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.1.0", + "version": "1.2.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e67064347..981e36be3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.1.0", + "version": "1.2.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/agent/chat/AgentChatHomeShell.test.tsx b/src/components/agent/chat/AgentChatHomeShell.test.tsx deleted file mode 100644 index 28ed4c110..000000000 --- a/src/components/agent/chat/AgentChatHomeShell.test.tsx +++ /dev/null @@ -1,2237 +0,0 @@ -import React from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ConfiguredProvider } from "@/hooks/useConfiguredProviders"; -import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry"; -import { AgentChatHomeShell } from "./AgentChatHomeShell"; -import { SettingsTabs } from "@/types/settings"; -import { - readTeamMemorySnapshot, - writeTeamMemorySnapshot, -} from "@/lib/teamMemorySync"; - -const { - mockBuildClawAgentParams, - mockCreateAutomationJob, - mockCreateContent, - mockHomeShellExecutionStrategy, - mockHomeShellModel, - mockHomeShellProviderType, - mockHomeShellRecentExecutionRuntime, - mockGetProject, - mockListProjects, - mockSetExecutionStrategy, - mockSetModel, - mockSetProviderType, - mockLoadConfiguredProviders, - mockGetModelRegistry, - mockGetProviderAliasConfig, - mockFetchProviderModelsAuto, - mockFilterModelsByTheme, - mockSaveChatToolPreferences, - mockPrepareClawSolution, - mockUseClawSolutions, - mockRecordClawSolutionUsage, - mockClawSolutions, - mockUseServiceSkills, - mockRecordServiceSkillUsage, - mockRecordServiceSkillAutomationLink, - mockCreateServiceSkillRun, - mockGetServiceSkillRun, - mockIsTerminalServiceSkillRunStatus, - mockSiteGetAdapterLaunchReadiness, - mockToastLoading, - mockToastSuccess, - mockToastError, - mockToastInfo, - mockServiceSkills, -} = vi.hoisted(() => { - const mockClawSolutions = [ - { - id: "web-research-brief", - title: "网页研究简报", - summary: "快速整理研究目标、关键来源与结论框架。", - outputHint: "研究提纲 + 结论简报", - recommendedCapabilities: ["模型", "研究"], - readiness: "ready", - readinessMessage: "可直接开始", - badge: "研究方案", - recentUsedAt: null, - isRecent: false, - readinessLabel: "可直接开始", - readinessTone: "emerald", - }, - { - id: "social-post-starter", - title: "社媒主稿生成", - summary: "进入社媒专项工作台并生成一版首稿。", - outputHint: "社媒首稿 + 平台结构", - recommendedCapabilities: ["模型", "社媒主题"], - readiness: "ready", - readinessMessage: "可直接开始", - badge: "社媒方案", - recentUsedAt: null, - isRecent: false, - readinessLabel: "可直接开始", - readinessTone: "emerald", - }, - { - id: "team-breakdown", - title: "多代理拆任务", - summary: "默认启用多代理偏好,按 team runtime 方式展开任务。", - outputHint: "任务拆解 + 分工执行", - recommendedCapabilities: ["模型", "多代理"], - readiness: "ready", - readinessMessage: "可直接开始,进入后会启用多代理偏好", - reasonCode: "team_recommended", - badge: "多代理", - recentUsedAt: null, - isRecent: false, - readinessLabel: "可直接开始", - readinessTone: "emerald", - }, - ]; - - const mockServiceSkills = [ - { - id: "short-video-script-replication", - title: "复制短视频脚本", - summary: "围绕参考视频的结构和节奏,输出一版可继续加工的脚本。", - category: "视频创作", - outputHint: "脚本大纲 + 镜头节奏", - source: "cloud_catalog", - runnerType: "instant", - defaultExecutorBinding: "agent_turn", - executionLocation: "client_default", - defaultArtifactKind: "brief", - themeTarget: "video", - version: "seed-v1", - slotSchema: [ - { - key: "reference_video", - label: "参考视频链接/素材", - type: "url", - required: true, - placeholder: "输入视频链接", - }, - ], - badge: "云目录", - recentUsedAt: null, - isRecent: false, - runnerLabel: "本地即时执行", - runnerTone: "emerald", - runnerDescription: "客户端起步版可直接进入工作区执行。", - actionLabel: "填写参数", - automationStatus: null, - }, - { - id: "daily-trend-briefing", - title: "每日趋势摘要", - summary: "围绕指定平台与关键词输出趋势摘要。", - category: "社媒运营", - outputHint: "趋势摘要 + 调度建议", - source: "cloud_catalog", - runnerType: "scheduled", - defaultExecutorBinding: "automation_job", - executionLocation: "client_default", - defaultArtifactKind: "analysis", - themeTarget: "social-media", - version: "seed-v1", - slotSchema: [ - { - key: "platform", - label: "监测平台", - type: "platform", - required: true, - placeholder: "选择平台", - defaultValue: "x", - options: [{ value: "x", label: "X / Twitter" }], - }, - { - key: "industry_keywords", - label: "行业关键词", - type: "textarea", - required: true, - placeholder: "输入关键词", - }, - { - key: "schedule_time", - label: "推送时间", - type: "schedule_time", - required: false, - placeholder: "例如 每天 09:00", - defaultValue: "每天 09:00", - }, - ], - badge: "云目录", - recentUsedAt: null, - isRecent: false, - runnerLabel: "本地计划任务", - runnerTone: "sky", - runnerDescription: "可直接创建本地定时任务,并回流到任务中心与工作区。", - actionLabel: "创建任务", - automationStatus: { - jobId: "automation-job-daily-trend", - jobName: "每日趋势摘要", - statusLabel: "成功", - tone: "emerald", - detail: "下次 03/24 09:00", - }, - }, - { - id: "github-repo-radar", - title: "GitHub 仓库线索检索", - summary: - "复用你当前浏览器里的 GitHub 登录态,直接检索主题仓库并沉淀成结构化线索。", - category: "情报研究", - outputHint: "仓库列表 + 关键线索", - source: "cloud_catalog", - runnerType: "instant", - defaultExecutorBinding: "browser_assist", - executionLocation: "client_default", - defaultArtifactKind: "analysis", - themeTarget: "knowledge", - version: "seed-v1", - readinessRequirements: { - requiresBrowser: true, - requiresProject: true, - }, - siteCapabilityBinding: { - adapterName: "github/search", - autoRun: true, - requireAttachedSession: true, - saveMode: "current_content", - slotArgMap: { - repository_query: "query", - }, - fixedArgs: { - limit: 10, - }, - suggestedTitleTemplate: "GitHub 仓库线索 · {{repository_query}}", - }, - slotSchema: [ - { - key: "repository_query", - label: "检索主题", - type: "text", - required: true, - placeholder: "例如 MCP agent browser automation", - }, - ], - badge: "云目录", - recentUsedAt: null, - isRecent: false, - runnerLabel: "浏览器站点执行", - runnerTone: "emerald", - runnerDescription: - "直接进入浏览器工作台,复用真实登录态执行站点脚本并沉淀结果。", - actionLabel: "启动采集", - automationStatus: null, - }, - ]; - - const mockRecordClawSolutionUsage = vi.fn(); - const mockRecordServiceSkillUsage = vi.fn(); - const mockRecordServiceSkillAutomationLink = vi.fn(); - const mockCreateServiceSkillRun = vi.fn(); - const mockGetServiceSkillRun = vi.fn(); - const mockIsTerminalServiceSkillRunStatus = vi.fn(); - const mockSiteGetAdapterLaunchReadiness = vi.fn(); - const mockToastLoading = vi.fn(); - const mockToastSuccess = vi.fn(); - const mockToastError = vi.fn(); - const mockToastInfo = vi.fn(); - - return { - mockBuildClawAgentParams: vi.fn((overrides?: Record) => ({ - agentEntry: "claw", - ...(overrides || {}), - })), - mockCreateAutomationJob: vi.fn( - async (request: Record) => ({ - id: "automation-job-1", - ...request, - }), - ), - mockCreateContent: vi.fn(async (request: Record) => ({ - id: "content-service-skill-1", - project_id: request.project_id, - title: request.title, - content_type: request.content_type, - status: "draft", - order: 0, - word_count: 0, - body: typeof request.body === "string" ? request.body : "", - metadata: - request.metadata && typeof request.metadata === "object" - ? request.metadata - : undefined, - created_at: 1, - updated_at: 1, - })), - mockHomeShellProviderType: { current: "mock-provider" }, - mockHomeShellModel: { current: "mock-model" }, - mockHomeShellExecutionStrategy: { current: "react" }, - mockHomeShellRecentExecutionRuntime: { current: null as unknown }, - mockGetProject: vi.fn(async (projectId: string) => ({ - id: projectId, - name: `项目 ${projectId}`, - workspaceType: "general", - rootPath: `/tmp/${projectId}`, - isDefault: false, - createdAt: 0, - updatedAt: 0, - isFavorite: false, - isArchived: false, - tags: [], - })), - mockListProjects: vi.fn(async () => [ - { - id: "project-1", - name: "项目一", - workspaceType: "general", - rootPath: "/tmp/project-1", - isDefault: false, - createdAt: 0, - updatedAt: 0, - isFavorite: false, - isArchived: false, - tags: [], - }, - ]), - mockSetProviderType: vi.fn(), - mockSetModel: vi.fn(), - mockSetExecutionStrategy: vi.fn(), - mockLoadConfiguredProviders: vi.fn( - async (): Promise => [], - ), - mockGetModelRegistry: vi.fn( - async (): Promise => [], - ), - mockGetProviderAliasConfig: vi.fn(async () => null), - mockFetchProviderModelsAuto: vi.fn(async () => ({ - models: [], - source: "LocalFallback", - error: null, - })), - mockFilterModelsByTheme: vi.fn( - (_theme: string | undefined, models: unknown[]) => ({ - models, - usedFallback: false, - filteredOutCount: 0, - policyName: "mock", - }), - ), - mockSaveChatToolPreferences: vi.fn(), - mockPrepareClawSolution: vi.fn(), - mockUseClawSolutions: vi.fn(() => ({ - solutions: mockClawSolutions, - isLoading: false, - error: null, - refresh: vi.fn(), - recordUsage: mockRecordClawSolutionUsage, - })), - mockRecordClawSolutionUsage, - mockClawSolutions, - mockUseServiceSkills: vi.fn(() => ({ - skills: mockServiceSkills, - groups: [], - isLoading: false, - error: null, - refresh: vi.fn(), - recordUsage: mockRecordServiceSkillUsage, - catalogMeta: null, - })), - mockRecordServiceSkillUsage, - mockRecordServiceSkillAutomationLink, - mockCreateServiceSkillRun, - mockGetServiceSkillRun, - mockIsTerminalServiceSkillRunStatus, - mockSiteGetAdapterLaunchReadiness, - mockToastLoading, - mockToastSuccess, - mockToastError, - mockToastInfo, - mockServiceSkills, - }; -}); - -vi.mock("./components/EmptyState", () => ({ - EmptyState: ({ - onSend, - onLaunchBrowserAssist, - onRecommendationClick, - supportingSlotOverride, - serviceSkills, - onSelectServiceSkill, - onSelectTeam, - selectedTeam, - }: { - onSend: ( - value: string, - executionStrategy?: unknown, - images?: Array<{ data: string; mediaType: string }>, - ) => void; - onLaunchBrowserAssist?: () => void; - onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; - supportingSlotOverride?: React.ReactNode; - serviceSkills?: Array<{ id: string; title: string }>; - onSelectServiceSkill?: (skill: { id: string; title: string }) => void; - onSelectTeam?: (team: { - id: string; - source: string; - label: string; - description?: string; - roles?: Array<{ - id: string; - label: string; - summary?: string; - }>; - }) => void; - selectedTeam?: { label?: string | null } | null; - }) => ( - <> -
- {selectedTeam?.label || "none"} -
- - - - {onSelectTeam ? ( - - ) : null} - {onLaunchBrowserAssist ? ( - - ) : null} - {serviceSkills?.map((skill) => - onSelectServiceSkill ? ( - - - - ) : null, - )} - {supportingSlotOverride} - - ), -})); - -vi.mock("@/lib/api/memory", () => ({ - getProjectMemory: vi.fn(async () => ({ - characters: [], - })), -})); - -vi.mock("@/lib/api/skills", () => ({ - skillsApi: { - getLocal: vi.fn(async () => []), - getAll: vi.fn(async () => []), - }, -})); - -vi.mock("./hooks/agentChatStorage", () => ({ - DEFAULT_AGENT_MODEL: "mock-model", - DEFAULT_AGENT_PROVIDER: "mock-provider", - GLOBAL_MODEL_PREF_KEY: "global-model", - GLOBAL_PROVIDER_PREF_KEY: "global-provider", - getAgentPreferenceKeys: vi.fn(() => ({ - providerKey: "provider-key", - modelKey: "model-key", - })), - loadPersisted: vi.fn((_key: string, fallback: unknown) => fallback), - loadPersistedString: vi.fn(() => ""), - resolveWorkspaceAgentPreferences: vi.fn(() => ({ - providerType: "mock-provider", - model: "mock-model", - })), - savePersisted: vi.fn(), -})); - -vi.mock("./hooks/agentChatCoreUtils", () => ({ - normalizeExecutionStrategy: vi.fn((value: string) => value || "react"), -})); - -vi.mock("./hooks/useHomeShellAgentPreferences", () => ({ - useHomeShellAgentPreferences: vi.fn(() => ({ - providerType: mockHomeShellProviderType.current, - setProviderType: mockSetProviderType, - model: mockHomeShellModel.current, - setModel: mockSetModel, - executionStrategy: mockHomeShellExecutionStrategy.current, - setExecutionStrategy: mockSetExecutionStrategy, - recentExecutionRuntime: mockHomeShellRecentExecutionRuntime.current, - })), -})); - -vi.mock("./utils/chatToolPreferences", () => ({ - loadChatToolPreferences: vi.fn(() => ({ - webSearch: false, - thinking: false, - task: false, - subagent: false, - })), - alignChatToolPreferencesWithExecutionStrategy: vi.fn( - (preferences: Record) => preferences, - ), - saveChatToolPreferences: mockSaveChatToolPreferences, -})); - -vi.mock("@/lib/workspace/navigation", () => ({ - buildClawAgentParams: mockBuildClawAgentParams, -})); - -vi.mock("@/lib/api/clawSolutions", () => ({ - prepareClawSolution: mockPrepareClawSolution, -})); - -vi.mock("@/lib/api/automation", () => ({ - createAutomationJob: mockCreateAutomationJob, -})); - -vi.mock("@/lib/api/serviceSkillRuns", () => ({ - createServiceSkillRun: mockCreateServiceSkillRun, - getServiceSkillRun: mockGetServiceSkillRun, - isTerminalServiceSkillRunStatus: mockIsTerminalServiceSkillRunStatus, -})); - -vi.mock("@/lib/api/project", () => ({ - createContent: mockCreateContent, - getDefaultContentTypeForProject: vi.fn((projectType: string) => { - switch (projectType) { - case "video": - return "episode"; - case "social-media": - return "post"; - default: - return "document"; - } - }), - getProject: mockGetProject, - listProjects: mockListProjects, -})); - -vi.mock("@/lib/webview-api", () => ({ - siteGetAdapterLaunchReadiness: (...args: unknown[]) => - mockSiteGetAdapterLaunchReadiness(...args), -})); - -vi.mock("sonner", () => ({ - toast: { - loading: mockToastLoading, - success: mockToastSuccess, - error: mockToastError, - info: mockToastInfo, - }, -})); - -vi.mock("@/hooks/useConfiguredProviders", () => ({ - loadConfiguredProviders: mockLoadConfiguredProviders, -})); - -vi.mock("@/lib/api/modelRegistry", () => ({ - modelRegistryApi: { - getModelRegistry: mockGetModelRegistry, - getProviderAliasConfig: mockGetProviderAliasConfig, - fetchProviderModelsAuto: mockFetchProviderModelsAuto, - }, -})); - -vi.mock("./utils/modelThemePolicy", () => ({ - filterModelsByTheme: mockFilterModelsByTheme, -})); - -vi.mock("./claw-solutions/useClawSolutions", () => ({ - useClawSolutions: mockUseClawSolutions, -})); - -vi.mock("./claw-solutions/ClawHomeSolutionsPanel", () => ({ - ClawHomeSolutionsPanel: ({ - solutions, - onSelect, - }: { - solutions: Array<{ id: string; title: string }>; - onSelect: (solution: { id: string; title: string }) => void; - }) => ( - <> - {solutions.map((solution) => ( - - ))} - - ), -})); - -vi.mock("./service-skills/useServiceSkills", () => ({ - useServiceSkills: mockUseServiceSkills, -})); - -vi.mock("./service-skills/automationLinkStorage", () => ({ - recordServiceSkillAutomationLink: mockRecordServiceSkillAutomationLink, -})); - -vi.mock("./service-skills/ServiceSkillLaunchDialog", () => ({ - ServiceSkillLaunchDialog: ({ - skill, - open, - onLaunch, - onCreateAutomation, - onOpenBrowserRuntime, - }: { - skill: { id: string; title: string; runnerType?: string } | null; - open: boolean; - onLaunch: ( - skill: { id: string; title: string; runnerType?: string }, - slotValues: Record, - ) => void; - onCreateAutomation?: ( - skill: { id: string; title: string; runnerType?: string }, - slotValues: Record, - ) => void; - onOpenBrowserRuntime?: ( - skill: { id: string; title: string; runnerType?: string }, - slotValues: Record, - ) => void; - }) => - open && skill ? ( - <> - - {skill.id === "github-repo-radar" && onOpenBrowserRuntime ? ( - - ) : null} - {skill.runnerType === "scheduled" && onCreateAutomation ? ( - - ) : null} - - ) : null, -})); - -vi.mock( - "@/components/settings-v2/system/automation/AutomationJobDialog", - () => ({ - AutomationJobDialog: ({ - open, - mode, - initialValues, - onSubmit, - }: { - open: boolean; - mode: "create" | "edit"; - initialValues?: Record | null; - onSubmit: (payload: { - mode: "create"; - request: Record; - }) => Promise; - }) => - open ? ( -
- {mode} - - {typeof initialValues?.schedule_kind === "string" - ? initialValues.schedule_kind - : "-"} - - - {typeof initialValues?.name === "string" ? initialValues.name : "-"} - - -
- ) : null, - }), -); - -const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; - -beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; - localStorage.clear(); - mockHomeShellProviderType.current = "mock-provider"; - mockHomeShellModel.current = "mock-model"; - mockHomeShellExecutionStrategy.current = "react"; - mockHomeShellRecentExecutionRuntime.current = null; - mockUseClawSolutions.mockImplementation(() => ({ - solutions: mockClawSolutions, - isLoading: false, - error: null, - refresh: vi.fn(), - recordUsage: mockRecordClawSolutionUsage, - })); - mockUseServiceSkills.mockImplementation(() => ({ - skills: mockServiceSkills, - groups: [], - isLoading: false, - error: null, - refresh: vi.fn(), - recordUsage: mockRecordServiceSkillUsage, - catalogMeta: null, - })); - mockLoadConfiguredProviders.mockResolvedValue([]); - mockGetModelRegistry.mockResolvedValue([]); - mockGetProviderAliasConfig.mockResolvedValue(null); - mockFetchProviderModelsAuto.mockResolvedValue({ - models: [], - source: "LocalFallback", - error: null, - }); - mockListProjects.mockResolvedValue([ - { - id: "project-1", - name: "项目一", - workspaceType: "general", - rootPath: "/tmp/project-1", - isDefault: false, - createdAt: 0, - updatedAt: 0, - isFavorite: false, - isArchived: false, - tags: [], - }, - ]); - mockFilterModelsByTheme.mockImplementation( - (_theme: string | undefined, models: unknown[]) => ({ - models, - usedFallback: false, - filteredOutCount: 0, - policyName: "mock", - }), - ); - mockCreateServiceSkillRun.mockResolvedValue({ - id: "service-skill-run-1", - status: "success", - outputSummary: "云端结果已生成", - }); - mockGetServiceSkillRun.mockResolvedValue({ - id: "service-skill-run-1", - status: "success", - }); - mockSiteGetAdapterLaunchReadiness.mockResolvedValue({ - status: "ready", - adapter: "github/search", - domain: "github.com", - profile_key: "attached-github", - target_id: "tab-github", - message: - "已检测到 github.com 的真实浏览器页面,Claw 可以直接复用当前会话执行。", - }); - mockIsTerminalServiceSkillRunStatus.mockImplementation((status: string) => - ["success", "failed", "canceled", "timeout"].includes(status), - ); - mockToastLoading.mockImplementation(() => "toast-loading"); - mockToastSuccess.mockImplementation(() => undefined); - mockToastError.mockImplementation(() => undefined); - mockToastInfo.mockImplementation(() => undefined); -}); - -afterEach(() => { - localStorage.clear(); - while (mountedRoots.length > 0) { - const mounted = mountedRoots.pop(); - if (!mounted) { - break; - } - act(() => { - mounted.root.unmount(); - }); - mounted.container.remove(); - } - vi.clearAllMocks(); -}); - -function renderShell( - props: Partial> = {}, -) { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - const defaultProps: React.ComponentProps = { - onNavigate: vi.fn(), - projectId: "project-1", - theme: "general", - lockTheme: false, - onEnterWorkspace: vi.fn(), - }; - - act(() => { - root.render(); - }); - - mountedRoots.push({ root, container }); - return { - container, - props: { - ...defaultProps, - ...props, - }, - }; -} - -async function flushEffects(times = 4) { - for (let index = 0; index < times; index += 1) { - await act(async () => { - await Promise.resolve(); - }); - } -} - -describe("AgentChatHomeShell", () => { - it("发送首条消息时应直接导航到 claw 工作区", async () => { - const onNavigate = vi.fn(); - const onEnterWorkspace = vi.fn(); - const { container } = renderShell({ - onNavigate, - onEnterWorkspace, - }); - - await flushEffects(); - - const sendButton = container.querySelector( - '[data-testid="home-shell-send"]', - ) as HTMLButtonElement | null; - - expect(sendButton).toBeTruthy(); - - act(() => { - sendButton?.click(); - }); - - await flushEffects(); - - expect(mockBuildClawAgentParams).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: "project-1", - theme: "general", - initialCreationMode: "guided", - initialUserPrompt: "整理成 notebook 工作方式", - initialUserImages: [], - openBrowserAssistOnMount: undefined, - newChatAt: expect.any(Number), - }), - ); - expect(onNavigate).toHaveBeenCalledWith( - "agent", - expect.objectContaining({ - agentEntry: "claw", - projectId: "project-1", - initialUserPrompt: "整理成 notebook 工作方式", - newChatAt: expect.any(Number), - }), - ); - expect(onEnterWorkspace).not.toHaveBeenCalled(); - }); - - it("首页普通自然句命中站点 skill 时应直接走 service skill 启动链", async () => { - const onNavigate = vi.fn(); - const onEnterWorkspace = vi.fn(); - const { container } = renderShell({ - onNavigate, - onEnterWorkspace, - }); - - await flushEffects(); - - const sendButton = container.querySelector( - '[data-testid="home-shell-send-github-site-skill"]', - ) as HTMLButtonElement | null; - - expect(sendButton).toBeTruthy(); - - act(() => { - sendButton?.click(); - }); - - await flushEffects(); - - expect(onNavigate).toHaveBeenCalledWith( - "agent", - expect.objectContaining({ - projectId: "project-1", - contentId: "content-service-skill-1", - theme: "general", - lockTheme: true, - initialCreationMode: "guided", - autoRunInitialPromptOnMount: true, - initialUserPrompt: "你帮我在 GitHub 找一下和“AI Agent”相关的项目。", - initialAutoSendRequestMetadata: { - harness: expect.objectContaining({ - browser_requirement: "required", - service_skill_launch: expect.objectContaining({ - adapter_name: "github/search", - args: { - query: "AI Agent", - limit: 10, - }, - }), - }), - }, - }), - ); - expect(onEnterWorkspace).not.toHaveBeenCalled(); - expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({ - skillId: "github-repo-radar", - runnerType: "instant", - }); - }); - - it("首页浏览器入口应直接打开浏览器工作台", async () => { - const onNavigate = vi.fn(); - const onEnterWorkspace = vi.fn(); - const { container } = renderShell({ - onNavigate, - onEnterWorkspace, - }); - - await flushEffects(); - - const browserRuntimeButton = container.querySelector( - '[data-testid="home-shell-open-browser-runtime"]', - ) as HTMLButtonElement | null; - - expect(browserRuntimeButton).toBeTruthy(); - - act(() => { - browserRuntimeButton?.click(); - }); - - await flushEffects(); - - expect(onNavigate).toHaveBeenCalledWith("browser-runtime", { - projectId: "project-1", - }); - expect(onEnterWorkspace).not.toHaveBeenCalled(); - }); - - it("点击 team 推荐时应开启多代理偏好并直接进入工作区", async () => { - const onEnterWorkspace = vi.fn(); - const { container } = renderShell({ - onNavigate: undefined, - onEnterWorkspace, - }); - - await flushEffects(); - - const teamRecommendationButton = container.querySelector( - '[data-testid="home-shell-team-recommendation"]', - ) as HTMLButtonElement | null; - - expect(teamRecommendationButton).toBeTruthy(); - - act(() => { - teamRecommendationButton?.click(); - }); - - await flushEffects(); - - expect(mockSaveChatToolPreferences).toHaveBeenLastCalledWith( - expect.objectContaining({ - webSearch: false, - thinking: false, - task: false, - subagent: true, - }), - "general", - ); - expect(onEnterWorkspace).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: "project-1", - theme: "general", - initialCreationMode: "guided", - initialUserPrompt: - "请按 team runtime 方式做一次冒烟测试:主线程先拆成两个子任务,再创建 explorer 与 executor 两个子代理并行处理;至少等待一个子代理完成,必要时继续 send_input,最后回到主线程输出 team workspace 总结。", - newChatAt: expect.any(Number), - }), - ); - }); - - it("点击网页研究简报时应开启联网研究偏好并进入工作区", async () => { - const onEnterWorkspace = vi.fn(); - mockPrepareClawSolution.mockResolvedValueOnce({ - solutionId: "web-research-brief", - actionType: "fill_input", - prompt: "请围绕这个主题先给我做一版网页研究简报", - shouldLaunchBrowserAssist: false, - shouldEnableTeamMode: false, - readiness: "ready", - readinessMessage: "可直接开始", - }); - - const { container } = renderShell({ - onNavigate: undefined, - onEnterWorkspace, - }); - - await flushEffects(); - - const researchSolutionButton = container.querySelector( - '[data-testid="home-shell-solution-web-research-brief"]', - ) as HTMLButtonElement | null; - - expect(researchSolutionButton).toBeTruthy(); - - act(() => { - researchSolutionButton?.click(); - }); - - await flushEffects(); - - expect(mockSaveChatToolPreferences).toHaveBeenLastCalledWith( - expect.objectContaining({ - webSearch: true, - thinking: false, - task: false, - subagent: false, - }), - "general", - ); - expect(onEnterWorkspace).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: "project-1", - theme: "general", - initialCreationMode: "guided", - initialUserPrompt: "请围绕这个主题先给我做一版网页研究简报", - }), - ); - expect(mockRecordClawSolutionUsage).toHaveBeenCalledWith({ - solutionId: "web-research-brief", - actionType: "fill_input", - themeTarget: null, - }); - }); - - it("最近 session runtime 的工具偏好应先回灌首页壳,再参与 team 推荐", async () => { - mockHomeShellRecentExecutionRuntime.current = { - recent_preferences: { - webSearch: true, - thinking: true, - task: false, - subagent: false, - }, - recent_team_selection: null, - }; - const onEnterWorkspace = vi.fn(); - const { container } = renderShell({ - onNavigate: undefined, - onEnterWorkspace, - }); - - await flushEffects(); - - const teamRecommendationButton = container.querySelector( - '[data-testid="home-shell-team-recommendation"]', - ) as HTMLButtonElement | null; - - expect(teamRecommendationButton).toBeTruthy(); - - act(() => { - teamRecommendationButton?.click(); - }); - - await flushEffects(); - - expect(mockSaveChatToolPreferences).toHaveBeenLastCalledWith( - expect.objectContaining({ - webSearch: true, - thinking: true, - task: false, - subagent: true, - }), - "general", - ); - expect(onEnterWorkspace).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: "project-1", - theme: "general", - }), - ); - }); - - it("首页壳应优先按当前项目的 shadow snapshot 恢复 Team", async () => { - localStorage.clear(); - writeTeamMemorySnapshot(localStorage, { - repoScope: "/tmp/project-1", - entries: { - "team.selection": { - key: "team.selection", - content: [ - "主题:general", - "会话:session-shadow", - "Team:研究协作团队", - "来源:custom", - "说明:负责调研、归纳与验证。", - "角色:", - "- 研究:负责调研与信息归纳。", - ].join("\n"), - updatedAt: Date.now(), - }, - }, - }); - - const { container } = renderShell(); - - await flushEffects(); - - expect( - container.querySelector('[data-testid="home-shell-selected-team"]') - ?.textContent, - ).toBe("研究协作团队"); - }); - - it("首页壳首次进入新项目时不应回退到全局 theme localStorage Team", async () => { - localStorage.clear(); - localStorage.setItem( - "lime.chat.team_selection.v1.general", - JSON.stringify({ - id: "research-team", - source: "builtin", - }), - ); - - const { container } = renderShell(); - - await flushEffects(); - - expect( - container.querySelector('[data-testid="home-shell-selected-team"]') - ?.textContent, - ).toBe("none"); - }); - - it("首页手动切换 Team 后应写回当前项目的 shadow snapshot", async () => { - localStorage.clear(); - const { container } = renderShell(); - - await flushEffects(); - - const selectTeamButton = container.querySelector( - '[data-testid="home-shell-select-team"]', - ) as HTMLButtonElement | null; - - expect(selectTeamButton).toBeTruthy(); - - act(() => { - selectTeamButton?.click(); - }); - - await flushEffects(); - - const snapshot = readTeamMemorySnapshot(localStorage, "/tmp/project-1"); - expect( - snapshot?.entries["team.selection"]?.content.includes( - "Team:首页协作团队", - ), - ).toBe(true); - expect( - container.querySelector('[data-testid="home-shell-selected-team"]') - ?.textContent, - ).toBe("首页协作团队"); - }); - - it("首页进入自动执行链路时应把当前 Team 注入初始 metadata", async () => { - localStorage.clear(); - writeTeamMemorySnapshot(localStorage, { - repoScope: "/tmp/project-1", - entries: { - "team.selection": { - key: "team.selection", - content: [ - "主题:general", - "会话:session-shadow", - "Team:研究协作团队", - "来源:custom", - "说明:负责调研、归纳与验证。", - "角色:", - "- 研究:负责调研与信息归纳。", - ].join("\n"), - updatedAt: Date.now(), - }, - }, - }); - const onNavigate = vi.fn(); - const { container } = renderShell({ - onNavigate, - onEnterWorkspace: vi.fn(), - }); - - await flushEffects(); - - const sendButton = container.querySelector( - '[data-testid="home-shell-send-github-site-skill"]', - ) as HTMLButtonElement | null; - expect(sendButton).toBeTruthy(); - - act(() => { - sendButton?.click(); - }); - - await flushEffects(); - - expect(onNavigate).toHaveBeenCalledWith( - "agent", - expect.objectContaining({ - initialAutoSendRequestMetadata: { - harness: expect.objectContaining({ - selected_team_label: "研究协作团队", - selected_team_source: "custom", - selected_team_summary: - expect.stringContaining("负责调研、归纳与验证。"), - }), - }, - }), - ); - }); - - it("点击社媒方案时应切换到 social-media 工作区", async () => { - const onNavigate = vi.fn(); - mockLoadConfiguredProviders.mockResolvedValueOnce([ - { - key: "custom-social-provider", - label: "Custom Social Provider", - registryId: "custom-social-provider", - fallbackRegistryId: "openai", - type: "openai", - }, - ]); - mockGetModelRegistry.mockResolvedValueOnce([ - { - id: "social-model-1", - display_name: "Social Model 1", - provider_id: "custom-social-provider", - provider_name: "Custom Social Provider", - family: null, - tier: "pro", - capabilities: { - vision: false, - tools: true, - streaming: true, - json_mode: true, - function_calling: true, - reasoning: false, - }, - pricing: null, - limits: { - context_length: null, - max_output_tokens: null, - requests_per_minute: null, - tokens_per_minute: null, - }, - status: "active", - release_date: null, - is_latest: true, - description: "social", - source: "custom", - created_at: 0, - updated_at: 0, - }, - ]); - mockPrepareClawSolution.mockResolvedValueOnce({ - solutionId: "social-post-starter", - actionType: "navigate_theme", - prompt: "请先帮我起草一版社媒内容首稿", - themeTarget: "social-media", - shouldLaunchBrowserAssist: false, - shouldEnableTeamMode: false, - readiness: "ready", - readinessMessage: "可直接开始", - }); - - const { container } = renderShell({ - onNavigate, - }); - - await flushEffects(); - - const socialSolutionButton = container.querySelector( - '[data-testid="home-shell-solution-social-post-starter"]', - ) as HTMLButtonElement | null; - - expect(socialSolutionButton).toBeTruthy(); - - act(() => { - socialSolutionButton?.click(); - }); - - await flushEffects(); - - expect(mockSetProviderType).toHaveBeenCalledWith("custom-social-provider"); - expect(mockSetModel).toHaveBeenCalledWith("social-model-1"); - expect(mockPrepareClawSolution).toHaveBeenCalledWith( - "social-post-starter", - { - projectId: "project-1", - userInput: undefined, - }, - ); - expect(mockBuildClawAgentParams).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: "project-1", - theme: "social-media", - initialUserPrompt: "请先帮我起草一版社媒内容首稿", - }), - ); - expect(onNavigate).toHaveBeenCalledWith( - "agent", - expect.objectContaining({ - agentEntry: "claw", - theme: "social-media", - initialUserPrompt: "请先帮我起草一版社媒内容首稿", - }), - ); - expect(mockSetProviderType.mock.invocationCallOrder[0]).toBeLessThan( - onNavigate.mock.invocationCallOrder[0], - ); - expect(mockSetModel.mock.invocationCallOrder[0]).toBeLessThan( - onNavigate.mock.invocationCallOrder[0], - ); - expect(mockRecordClawSolutionUsage).toHaveBeenCalledWith({ - solutionId: "social-post-starter", - actionType: "navigate_theme", - themeTarget: "social-media", - }); - }); - - it("当前 provider 已可用时应保留 custom provider id 并仅切换模型", async () => { - const onNavigate = vi.fn(); - mockHomeShellProviderType.current = "custom-social-provider"; - mockHomeShellModel.current = "legacy-model"; - mockLoadConfiguredProviders.mockResolvedValueOnce([ - { - key: "custom-social-provider", - label: "Custom Social Provider", - registryId: "custom-social-provider", - fallbackRegistryId: "openai", - type: "openai", - }, - { - key: "other-provider", - label: "Other Provider", - registryId: "other-provider", - type: "openai", - }, - ]); - mockGetModelRegistry.mockResolvedValueOnce([ - { - id: "custom-social-model", - display_name: "Custom Social Model", - provider_id: "custom-social-provider", - provider_name: "Custom Social Provider", - family: null, - tier: "pro", - capabilities: { - vision: false, - tools: true, - streaming: true, - json_mode: true, - function_calling: true, - reasoning: false, - }, - pricing: null, - limits: { - context_length: null, - max_output_tokens: null, - requests_per_minute: null, - tokens_per_minute: null, - }, - status: "active", - release_date: null, - is_latest: true, - description: "custom-social-model", - source: "custom", - created_at: 0, - updated_at: 0, - }, - ]); - mockPrepareClawSolution.mockResolvedValueOnce({ - solutionId: "social-post-starter", - actionType: "navigate_theme", - prompt: "请先帮我起草一版社媒内容首稿", - themeTarget: "social-media", - shouldLaunchBrowserAssist: false, - shouldEnableTeamMode: false, - readiness: "ready", - readinessMessage: "可直接开始", - }); - - const { container } = renderShell({ - onNavigate, - }); - - await flushEffects(); - - const socialSolutionButton = container.querySelector( - '[data-testid="home-shell-solution-social-post-starter"]', - ) as HTMLButtonElement | null; - - expect(socialSolutionButton).toBeTruthy(); - - act(() => { - socialSolutionButton?.click(); - }); - - await flushEffects(); - - expect(mockSetProviderType).not.toHaveBeenCalled(); - expect(mockSetModel).toHaveBeenCalledWith("custom-social-model"); - expect(mockGetModelRegistry).toHaveBeenCalled(); - expect(mockFetchProviderModelsAuto).not.toHaveBeenCalled(); - expect(onNavigate).toHaveBeenCalledWith( - "agent", - expect.objectContaining({ - theme: "social-media", - }), - ); - }); - - it("点击多代理方案时应开启多代理偏好并进入工作区", async () => { - const onEnterWorkspace = vi.fn(); - mockPrepareClawSolution.mockResolvedValueOnce({ - solutionId: "team-breakdown", - actionType: "enable_team_mode", - prompt: "请把这个任务按多代理方式拆解", - shouldLaunchBrowserAssist: false, - shouldEnableTeamMode: true, - readiness: "ready", - readinessMessage: "可直接开始,进入后会启用多代理偏好", - }); - - const { container } = renderShell({ - onNavigate: undefined, - onEnterWorkspace, - }); - - await flushEffects(); - - const teamSolutionButton = container.querySelector( - '[data-testid="home-shell-solution-team-breakdown"]', - ) as HTMLButtonElement | null; - - expect(teamSolutionButton).toBeTruthy(); - - act(() => { - teamSolutionButton?.click(); - }); - - await flushEffects(); - - expect(mockSaveChatToolPreferences).toHaveBeenLastCalledWith( - expect.objectContaining({ - webSearch: false, - thinking: false, - task: false, - subagent: true, - }), - "general", - ); - expect(onEnterWorkspace).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: "project-1", - theme: "general", - initialCreationMode: "guided", - initialUserPrompt: "请把这个任务按多代理方式拆解", - }), - ); - expect(mockRecordClawSolutionUsage).toHaveBeenCalledWith({ - solutionId: "team-breakdown", - actionType: "enable_team_mode", - themeTarget: null, - }); - }); - - it("点击服务型技能后应完成补参并进入对应工作区", async () => { - const onEnterWorkspace = vi.fn(); - const { container } = renderShell({ - onNavigate: undefined, - onEnterWorkspace, - }); - - await flushEffects(); - - const serviceSkillButton = container.querySelector( - '[data-testid="home-shell-empty-state-service-skill-short-video-script-replication"]', - ) as HTMLButtonElement | null; - - expect(serviceSkillButton).toBeTruthy(); - - act(() => { - serviceSkillButton?.click(); - }); - - await flushEffects(); - - const launchButton = container.querySelector( - '[data-testid="home-shell-service-skill-launch"]', - ) as HTMLButtonElement | null; - - expect(launchButton).toBeTruthy(); - - act(() => { - launchButton?.click(); - }); - - await flushEffects(); - - expect(mockCreateContent).toHaveBeenCalledWith( - expect.objectContaining({ - project_id: "project-1", - title: "复制短视频脚本", - content_type: "episode", - body: "", - }), - ); - expect(onEnterWorkspace).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: "project-1", - contentId: "content-service-skill-1", - theme: "video", - initialCreationMode: "guided", - autoRunInitialPromptOnMount: true, - initialRequestMetadata: { - artifact: { - artifact_mode: "draft", - artifact_kind: "brief", - workbench_surface: "right_panel", - }, - }, - initialUserPrompt: expect.stringContaining("[技能任务] 复制短视频脚本"), - }), - ); - expect(onEnterWorkspace).toHaveBeenCalledWith( - expect.objectContaining({ - initialUserPrompt: expect.stringContaining( - "- 参考视频链接/素材: https://example.com/video", - ), - }), - ); - expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({ - skillId: "short-video-script-replication", - runnerType: "instant", - }); - }); - - it("通过首页输入区 @ 选择服务型技能时应打开补参弹窗", async () => { - const { container } = renderShell(); - - await flushEffects(); - - const mentionServiceSkillButton = container.querySelector( - '[data-testid="home-shell-empty-state-service-skill-short-video-script-replication"]', - ) as HTMLButtonElement | null; - - expect(mentionServiceSkillButton).toBeTruthy(); - - act(() => { - mentionServiceSkillButton?.click(); - }); - - await flushEffects(); - - const launchButton = container.querySelector( - '[data-testid="home-shell-service-skill-launch"]', - ) as HTMLButtonElement | null; - - expect(launchButton).toBeTruthy(); - }); - - it("站点型服务技能主按钮应进入 Claw 工作区并携带初始执行载荷", async () => { - const onNavigate = vi.fn(); - const onEnterWorkspace = vi.fn(); - const { container } = renderShell({ - onNavigate, - onEnterWorkspace, - }); - - await flushEffects(); - - const serviceSkillButton = container.querySelector( - '[data-testid="home-shell-empty-state-service-skill-github-repo-radar"]', - ) as HTMLButtonElement | null; - - expect(serviceSkillButton).toBeTruthy(); - - act(() => { - serviceSkillButton?.click(); - }); - - await flushEffects(); - - const launchButton = container.querySelector( - '[data-testid="home-shell-service-skill-launch"]', - ) as HTMLButtonElement | null; - - expect(launchButton).toBeTruthy(); - - act(() => { - launchButton?.click(); - }); - - await flushEffects(); - - expect(mockCreateContent).toHaveBeenCalledWith( - expect.objectContaining({ - project_id: "project-1", - title: "GitHub 仓库线索检索", - content_type: "document", - }), - ); - expect(onNavigate).not.toHaveBeenCalledWith( - "browser-runtime", - expect.anything(), - ); - expect(onNavigate).toHaveBeenCalledWith( - "agent", - expect.objectContaining({ - projectId: "project-1", - contentId: "content-service-skill-1", - theme: "general", - initialCreationMode: "guided", - autoRunInitialPromptOnMount: true, - initialUserPrompt: - "你帮我在 GitHub 找一下和“browser assist mcp”相关的项目。", - initialRequestMetadata: undefined, - initialAutoSendRequestMetadata: { - harness: { - browser_requirement: "required", - browser_requirement_reason: - expect.stringContaining("真实浏览器页面"), - browser_assist: { - enabled: true, - profile_key: "attached-github", - preferred_backend: "lime_extension_bridge", - auto_launch: false, - stream_mode: "both", - }, - service_skill_launch: expect.objectContaining({ - adapter_name: "github/search", - content_id: "content-service-skill-1", - project_id: "project-1", - save_mode: "current_content", - args: { - query: "browser assist mcp", - limit: 10, - }, - launch_readiness: expect.objectContaining({ - status: "ready", - profile_key: "attached-github", - target_id: "tab-github", - }), - }), - }, - }, - }), - ); - const firstSiteSkillLaunchPayload = onNavigate.mock.calls.find( - ([route]) => route === "agent", - )?.[1]; - expect(firstSiteSkillLaunchPayload?.initialUserPrompt).not.toContain( - "[站点技能启动上下文]", - ); - expect(firstSiteSkillLaunchPayload?.initialUserPrompt).not.toContain( - "adapter_name", - ); - expect( - firstSiteSkillLaunchPayload?.initialAutoSendRequestMetadata, - ).not.toHaveProperty("artifact"); - expect(onEnterWorkspace).not.toHaveBeenCalled(); - expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({ - skillId: "github-repo-radar", - runnerType: "instant", - }); - }); - - it("站点型服务技能次级按钮才应跳到浏览器工作台", async () => { - const onNavigate = vi.fn(); - const onEnterWorkspace = vi.fn(); - const { container } = renderShell({ - onNavigate, - onEnterWorkspace, - }); - - await flushEffects(); - - const serviceSkillButton = container.querySelector( - '[data-testid="home-shell-empty-state-service-skill-github-repo-radar"]', - ) as HTMLButtonElement | null; - - act(() => { - serviceSkillButton?.click(); - }); - - await flushEffects(); - - const browserRuntimeButton = container.querySelector( - '[data-testid="home-shell-service-skill-open-browser-runtime"]', - ) as HTMLButtonElement | null; - - expect(browserRuntimeButton).toBeTruthy(); - - act(() => { - browserRuntimeButton?.click(); - }); - - await flushEffects(); - - expect(onNavigate).toHaveBeenCalledWith("browser-runtime", { - projectId: "project-1", - contentId: "content-service-skill-1", - initialProfileKey: "attached-github", - initialTargetId: "tab-github", - initialAdapterName: "github/search", - initialArgs: { - query: "browser assist mcp", - limit: 10, - }, - initialAutoRun: true, - initialRequireAttachedSession: true, - initialSaveTitle: undefined, - }); - expect(onEnterWorkspace).not.toHaveBeenCalled(); - }); - - it("站点型服务技能缺少附着会话时应留在入口层并提示先准备浏览器", async () => { - const onNavigate = vi.fn(); - const onEnterWorkspace = vi.fn(); - mockSiteGetAdapterLaunchReadiness.mockResolvedValueOnce({ - status: "requires_browser_runtime", - adapter: "github/search", - domain: "github.com", - message: "当前没有检测到已附着到真实浏览器的 github.com 页面。", - report_hint: "请先去浏览器工作台连接真实浏览器。", - }); - const { container } = renderShell({ - onNavigate, - onEnterWorkspace, - }); - - await flushEffects(); - - const serviceSkillButton = container.querySelector( - '[data-testid="home-shell-empty-state-service-skill-github-repo-radar"]', - ) as HTMLButtonElement | null; - - act(() => { - serviceSkillButton?.click(); - }); - - await flushEffects(); - - const launchButton = container.querySelector( - '[data-testid="home-shell-service-skill-launch"]', - ) as HTMLButtonElement | null; - - act(() => { - launchButton?.click(); - }); - - await flushEffects(); - - expect(onNavigate).not.toHaveBeenCalledWith("agent", expect.anything()); - expect(onEnterWorkspace).not.toHaveBeenCalled(); - expect(mockToastInfo).toHaveBeenCalledWith( - expect.stringContaining("请先去浏览器工作台连接真实浏览器"), - ); - expect(mockToastError).not.toHaveBeenCalled(); - }); - - it("cloud_required 服务型技能成功后应回流本地工作区", async () => { - const onEnterWorkspace = vi.fn(); - mockCreateServiceSkillRun.mockResolvedValue({ - id: "service-skill-run-cloud-1", - status: "success", - outputSummary: "云端结果已生成", - outputText: "# 云端视频配音\n\n第一版成稿", - finishedAt: "2026-03-26T01:02:03.000Z", - }); - mockUseServiceSkills.mockImplementation(() => ({ - skills: [ - { - ...mockServiceSkills[0], - id: "cloud-video-dubbing", - title: "云端视频配音", - executionLocation: "cloud_required", - defaultExecutorBinding: "cloud_scene", - runnerLabel: "云端托管执行", - runnerTone: "slate", - runnerDescription: "提交到 OEM 云端执行,结果由服务端异步返回。", - actionLabel: "提交云端", - }, - ], - groups: [], - isLoading: false, - error: null, - refresh: vi.fn(), - recordUsage: mockRecordServiceSkillUsage, - catalogMeta: null, - })); - - const { container } = renderShell({ - onNavigate: undefined, - onEnterWorkspace, - }); - - await flushEffects(); - - const serviceSkillButton = container.querySelector( - '[data-testid="home-shell-empty-state-service-skill-cloud-video-dubbing"]', - ) as HTMLButtonElement | null; - - expect(serviceSkillButton).toBeTruthy(); - - act(() => { - serviceSkillButton?.click(); - }); - - await flushEffects(); - - const launchButton = container.querySelector( - '[data-testid="home-shell-service-skill-launch"]', - ) as HTMLButtonElement | null; - - expect(launchButton).toBeTruthy(); - - act(() => { - launchButton?.click(); - }); - - await flushEffects(); - - expect(mockCreateServiceSkillRun).toHaveBeenCalledWith( - "cloud-video-dubbing", - expect.stringContaining("[技能任务] 云端视频配音"), - ); - expect(mockCreateServiceSkillRun).toHaveBeenCalledWith( - "cloud-video-dubbing", - expect.stringContaining("- 参考视频链接/素材: https://example.com/video"), - ); - expect(mockCreateContent).toHaveBeenCalledWith( - expect.objectContaining({ - project_id: "project-1", - title: "云端视频配音", - content_type: "episode", - body: "# 云端视频配音\n\n第一版成稿", - metadata: expect.objectContaining({ - source: "service_skill", - serviceSkill: expect.objectContaining({ - id: "cloud-video-dubbing", - executionLocation: "cloud_required", - themeTarget: "video", - }), - cloudRun: expect.objectContaining({ - id: "service-skill-run-cloud-1", - status: "success", - outputSummary: "云端结果已生成", - finishedAt: "2026-03-26T01:02:03.000Z", - }), - }), - }), - ); - expect(onEnterWorkspace).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: "project-1", - contentId: "content-service-skill-1", - theme: "video", - initialCreationMode: "guided", - initialRequestMetadata: { - artifact: { - artifact_mode: "draft", - artifact_kind: "brief", - workbench_surface: "right_panel", - }, - }, - }), - ); - expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({ - skillId: "cloud-video-dubbing", - runnerType: "instant", - }); - expect(mockToastLoading).toHaveBeenCalledWith( - "正在提交 云端视频配音 到云端...", - ); - expect(mockToastSuccess).toHaveBeenCalledWith( - "云端视频配音 云端运行完成:云端结果已生成,正在回流本地工作区。", - { - id: "toast-loading", - }, - ); - }); - - it("点击定时服务型技能创建任务后应先建本地 automation 再进入工作区", async () => { - const onEnterWorkspace = vi.fn(); - const { container } = renderShell({ - onNavigate: undefined, - onEnterWorkspace, - }); - - await flushEffects(); - - const serviceSkillButton = container.querySelector( - '[data-testid="home-shell-empty-state-service-skill-daily-trend-briefing"]', - ) as HTMLButtonElement | null; - - expect(serviceSkillButton).toBeTruthy(); - - act(() => { - serviceSkillButton?.click(); - }); - - await flushEffects(); - - const createAutomationButton = container.querySelector( - '[data-testid="home-shell-service-skill-create-automation"]', - ) as HTMLButtonElement | null; - - expect(createAutomationButton).toBeTruthy(); - - act(() => { - createAutomationButton?.click(); - }); - - await flushEffects(); - - const automationDialog = container.querySelector( - '[data-testid="home-shell-automation-dialog"]', - ) as HTMLDivElement | null; - const automationSubmitButton = container.querySelector( - '[data-testid="home-shell-automation-submit"]', - ) as HTMLButtonElement | null; - - expect(automationDialog).toBeTruthy(); - expect(container.textContent).toContain("create"); - expect(container.textContent).toContain("cron"); - expect(container.textContent).toContain("每日趋势摘要"); - expect(automationSubmitButton).toBeTruthy(); - - act(() => { - automationSubmitButton?.click(); - }); - - await flushEffects(); - - expect(mockCreateAutomationJob).toHaveBeenCalledWith( - expect.objectContaining({ - workspace_id: "project-1", - execution_mode: "skill", - schedule: { - kind: "cron", - expr: "00 09 * * *", - tz: expect.any(String), - }, - payload: expect.objectContaining({ - kind: "agent_turn", - prompt: expect.stringContaining("[技能任务] 每日趋势摘要"), - content_id: "content-service-skill-1", - request_metadata: expect.objectContaining({ - artifact: expect.objectContaining({ - artifact_mode: "draft", - artifact_kind: "analysis", - }), - service_skill: expect.objectContaining({ - id: "daily-trend-briefing", - title: "每日趋势摘要", - runner_type: "scheduled", - slot_values: [ - { - key: "platform", - label: "监测平台", - value: "X / Twitter", - }, - { - key: "industry_keywords", - label: "行业关键词", - value: "AI Agent,创作者工具", - }, - { - key: "schedule_time", - label: "推送时间", - value: "每天 09:00", - }, - ], - slot_summary: [ - "监测平台: X / Twitter", - "行业关键词: AI Agent,创作者工具", - "推送时间: 每天 09:00", - ], - user_input: null, - }), - harness: expect.objectContaining({ - theme: "social-media", - session_mode: "theme_workbench", - content_id: "content-service-skill-1", - }), - }), - }), - }), - ); - expect(mockCreateContent).toHaveBeenCalledTimes(1); - expect(mockCreateContent).toHaveBeenCalledWith( - expect.objectContaining({ - project_id: "project-1", - title: "每日趋势摘要", - content_type: "post", - body: "", - }), - ); - expect(mockCreateContent.mock.invocationCallOrder[0]).toBeLessThan( - mockCreateAutomationJob.mock.invocationCallOrder[0], - ); - expect(onEnterWorkspace).toHaveBeenCalledWith( - expect.objectContaining({ - projectId: "project-1", - contentId: "content-service-skill-1", - theme: "social-media", - initialCreationMode: "guided", - autoRunInitialPromptOnMount: true, - initialRequestMetadata: { - artifact: { - artifact_mode: "draft", - artifact_kind: "analysis", - workbench_surface: "right_panel", - }, - }, - initialUserPrompt: expect.stringContaining("[技能任务] 每日趋势摘要"), - }), - ); - expect(mockRecordServiceSkillAutomationLink).toHaveBeenCalledWith({ - skillId: "daily-trend-briefing", - jobId: "automation-job-1", - jobName: expect.any(String), - }); - expect(mockRecordServiceSkillUsage).toHaveBeenCalledWith({ - skillId: "daily-trend-briefing", - runnerType: "scheduled", - }); - }); - - it("首页不应再展示 service skill 专属任务状态快捷入口", async () => { - const { container } = renderShell(); - - await flushEffects(); - - const openAutomationButton = container.querySelector( - '[data-testid="home-shell-empty-state-service-skill-open-automation-daily-trend-briefing"]', - ); - - expect(openAutomationButton).toBeFalsy(); - }); - - it("方案未就绪且缺少模型时应直接跳到供应商设置", async () => { - const onNavigate = vi.fn(); - const onEnterWorkspace = vi.fn(); - mockPrepareClawSolution.mockResolvedValueOnce({ - solutionId: "social-post-starter", - actionType: "navigate_theme", - prompt: "请先帮我起草一版社媒内容首稿", - themeTarget: "social-media", - shouldLaunchBrowserAssist: false, - shouldEnableTeamMode: false, - readiness: "needs_setup", - readinessMessage: "请先配置至少一个可用模型", - reasonCode: "missing_model", - }); - - const { container } = renderShell({ - onNavigate, - onEnterWorkspace, - }); - - await flushEffects(); - - const socialSolutionButton = container.querySelector( - '[data-testid="home-shell-solution-social-post-starter"]', - ) as HTMLButtonElement | null; - - expect(socialSolutionButton).toBeTruthy(); - - act(() => { - socialSolutionButton?.click(); - }); - - await flushEffects(); - - expect(onNavigate).toHaveBeenCalledWith("settings", { - tab: SettingsTabs.Providers, - }); - expect(onEnterWorkspace).not.toHaveBeenCalled(); - expect(mockRecordClawSolutionUsage).not.toHaveBeenCalled(); - }); -}); diff --git a/src/components/agent/chat/AgentChatHomeShell.tsx b/src/components/agent/chat/AgentChatHomeShell.tsx deleted file mode 100644 index 3f24257b8..000000000 --- a/src/components/agent/chat/AgentChatHomeShell.tsx +++ /dev/null @@ -1,1626 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import styled from "styled-components"; -import { toast } from "sonner"; -import { createAutomationJob } from "@/lib/api/automation"; -import { prepareClawSolution } from "@/lib/api/clawSolutions"; -import { siteGetAdapterLaunchReadiness } from "@/lib/webview-api"; -import { - createServiceSkillRun, - getServiceSkillRun, - isTerminalServiceSkillRunStatus, - type ServiceSkillRun, -} from "@/lib/api/serviceSkillRuns"; -import { - createContent, - getProject, - listProjects, - type Project, -} from "@/lib/api/project"; -import { readTeamMemorySnapshot } from "@/lib/teamMemorySync"; -import { - AutomationJobDialog, - type AutomationJobDialogInitialValues, - type AutomationJobDialogSubmit, -} from "@/components/settings-v2/system/automation/AutomationJobDialog"; -import type { BrowserRuntimePageParams, Page, PageParams } from "@/types/page"; -import { SettingsTabs } from "@/types/settings"; -import { EmptyState } from "./components/EmptyState"; -import type { CreationMode } from "./components/types"; -import type { MessageImage } from "./types"; -import { - alignChatToolPreferencesWithExecutionStrategy, - saveChatToolPreferences, -} from "./utils/chatToolPreferences"; -import { isTeamRuntimeRecommendation } from "./utils/contextualRecommendations"; -import { resolveClawWorkspaceProviderSelection } from "./utils/clawWorkspaceProviderSelection"; -import { createChatToolPreferencesFromExecutionRuntime } from "./utils/sessionExecutionRuntime"; -import { normalizeProjectId } from "./utils/topicProjectResolution"; -import { - LAST_PROJECT_ID_KEY, - usePersistedProjectId, -} from "./hooks/agentProjectStorage"; -import { useHomeShellAgentPreferences } from "./hooks/useHomeShellAgentPreferences"; -import { useHomeShellProjectMemory } from "./hooks/useHomeShellProjectMemory"; -import { useHomeShellSkills } from "./hooks/useHomeShellSkills"; -import { useThemeScopedChatToolPreferences } from "./hooks/useThemeScopedChatToolPreferences"; -import { useSelectedTeamPreference } from "./hooks/useSelectedTeamPreference"; -import { syncTeamMemoryShadowSnapshot } from "./hooks/useTeamMemoryShadowSync"; -import { attachSelectedTeamToRequestMetadata } from "./utils/teamRequestMetadata"; -import { - enableSubagentPreference, - resolveClawSolutionLaunch, - resolveClawSolutionSetupTarget, -} from "./claw-solutions/actionDispatcher"; -import { useClawSolutions } from "./claw-solutions/useClawSolutions"; -import { ClawHomeSolutionsPanel } from "./claw-solutions/ClawHomeSolutionsPanel"; -import type { ClawSolutionHomeItem } from "./claw-solutions/types"; -import { normalizeInitialTheme } from "./agentChatWorkspaceShared"; -import { normalizeThemeType } from "@/lib/workspace/workbenchContract"; -import { - type AgentChatWorkspaceBootstrap, - resolveHomeShellWorkspaceEntry, - type HomeShellEnterWorkspacePayload, -} from "./homeShellEntry"; -import { useServiceSkills } from "./service-skills/useServiceSkills"; -import { ServiceSkillLaunchDialog } from "./service-skills/ServiceSkillLaunchDialog"; -import { matchAutoLaunchSiteSkillFromText } from "./service-skills/autoMatchSiteSkill"; -import { composeServiceSkillPrompt } from "./service-skills/promptComposer"; -import { - buildServiceSkillAutomationAgentTurnPayloadContext, - buildServiceSkillAutomationInitialValues, - supportsServiceSkillLocalAutomation, -} from "./service-skills/automationDraft"; -import { recordServiceSkillAutomationLink } from "./service-skills/automationLinkStorage"; -import { recordServiceSkillCloudRun } from "./service-skills/cloudRunStorage"; -import type { - ServiceSkillHomeItem, - ServiceSkillSlotValues, -} from "./service-skills/types"; -import { buildServiceSkillWorkspaceSeed } from "./service-skills/workspaceLaunch"; -import { - buildSiteLaunchBlockedMessage, - buildServiceSkillClawLaunchContext, - buildServiceSkillClawLaunchRequestMetadata, - buildServiceSkillSiteCapabilityArgs, - buildServiceSkillSiteCapabilitySaveTitle, - composeServiceSkillClawLaunchPrompt, - isServiceSkillExecutableAsSiteAdapter, - isSiteLaunchReadinessReady, -} from "./service-skills/siteCapabilityBinding"; - -const PageContainer = styled.div<{ $compact?: boolean }>` - display: flex; - height: 100%; - width: 100%; - position: relative; - min-height: 0; - gap: ${({ $compact }) => ($compact ? "8px" : "14px")}; - padding: ${({ $compact }) => ($compact ? "8px" : "14px")}; - box-sizing: border-box; - overflow: hidden; - isolation: isolate; - background: - radial-gradient( - circle at 14% 18%, - rgba(56, 189, 248, 0.1), - transparent 30% - ), - radial-gradient( - circle at 86% 14%, - rgba(16, 185, 129, 0.08), - transparent 28% - ), - radial-gradient( - circle at 72% 84%, - rgba(245, 158, 11, 0.06), - transparent 24% - ), - linear-gradient( - 180deg, - rgba(248, 250, 252, 0.98) 0%, - rgba(248, 250, 252, 0.96) 42%, - rgba(242, 251, 247, 0.94) 100% - ); - - > * { - position: relative; - z-index: 1; - } -`; - -const MainArea = styled.div<{ $compact?: boolean }>` - display: flex; - flex-direction: column; - flex: 1; - min-width: 0; - min-height: 0; - overflow: hidden; - position: relative; - border: 1px solid rgba(226, 232, 240, 0.88); - border-radius: ${({ $compact }) => ($compact ? "24px" : "32px")}; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.96) 0%, - rgba(248, 250, 252, 0.94) 56%, - rgba(248, 250, 252, 0.88) 100% - ); - box-shadow: - 0 24px 72px -36px rgba(15, 23, 42, 0.18), - 0 16px 28px -24px rgba(15, 23, 42, 0.1), - inset 0 1px 0 rgba(255, 255, 255, 0.76); -`; - -const ChatContainer = styled.div` - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - height: 100%; -`; - -const ChatContainerInner = styled.div` - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - height: 100%; - overflow: hidden; - background: linear-gradient( - 180deg, - rgba(248, 250, 252, 0.78) 0%, - rgba(255, 255, 255, 0.12) 18%, - rgba(255, 255, 255, 0) 100% - ); -`; - -const ThemeWorkbenchLayoutShell = styled.div<{ $bottomInset: string }>` - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; - box-sizing: border-box; - padding-bottom: ${({ $bottomInset }) => $bottomInset}; - transition: padding-bottom 0.2s ease; -`; - -function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - if (typeof error === "string") { - return error; - } - return "请稍后重试"; -} - -function normalizeOptionalText(value?: string | null): string | undefined { - if (typeof value !== "string") { - return undefined; - } - - const normalized = value.trim(); - return normalized ? normalized : undefined; -} - -interface ServiceSkillLaunchOptions { - launchUserInput?: string | null; -} - -function resolveServiceSkillLaunchUserInput( - currentInput: string, - options?: ServiceSkillLaunchOptions, -): string | undefined { - if (options && "launchUserInput" in options) { - return normalizeOptionalText(options.launchUserInput); - } - - return normalizeOptionalText(currentInput); -} - -function buildServiceSkillCloudResultBody( - skill: ServiceSkillHomeItem, - run: ServiceSkillRun, -): string { - return ( - normalizeOptionalText(run.outputText) || - normalizeOptionalText(run.outputSummary) || - `# ${skill.title}\n\n云端结果已生成。` - ); -} - -function buildServiceSkillCloudResultMetadata( - run: ServiceSkillRun, -): Record { - return { - cloudRun: { - id: run.id, - status: run.status, - executorKind: run.executorKind ?? null, - outputSummary: normalizeOptionalText(run.outputSummary) ?? null, - errorCode: run.errorCode ?? null, - errorMessage: run.errorMessage ?? null, - startedAt: run.startedAt ?? null, - finishedAt: run.finishedAt ?? null, - updatedAt: run.updatedAt ?? null, - }, - }; -} - -function resolveFallbackProjectType(theme?: string): Project["workspaceType"] { - return normalizeThemeType(theme); -} - -function buildFallbackAutomationWorkspace( - projectId: string, - theme?: string, -): Project { - return { - id: projectId, - name: projectId, - workspaceType: resolveFallbackProjectType(theme), - rootPath: "", - isDefault: false, - createdAt: 0, - updatedAt: 0, - isFavorite: false, - isArchived: false, - tags: [], - }; -} - -function prioritizeAutomationWorkspaces( - workspaces: Project[], - projectId?: string | null, - theme?: string, -): Project[] { - const normalizedProjectId = normalizeProjectId(projectId); - if (!normalizedProjectId) { - return workspaces; - } - - const matched = workspaces.find( - (workspace) => workspace.id === normalizedProjectId, - ); - const fallbackWorkspace = - matched ?? buildFallbackAutomationWorkspace(normalizedProjectId, theme); - const remaining = workspaces.filter( - (workspace) => workspace.id !== normalizedProjectId, - ); - - return [fallbackWorkspace, ...remaining]; -} - -const SERVICE_SKILL_RUN_STATUS_LABELS: Record = { - queued: "排队中", - running: "运行中", - success: "已完成", - failed: "执行失败", - canceled: "已取消", - timeout: "已超时", -}; - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - window.setTimeout(resolve, ms); - }); -} - -function getServiceSkillRunStatusLabel(status: string): string { - return SERVICE_SKILL_RUN_STATUS_LABELS[status] ?? status; -} - -function buildServiceSkillRunSuccessMessage( - skill: ServiceSkillHomeItem, - run: ServiceSkillRun, -): string { - const summary = run.outputSummary || run.outputText || run.inputSummary; - if (summary) { - return `${skill.title} 云端运行完成:${summary},正在回流本地工作区。`; - } - - return `${skill.title} 云端运行完成,正在回流本地工作区。`; -} - -interface PendingServiceSkillAutomationLaunch { - skill: ServiceSkillHomeItem; - prompt: string; - slotValues: ServiceSkillSlotValues; - userInput?: string; - usage: { - skillId: string; - runnerType: ServiceSkillHomeItem["runnerType"]; - }; -} - -export type { AgentChatWorkspaceBootstrap } from "./homeShellEntry"; - -interface AgentChatHomeShellProps { - onNavigate?: (page: Page, params?: PageParams) => void; - projectId?: string; - theme?: string; - initialCreationMode?: CreationMode; - lockTheme?: boolean; - onEnterWorkspace: (payload: AgentChatWorkspaceBootstrap) => void; -} - -export function AgentChatHomeShell({ - onNavigate, - projectId: externalProjectId, - theme: initialTheme, - initialCreationMode, - lockTheme = false, - onEnterWorkspace, -}: AgentChatHomeShellProps) { - const normalizedEntryTheme = normalizeInitialTheme(initialTheme); - const [input, setInput] = useState(""); - const [activeTheme, setActiveTheme] = useState(normalizedEntryTheme); - const [creationMode, setCreationMode] = useState( - initialCreationMode ?? "guided", - ); - const { - projectId: currentProjectId, - setProjectId: setCurrentProjectId, - rememberProjectId, - } = usePersistedProjectId(externalProjectId, LAST_PROJECT_ID_KEY); - const { - chatToolPreferences, - setChatToolPreferences, - syncChatToolPreferencesSource, - } = useThemeScopedChatToolPreferences(activeTheme, { - scopeId: currentProjectId, - }); - const { - providerType, - setProviderType, - model, - setModel, - executionStrategy, - setExecutionStrategy, - recentExecutionRuntime, - } = useHomeShellAgentPreferences(currentProjectId); - const projectMemory = useHomeShellProjectMemory(currentProjectId); - const { skills, skillsLoading, refreshSkills } = useHomeShellSkills(); - const [browserAssistLoading, setBrowserAssistLoading] = useState(false); - const [currentProjectRootPath, setCurrentProjectRootPath] = useState< - string | null - >(null); - const [manualTeamShadowSyncState, setManualTeamShadowSyncState] = useState<{ - projectId: string | null; - version: number; - }>({ - projectId: null, - version: 0, - }); - - useEffect(() => { - const normalizedProjectId = normalizeProjectId(currentProjectId); - if (!normalizedProjectId) { - setCurrentProjectRootPath(null); - return; - } - - let cancelled = false; - void getProject(normalizedProjectId) - .then((project) => { - if (cancelled) { - return; - } - setCurrentProjectRootPath(project?.rootPath?.trim() || null); - }) - .catch(() => { - if (cancelled) { - return; - } - setCurrentProjectRootPath(null); - }); - - return () => { - cancelled = true; - }; - }, [currentProjectId]); - - const persistedTeamMemoryShadowSnapshot = useMemo(() => { - if (!currentProjectRootPath || typeof localStorage === "undefined") { - return null; - } - - return readTeamMemorySnapshot(localStorage, currentProjectRootPath); - }, [currentProjectRootPath]); - const { - selectedTeam, - setSelectedTeam: handleSelectTeam, - enableSuggestedTeam: handleEnableSuggestedTeam, - preferredTeamPresetId, - selectedTeamLabel, - selectedTeamSummary, - } = useSelectedTeamPreference(activeTheme, { - runtimeSelection: recentExecutionRuntime?.recent_team_selection ?? null, - shadowSnapshot: persistedTeamMemoryShadowSnapshot, - allowPersistedThemeFallback: !currentProjectId, - }); - - useEffect(() => { - const normalizedProjectId = normalizeProjectId(currentProjectId); - if ( - manualTeamShadowSyncState.version <= 0 || - manualTeamShadowSyncState.projectId !== normalizedProjectId || - !currentProjectRootPath || - typeof localStorage === "undefined" - ) { - return; - } - - syncTeamMemoryShadowSnapshot({ - repoScope: currentProjectRootPath, - activeTheme, - selectedTeam, - storage: localStorage, - }); - }, [ - activeTheme, - currentProjectRootPath, - currentProjectId, - manualTeamShadowSyncState, - selectedTeam, - ]); - - const handleManualSelectTeam = useCallback( - (team: Parameters[0]) => { - handleSelectTeam(team); - setManualTeamShadowSyncState((previous) => ({ - projectId: normalizeProjectId(currentProjectId), - version: previous.version + 1, - })); - }, - [currentProjectId, handleSelectTeam], - ); - const runtimeChatToolPreferences = useMemo( - () => createChatToolPreferencesFromExecutionRuntime(recentExecutionRuntime), - [recentExecutionRuntime], - ); - const effectiveChatToolPreferences = useMemo( - () => - alignChatToolPreferencesWithExecutionStrategy( - chatToolPreferences, - executionStrategy, - ), - [chatToolPreferences, executionStrategy], - ); - const { - solutions: clawSolutions, - isLoading: clawSolutionsLoading, - error: clawSolutionsError, - recordUsage: recordClawSolutionUsage, - } = useClawSolutions(activeTheme === "general"); - const { - skills: serviceSkills, - groups: _serviceSkillGroups, - catalogMeta: _serviceSkillCatalogMeta, - isLoading: _serviceSkillsLoading, - error: serviceSkillsError, - recordUsage: recordServiceSkillUsage, - } = useServiceSkills(activeTheme === "general"); - const [selectedServiceSkill, setSelectedServiceSkill] = - useState(null); - const [serviceSkillDialogOpen, setServiceSkillDialogOpen] = useState(false); - const [automationDialogOpen, setAutomationDialogOpen] = useState(false); - const [automationDialogInitialValues, setAutomationDialogInitialValues] = - useState(null); - const [automationWorkspaces, setAutomationWorkspaces] = useState( - [], - ); - const [automationJobSaving, setAutomationJobSaving] = useState(false); - const [pendingServiceSkillAutomation, setPendingServiceSkillAutomation] = - useState(null); - - useEffect(() => { - setActiveTheme(normalizeInitialTheme(initialTheme)); - }, [initialTheme]); - - useEffect(() => { - if (!initialCreationMode) { - return; - } - setCreationMode(initialCreationMode); - }, [initialCreationMode]); - - useEffect(() => { - if (activeTheme !== "general" || !clawSolutionsError) { - return; - } - - toast.error(`加载 Claw 方案失败:${clawSolutionsError}`); - }, [activeTheme, clawSolutionsError]); - - useEffect(() => { - if (activeTheme !== "general" || !serviceSkillsError) { - return; - } - - toast.error(`加载技能目录失败:${serviceSkillsError}`); - }, [activeTheme, serviceSkillsError]); - - useEffect(() => { - syncChatToolPreferencesSource(activeTheme, runtimeChatToolPreferences); - }, [activeTheme, runtimeChatToolPreferences, syncChatToolPreferencesSource]); - - useEffect(() => { - if (chatToolPreferences.task === effectiveChatToolPreferences.task) { - return; - } - - setChatToolPreferences(effectiveChatToolPreferences); - }, [ - chatToolPreferences.task, - effectiveChatToolPreferences, - setChatToolPreferences, - ]); - - const handleRefreshSkills = useCallback(async () => { - await refreshSkills(true); - }, [refreshSkills]); - - const handleProjectChange = useCallback( - (nextProjectId: string) => { - if (externalProjectId) { - return; - } - - const normalizedProjectId = normalizeProjectId(nextProjectId); - setCurrentProjectId(normalizedProjectId); - if (normalizedProjectId) { - rememberProjectId(normalizedProjectId); - } - }, - [externalProjectId, rememberProjectId, setCurrentProjectId], - ); - - const handleOpenBrowserRuntimeFromHome = useCallback(() => { - if (!onNavigate) { - toast.error("当前入口暂不支持打开浏览器工作台,请从桌面主界面重试。"); - return; - } - - const normalizedProjectId = normalizeProjectId(currentProjectId); - if (normalizedProjectId) { - rememberProjectId(normalizedProjectId); - } - - onNavigate( - "browser-runtime", - normalizedProjectId - ? { - projectId: normalizedProjectId, - } - : undefined, - ); - }, [currentProjectId, onNavigate, rememberProjectId]); - - const handleEnterWorkspace = useCallback( - (payload: HomeShellEnterWorkspacePayload) => { - const normalizedProjectId = normalizeProjectId(currentProjectId); - const payloadWithSelectedTeamMetadata: HomeShellEnterWorkspacePayload = { - ...payload, - initialRequestMetadata: attachSelectedTeamToRequestMetadata( - payload.initialRequestMetadata, - { - preferredTeamPresetId, - selectedTeam, - selectedTeamLabel, - selectedTeamSummary, - }, - ), - initialAutoSendRequestMetadata: attachSelectedTeamToRequestMetadata( - payload.initialAutoSendRequestMetadata, - { - preferredTeamPresetId, - selectedTeam, - selectedTeamLabel, - selectedTeamSummary, - }, - ), - }; - const resolved = resolveHomeShellWorkspaceEntry({ - projectId: normalizedProjectId, - activeTheme, - creationMode, - defaultToolPreferences: effectiveChatToolPreferences, - payload: payloadWithSelectedTeamMetadata, - }); - - if (!resolved.ok) { - if (resolved.reason === "missing_project") { - toast.error("缺少项目工作区,请先选择项目后再使用 Agent"); - } - return false; - } - - if (normalizedProjectId) { - rememberProjectId(normalizedProjectId); - } - saveChatToolPreferences(resolved.toolPreferences, resolved.targetTheme); - - if (onNavigate) { - onNavigate("agent", resolved.navigationParams); - return true; - } - - onEnterWorkspace(resolved.workspaceBootstrap); - return true; - }, - [ - activeTheme, - effectiveChatToolPreferences, - creationMode, - currentProjectId, - rememberProjectId, - onEnterWorkspace, - onNavigate, - preferredTeamPresetId, - selectedTeam, - selectedTeamLabel, - selectedTeamSummary, - ], - ); - - const handleClawSolutionSelect = useCallback( - async (solution: ClawSolutionHomeItem) => { - try { - const preparation = await prepareClawSolution(solution.id, { - projectId: normalizeProjectId(currentProjectId) ?? undefined, - userInput: input.trim() || undefined, - }); - - if (preparation.readiness !== "ready") { - const setupTab = resolveClawSolutionSetupTarget( - preparation.readiness, - preparation.reasonCode, - ); - if (setupTab && onNavigate) { - if (setupTab === SettingsTabs.Skills) { - onNavigate("skills"); - } else { - onNavigate("settings", { tab: setupTab }); - } - return; - } - toast.error(preparation.readinessMessage); - return; - } - - const launch = resolveClawSolutionLaunch( - preparation, - effectiveChatToolPreferences, - ); - const targetTheme = - launch.enterWorkspacePayload.themeOverride ?? activeTheme; - - try { - const providerSelection = await resolveClawWorkspaceProviderSelection( - { - currentProviderType: providerType, - currentModel: model, - theme: targetTheme, - }, - ); - - if (providerSelection) { - if (providerSelection.providerType !== providerType) { - setProviderType(providerSelection.providerType); - } - if (providerSelection.model !== model) { - setModel(providerSelection.model); - } - } - } catch (selectionError) { - console.warn( - "[AgentChatHomeShell] 解析 Claw 工作区默认 provider/model 失败,继续沿用当前选择:", - selectionError, - ); - } - - if (launch.preferencesChanged) { - setChatToolPreferences(launch.nextToolPreferences); - } - - if (launch.shouldStartBrowserAssistLoading) { - setBrowserAssistLoading(true); - } - - const entered = handleEnterWorkspace(launch.enterWorkspacePayload); - - if (!entered) { - if (launch.shouldStartBrowserAssistLoading) { - setBrowserAssistLoading(false); - } - return; - } - - recordClawSolutionUsage(launch.usageRecord); - } catch (error) { - setBrowserAssistLoading(false); - toast.error(`启动方案失败:${getErrorMessage(error)}`); - } - }, - [ - effectiveChatToolPreferences, - currentProjectId, - handleEnterWorkspace, - input, - model, - onNavigate, - providerType, - recordClawSolutionUsage, - setChatToolPreferences, - setModel, - setProviderType, - activeTheme, - ], - ); - - const handleServiceSkillSelect = useCallback( - (skill: ServiceSkillHomeItem) => { - setSelectedServiceSkill(skill); - setServiceSkillDialogOpen(true); - }, - [], - ); - - const _handleOpenServiceSkillAutomationJob = useCallback( - (skill: ServiceSkillHomeItem) => { - const jobId = skill.automationStatus?.jobId; - if (!jobId || !onNavigate) { - return; - } - - onNavigate("automation", { - selectedJobId: jobId, - workspaceTab: "tasks", - }); - }, - [onNavigate], - ); - - const createServiceSkillSeededContent = useCallback( - async ( - skill: ServiceSkillHomeItem, - projectId?: string | null, - options?: { - body?: string; - metadata?: Record; - }, - ) => { - const normalizedProjectId = normalizeProjectId( - projectId ?? currentProjectId, - ); - const seed = buildServiceSkillWorkspaceSeed( - skill, - skill.themeTarget ?? activeTheme, - ); - - if (!normalizedProjectId || !seed) { - return null; - } - - const mergedMetadata = { - ...(seed.metadata ?? {}), - ...(options?.metadata ?? {}), - }; - - return createContent({ - project_id: normalizedProjectId, - title: seed.title, - content_type: seed.contentType, - body: options?.body ?? "", - metadata: - Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined, - }); - }, - [activeTheme, currentProjectId], - ); - - const prepareServiceSkillCloudResultWorkspacePayload = useCallback( - async ( - skill: ServiceSkillHomeItem, - run: ServiceSkillRun, - ): Promise => { - const normalizedProjectId = normalizeProjectId(currentProjectId); - const seed = buildServiceSkillWorkspaceSeed( - skill, - skill.themeTarget ?? activeTheme, - ); - - if (!normalizedProjectId || !seed) { - return null; - } - - const created = await createServiceSkillSeededContent( - skill, - normalizedProjectId, - { - body: buildServiceSkillCloudResultBody(skill, run), - metadata: buildServiceSkillCloudResultMetadata(run), - }, - ); - - if (!created) { - return null; - } - - return { - contentId: created.id, - themeOverride: skill.themeTarget, - initialRequestMetadata: seed.requestMetadata, - }; - }, - [activeTheme, createServiceSkillSeededContent, currentProjectId], - ); - - const prepareServiceSkillWorkspacePayload = useCallback( - async ( - skill: ServiceSkillHomeItem, - prompt: string, - options?: { - contentId?: string | null; - projectId?: string | null; - }, - ): Promise => { - const normalizedProjectId = normalizeProjectId( - options?.projectId ?? currentProjectId, - ); - const existingContentId = options?.contentId?.trim(); - const seed = buildServiceSkillWorkspaceSeed( - skill, - skill.themeTarget ?? activeTheme, - ); - - if (existingContentId) { - return { - prompt, - contentId: existingContentId, - themeOverride: skill.themeTarget, - initialRequestMetadata: seed?.requestMetadata, - autoRunInitialPromptOnMount: true, - }; - } - - if (!normalizedProjectId || !seed) { - return { - prompt, - themeOverride: skill.themeTarget, - initialRequestMetadata: seed?.requestMetadata, - autoRunInitialPromptOnMount: true, - }; - } - - const created = await createServiceSkillSeededContent( - skill, - normalizedProjectId, - ); - - if (!created) { - return { - prompt, - themeOverride: skill.themeTarget, - initialRequestMetadata: seed.requestMetadata, - autoRunInitialPromptOnMount: true, - }; - } - - return { - prompt, - contentId: created.id, - themeOverride: skill.themeTarget, - initialRequestMetadata: seed.requestMetadata, - autoRunInitialPromptOnMount: true, - }; - }, - [activeTheme, createServiceSkillSeededContent, currentProjectId], - ); - - const prepareServiceSkillSiteWorkspacePayload = useCallback( - async ( - skill: ServiceSkillHomeItem, - slotValues: ServiceSkillSlotValues, - launchReadiness: - | Awaited> - | null, - options?: ServiceSkillLaunchOptions, - ): Promise => { - if (!isServiceSkillExecutableAsSiteAdapter(skill)) { - throw new Error("当前技能未绑定站点执行能力"); - } - - if (!isSiteLaunchReadinessReady(launchReadiness)) { - throw new Error(buildSiteLaunchBlockedMessage(launchReadiness)); - } - - const normalizedProjectId = normalizeProjectId(currentProjectId); - if ( - skill.readinessRequirements?.requiresProject && - !normalizedProjectId - ) { - throw new Error("缺少项目工作区,请先选择项目后再启动站点技能。"); - } - const binding = skill.siteCapabilityBinding; - const saveMode = binding.saveMode ?? "project_resource"; - const initialSaveTitle = buildServiceSkillSiteCapabilitySaveTitle( - skill, - slotValues, - ); - let nextContentId: string | undefined; - - if (saveMode === "current_content" && normalizedProjectId) { - const created = await createServiceSkillSeededContent( - skill, - normalizedProjectId, - ); - nextContentId = created?.id ?? undefined; - } - - const clawLaunchContext = { - ...buildServiceSkillClawLaunchContext(skill, slotValues, { - contentId: nextContentId, - projectId: normalizedProjectId, - launchReadiness, - }), - saveTitle: nextContentId ? undefined : initialSaveTitle, - }; - const prompt = composeServiceSkillClawLaunchPrompt({ - skill, - slotValues, - userInput: resolveServiceSkillLaunchUserInput(input, options), - context: clawLaunchContext, - }); - - return { - prompt, - contentId: nextContentId, - themeOverride: "general", - initialAutoSendRequestMetadata: - buildServiceSkillClawLaunchRequestMetadata(clawLaunchContext), - autoRunInitialPromptOnMount: true, - }; - }, - [createServiceSkillSeededContent, currentProjectId, input], - ); - - const handleServiceSkillBrowserRuntimeLaunch = useCallback( - async ( - skill: ServiceSkillHomeItem, - slotValues: ServiceSkillSlotValues, - ): Promise => { - if (!isServiceSkillExecutableAsSiteAdapter(skill)) { - return; - } - - if (!onNavigate) { - toast.error("当前入口暂不支持打开浏览器工作台,请从桌面主界面重试。"); - return; - } - - const normalizedProjectId = normalizeProjectId(currentProjectId); - if ( - skill.readinessRequirements?.requiresProject && - !normalizedProjectId - ) { - toast.error("缺少项目工作区,请先选择项目后再启动浏览器采集。"); - return; - } - - const binding = skill.siteCapabilityBinding; - let launchReadiness: - | Awaited> - | null = null; - try { - launchReadiness = await siteGetAdapterLaunchReadiness({ - adapter_name: binding.adapterName, - }); - } catch { - launchReadiness = null; - } - const saveMode = binding.saveMode ?? "project_resource"; - const initialArgs = buildServiceSkillSiteCapabilityArgs( - skill, - slotValues, - ); - const initialSaveTitle = buildServiceSkillSiteCapabilitySaveTitle( - skill, - slotValues, - ); - let contentId: string | undefined; - - if (saveMode === "current_content" && normalizedProjectId) { - try { - const created = await createServiceSkillSeededContent( - skill, - normalizedProjectId, - ); - contentId = created?.id ?? undefined; - } catch (error) { - toast.error(`准备浏览器采集主稿失败:${getErrorMessage(error)}`); - return; - } - } - - const navigationParams: BrowserRuntimePageParams = { - projectId: normalizedProjectId ?? undefined, - contentId, - initialProfileKey: - launchReadiness?.status === "ready" - ? launchReadiness.profile_key - : undefined, - initialTargetId: - launchReadiness?.status === "ready" - ? launchReadiness.target_id - : undefined, - initialAdapterName: binding.adapterName, - initialArgs, - initialAutoRun: binding.autoRun ?? false, - initialRequireAttachedSession: binding.requireAttachedSession ?? false, - initialSaveTitle: contentId ? undefined : initialSaveTitle, - }; - - onNavigate("browser-runtime", navigationParams); - recordServiceSkillUsage({ - skillId: skill.id, - runnerType: skill.runnerType, - }); - setServiceSkillDialogOpen(false); - setSelectedServiceSkill(null); - }, - [ - createServiceSkillSeededContent, - currentProjectId, - onNavigate, - recordServiceSkillUsage, - ], - ); - - const handleServiceSkillLaunch = useCallback( - async ( - skill: ServiceSkillHomeItem, - slotValues: ServiceSkillSlotValues, - options?: ServiceSkillLaunchOptions, - ) => { - if (isServiceSkillExecutableAsSiteAdapter(skill)) { - let launchReadiness: - | Awaited> - | null = null; - try { - launchReadiness = await siteGetAdapterLaunchReadiness({ - adapter_name: skill.siteCapabilityBinding.adapterName, - }); - } catch { - // 门禁检查失败时保持弹窗可见,交由后续阻断提示兜底。 - } - - if (!isSiteLaunchReadinessReady(launchReadiness)) { - toast.info(buildSiteLaunchBlockedMessage(launchReadiness)); - return; - } - - let workspacePayload: HomeShellEnterWorkspacePayload; - try { - workspacePayload = await prepareServiceSkillSiteWorkspacePayload( - skill, - slotValues, - launchReadiness, - options, - ); - } catch (error) { - toast.error(`准备站点技能失败:${getErrorMessage(error)}`); - return; - } - - const entered = handleEnterWorkspace(workspacePayload); - if (!entered) { - return; - } - - recordServiceSkillUsage({ - skillId: skill.id, - runnerType: skill.runnerType, - }); - setServiceSkillDialogOpen(false); - setSelectedServiceSkill(null); - return; - } - - const prompt = composeServiceSkillPrompt({ - skill, - slotValues, - userInput: resolveServiceSkillLaunchUserInput(input, options), - }); - - if (skill.executionLocation === "cloud_required") { - const toastId = toast.loading(`正在提交 ${skill.title} 到云端...`); - - try { - setServiceSkillDialogOpen(false); - setSelectedServiceSkill(null); - - let run = await createServiceSkillRun(skill.id, prompt); - recordServiceSkillCloudRun(skill.id, run); - recordServiceSkillUsage({ - skillId: skill.id, - runnerType: skill.runnerType, - }); - - if (!isTerminalServiceSkillRunStatus(run.status)) { - toast.loading( - `${skill.title} ${getServiceSkillRunStatusLabel(run.status)},正在等待结果...`, - { - id: toastId, - }, - ); - - for (let attempt = 0; attempt < 12; attempt += 1) { - await sleep(2_000); - run = await getServiceSkillRun(run.id); - recordServiceSkillCloudRun(skill.id, run); - if (isTerminalServiceSkillRunStatus(run.status)) { - break; - } - } - } - - if (run.status === "success") { - let workspacePayload: HomeShellEnterWorkspacePayload | null = null; - let workspaceErrorMessage: string | null = null; - - try { - workspacePayload = - await prepareServiceSkillCloudResultWorkspacePayload( - skill, - run, - ); - } catch (error) { - workspaceErrorMessage = getErrorMessage(error); - } - - toast.success(buildServiceSkillRunSuccessMessage(skill, run), { - id: toastId, - }); - - if (workspacePayload) { - const entered = handleEnterWorkspace(workspacePayload); - if (!entered) { - toast.error( - "云端结果已生成,但进入工作区失败,请稍后手动打开。", - ); - } - } else if (workspaceErrorMessage) { - toast.error( - `云端结果已生成,但回流本地工作区失败:${workspaceErrorMessage}`, - ); - } - return; - } - - if (isTerminalServiceSkillRunStatus(run.status)) { - throw new Error( - run.errorMessage || - `${skill.title} ${getServiceSkillRunStatusLabel(run.status)}`, - ); - } - - toast.info( - `${skill.title} 已提交云端,当前仍在 ${getServiceSkillRunStatusLabel(run.status)}。`, - { - id: toastId, - }, - ); - } catch (error) { - toast.error(`提交云端运行失败:${getErrorMessage(error)}`, { - id: toastId, - }); - } - return; - } - - if (skill.runnerType !== "instant") { - toast.info("当前先进入工作区生成首版结果;如需持续运行,可继续创建本地任务。"); - } - - let workspacePayload: HomeShellEnterWorkspacePayload; - try { - workspacePayload = await prepareServiceSkillWorkspacePayload( - skill, - prompt, - ); - } catch (error) { - toast.error(`准备技能工作区失败:${getErrorMessage(error)}`); - return; - } - - const entered = handleEnterWorkspace(workspacePayload); - - if (!entered) { - return; - } - - recordServiceSkillUsage({ - skillId: skill.id, - runnerType: skill.runnerType, - }); - setServiceSkillDialogOpen(false); - setSelectedServiceSkill(null); - }, - [ - handleEnterWorkspace, - input, - prepareServiceSkillCloudResultWorkspacePayload, - prepareServiceSkillSiteWorkspacePayload, - prepareServiceSkillWorkspacePayload, - recordServiceSkillUsage, - ], - ); - - const handleEmptyStateSend = useCallback( - async ( - value: string, - sendExecutionStrategy?: "react" | "code_orchestrated" | "auto", - images?: MessageImage[], - ) => { - if (sendExecutionStrategy) { - setExecutionStrategy(sendExecutionStrategy); - } - - const trimmedValue = value.trim(); - if ( - activeTheme === "general" && - !images?.length && - trimmedValue && - !trimmedValue.startsWith("/") && - !trimmedValue.startsWith("@") - ) { - const matchedSiteSkill = matchAutoLaunchSiteSkillFromText({ - inputText: trimmedValue, - serviceSkills, - }); - if (matchedSiteSkill) { - await handleServiceSkillLaunch( - matchedSiteSkill.skill, - matchedSiteSkill.slotValues, - { - launchUserInput: matchedSiteSkill.launchUserInput, - }, - ); - return; - } - } - - handleEnterWorkspace({ - prompt: value, - images, - }); - }, - [ - activeTheme, - handleEnterWorkspace, - handleServiceSkillLaunch, - serviceSkills, - setExecutionStrategy, - ], - ); - - const handleServiceSkillAutomationSetup = useCallback( - async (skill: ServiceSkillHomeItem, slotValues: ServiceSkillSlotValues) => { - if (!supportsServiceSkillLocalAutomation(skill)) { - await handleServiceSkillLaunch(skill, slotValues); - return; - } - - const normalizedProjectId = normalizeProjectId(currentProjectId); - if (!normalizedProjectId) { - toast.error("缺少项目工作区,请先选择项目后再创建本地自动化任务。"); - return; - } - - const prompt = composeServiceSkillPrompt({ - skill, - slotValues, - userInput: input.trim() || undefined, - }); - const userInput = input.trim() || undefined; - - try { - let workspaces: Project[]; - try { - workspaces = prioritizeAutomationWorkspaces( - await listProjects(), - normalizedProjectId, - skill.themeTarget ?? activeTheme, - ); - } catch { - workspaces = [ - buildFallbackAutomationWorkspace( - normalizedProjectId, - skill.themeTarget ?? activeTheme, - ), - ]; - } - - setAutomationWorkspaces(workspaces); - setAutomationDialogInitialValues( - buildServiceSkillAutomationInitialValues({ - skill, - slotValues, - userInput, - workspaceId: normalizedProjectId, - }), - ); - setPendingServiceSkillAutomation({ - skill, - prompt, - slotValues, - userInput, - usage: { - skillId: skill.id, - runnerType: skill.runnerType, - }, - }); - setServiceSkillDialogOpen(false); - setSelectedServiceSkill(null); - setAutomationDialogOpen(true); - } catch (error) { - toast.error(`准备本地自动化任务失败:${getErrorMessage(error)}`); - } - }, - [activeTheme, currentProjectId, handleServiceSkillLaunch, input], - ); - - const handleAutomationDialogOpenChange = useCallback((open: boolean) => { - setAutomationDialogOpen(open); - if (!open) { - setAutomationDialogInitialValues(null); - setPendingServiceSkillAutomation(null); - } - }, []); - - const handleAutomationDialogSubmit = useCallback( - async (payload: AutomationJobDialogSubmit) => { - if (payload.mode !== "create") { - throw new Error("当前技能入口只支持创建新的本地自动化任务"); - } - - setAutomationJobSaving(true); - try { - const pendingLaunch = pendingServiceSkillAutomation; - let request = payload.request; - let automationContentId: string | null = null; - - if (pendingLaunch && request.payload.kind === "agent_turn") { - const createdContent = await createServiceSkillSeededContent( - pendingLaunch.skill, - request.workspace_id, - ); - automationContentId = createdContent?.id ?? null; - request = { - ...request, - payload: { - ...request.payload, - ...buildServiceSkillAutomationAgentTurnPayloadContext({ - skill: pendingLaunch.skill, - slotValues: pendingLaunch.slotValues, - userInput: pendingLaunch.userInput, - contentId: automationContentId, - }), - }, - }; - } - - const createdJob = await createAutomationJob(request); - toast.success(`本地自动化任务已创建:${createdJob.name}`); - - setAutomationDialogOpen(false); - setAutomationDialogInitialValues(null); - setPendingServiceSkillAutomation(null); - - if (!pendingLaunch) { - return; - } - - recordServiceSkillAutomationLink({ - skillId: pendingLaunch.usage.skillId, - jobId: createdJob.id, - jobName: createdJob.name, - }); - recordServiceSkillUsage(pendingLaunch.usage); - let workspacePayload: HomeShellEnterWorkspacePayload; - try { - workspacePayload = await prepareServiceSkillWorkspacePayload( - pendingLaunch.skill, - pendingLaunch.prompt, - { - contentId: automationContentId, - projectId: request.workspace_id, - }, - ); - } catch (error) { - toast.error( - `自动化任务已创建,但准备工作区失败:${getErrorMessage(error)}`, - ); - return; - } - const entered = handleEnterWorkspace(workspacePayload); - if (!entered) { - toast.error("自动化任务已创建,但进入工作区失败,请稍后手动打开。"); - } - } catch (error) { - toast.error(`创建本地自动化任务失败:${getErrorMessage(error)}`); - throw error; - } finally { - setAutomationJobSaving(false); - } - }, - [ - createServiceSkillSeededContent, - handleEnterWorkspace, - pendingServiceSkillAutomation, - prepareServiceSkillWorkspacePayload, - recordServiceSkillUsage, - ], - ); - - const handleRecommendationClick = useCallback( - (shortLabel: string, fullPrompt: string) => { - setInput(fullPrompt); - - if ( - activeTheme !== "general" || - !isTeamRuntimeRecommendation(shortLabel, fullPrompt) - ) { - return; - } - - const { nextToolPreferences, changed } = - enableSubagentPreference(effectiveChatToolPreferences); - - if (changed) { - setChatToolPreferences(nextToolPreferences); - } - saveChatToolPreferences(nextToolPreferences, activeTheme); - handleEnterWorkspace({ - prompt: fullPrompt, - toolPreferences: nextToolPreferences, - }); - }, - [ - activeTheme, - effectiveChatToolPreferences, - handleEnterWorkspace, - setChatToolPreferences, - ], - ); - - return ( - - - - - - { - void handleEmptyStateSend( - value, - sendExecutionStrategy, - images, - ); - }} - providerType={providerType} - setProviderType={setProviderType} - model={model} - setModel={setModel} - modelSelectorBackgroundPreload="idle" - executionStrategy={executionStrategy} - setExecutionStrategy={setExecutionStrategy} - onManageProviders={() => { - onNavigate?.("settings", { - tab: SettingsTabs.Providers, - }); - }} - webSearchEnabled={effectiveChatToolPreferences.webSearch} - onWebSearchEnabledChange={(enabled) => - setChatToolPreferences((previous) => ({ - ...previous, - webSearch: enabled, - })) - } - thinkingEnabled={effectiveChatToolPreferences.thinking} - onThinkingEnabledChange={(enabled) => - setChatToolPreferences((previous) => ({ - ...previous, - thinking: enabled, - })) - } - subagentEnabled={effectiveChatToolPreferences.subagent} - onSubagentEnabledChange={(enabled) => - setChatToolPreferences((previous) => ({ - ...previous, - subagent: enabled, - })) - } - selectedTeam={selectedTeam} - onSelectTeam={handleManualSelectTeam} - onEnableSuggestedTeam={handleEnableSuggestedTeam} - creationMode={creationMode} - onCreationModeChange={setCreationMode} - activeTheme={activeTheme} - onThemeChange={(theme) => { - if (!lockTheme) { - setActiveTheme(theme); - } - }} - showThemeTabs={false} - hasCanvasContent={false} - hasContentId={false} - selectedText="" - onRecommendationClick={handleRecommendationClick} - supportingSlotOverride={ - activeTheme === "general" ? ( - - ) : undefined - } - characters={projectMemory?.characters || []} - skills={skills} - serviceSkills={activeTheme === "general" ? serviceSkills : []} - isSkillsLoading={skillsLoading} - onSelectServiceSkill={handleServiceSkillSelect} - onNavigateToSettings={() => { - onNavigate?.("skills"); - }} - onRefreshSkills={handleRefreshSkills} - onLaunchBrowserAssist={ - activeTheme === "general" - ? handleOpenBrowserRuntimeFromHome - : undefined - } - browserAssistLoading={browserAssistLoading} - projectId={currentProjectId} - onProjectChange={handleProjectChange} - skipProjectSelectorWorkspaceReadyCheck - deferProjectSelectorListLoad - configLoadStrategy="idle" - onOpenSettings={() => { - onNavigate?.("settings", { - tab: SettingsTabs.Appearance, - }); - }} - /> - { - setServiceSkillDialogOpen(open); - if (!open) { - setSelectedServiceSkill(null); - } - }} - onLaunch={handleServiceSkillLaunch} - onCreateAutomation={handleServiceSkillAutomationSetup} - onOpenBrowserRuntime={handleServiceSkillBrowserRuntimeLaunch} - /> - - - - - - - ); -} diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 8e6946856..ed824933f 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -57,6 +57,7 @@ import { ensureWorkspaceReady, type Project, } from "@/lib/api/project"; +import { createImageGenerationTaskArtifact } from "@/lib/api/mediaTasks"; import { updateAgentRuntimeSession } from "@/lib/api/agentRuntime"; import { getProjectMemory, @@ -149,6 +150,7 @@ import { useWorkspaceGeneralResourceSync } from "./workspace/useWorkspaceGeneral import { useWorkspaceArtifactWorkbenchActions } from "./workspace/useWorkspaceArtifactWorkbenchActions"; import { useWorkspaceImageWorkbenchActionRuntime } from "./workspace/useWorkspaceImageWorkbenchActionRuntime"; import { useWorkspaceImageWorkbenchEventRuntime } from "./workspace/useWorkspaceImageWorkbenchEventRuntime"; +import { useWorkspaceImageTaskPreviewRuntime } from "./workspace/useWorkspaceImageTaskPreviewRuntime"; import { useWorkspaceRuntimeTeamDispatchPreviewRuntime } from "./workspace/useWorkspaceRuntimeTeamDispatchPreviewRuntime"; import { useWorkspaceSessionRestore } from "./workspace/useWorkspaceSessionRestore"; import { useWorkspaceResetRuntime } from "./workspace/useWorkspaceResetRuntime"; @@ -343,6 +345,10 @@ export function AgentChatWorkspace({ const [layoutMode, setLayoutMode] = useState( shouldBootstrapCanvasOnEntry ? "canvas" : "chat", ); + const shouldPreserveEntryThemeOnHome = + agentEntry === "new-task" && !contentId; + const shouldPreserveBlankHomeSurface = + shouldPreserveEntryThemeOnHome && normalizedEntryTheme === "general"; const [isInitialContentLoading, setIsInitialContentLoading] = useState( shouldBootstrapCanvasOnEntry, ); @@ -480,8 +486,6 @@ export function AgentChatWorkspace({ selectedSize: imageWorkbenchSelectedSize, setSelectedSize: setImageWorkbenchSelectedSize, preferredProviderUnavailable: imageWorkbenchPreferredProviderUnavailable, - generateImage: runImageWorkbenchGeneration, - cancelGeneration: cancelImageWorkbenchGeneration, saveImagesToResource: saveImageWorkbenchImagesToResource, } = imageWorkbenchGenerationRuntime; const imageWorkbenchPreferenceSourceLabel = useMemo(() => { @@ -743,7 +747,10 @@ export function AgentChatWorkspace({ theme, workspaceType: p.workspaceType, }); - if (!lockTheme || !initialTheme) { + if ( + !shouldPreserveEntryThemeOnHome && + (!lockTheme || !initialTheme) + ) { setActiveTheme(theme); } @@ -922,7 +929,13 @@ export function AgentChatWorkspace({ return () => { cancelled = true; }; - }, [projectId, contentId, lockTheme, initialTheme]); + }, [ + projectId, + contentId, + lockTheme, + initialTheme, + shouldPreserveEntryThemeOnHome, + ]); useEffect(() => { if (!shouldBootstrapCanvasOnEntry) { @@ -1302,7 +1315,22 @@ export function AgentChatWorkspace({ ); const appendLocalDispatchMessages = useCallback( (nextMessages: Message[]) => { - setChatMessages((previous) => [...previous, ...nextMessages]); + setChatMessages((previous) => { + const next = [...previous]; + + for (const message of nextMessages) { + const existingIndex = next.findIndex( + (candidate) => candidate.id === message.id, + ); + if (existingIndex === -1) { + next.push(message); + continue; + } + next[existingIndex] = message; + } + + return next; + }); }, [setChatMessages], ); @@ -2097,13 +2125,15 @@ export function AgentChatWorkspace({ const imageWorkbenchActionRuntime = useWorkspaceImageWorkbenchActionRuntime({ appendLocalDispatchMessages, - cancelImageWorkbenchGeneration, contentId, + createImageGenerationTask: createImageGenerationTaskArtifact, currentImageWorkbenchState, + imageWorkbenchSelectedModelId, + imageWorkbenchSelectedProviderId, imageWorkbenchSelectedSize, imageWorkbenchSessionKey, projectId, - runImageWorkbenchGeneration, + projectRootPath: project?.rootPath || null, saveImageWorkbenchImagesToResource, setCanvasState, setInput, @@ -2296,6 +2326,15 @@ export function AgentChatWorkspace({ // 布局层按实际展示内容判断,避免 browser preflight / bootstrap 预览仍被视为空白态。 const hasDisplayMessages = displayMessages.length > 0; const hasMessages = hasDisplayMessages; + const effectiveShowChatPanel = + showChatPanel || + (agentEntry === "new-task" && + (hasDisplayMessages || + isThemeWorkbench || + (!shouldUseCompactThemeWorkbench && isBootstrapDispatchPending) || + isSending || + queuedTurns.length > 0 || + Boolean(browserTaskPreflight))); const handleCanvasSelectionTextChange = useCallback((text: string) => { const normalized = text.trim().replace(/\s+/g, " "); @@ -2322,7 +2361,7 @@ export function AgentChatWorkspace({ isThemeWorkbench, hasPendingA2UIForm, layoutMode, - showChatPanel, + showChatPanel: effectiveShowChatPanel, showSidebar, defaultTopicSidebarVisible, hasMessages, @@ -2330,6 +2369,7 @@ export function AgentChatWorkspace({ autoCollapsedTopicSidebarRef, mappedTheme, normalizedEntryTheme, + shouldPreserveBlankHomeSurface, shouldBootstrapCanvasOnEntry, canvasState, generalCanvasState, @@ -2581,6 +2621,16 @@ export function AgentChatWorkspace({ handleImageWorkbenchCommand, }); + useWorkspaceImageTaskPreviewRuntime({ + sessionId, + projectId, + contentId, + projectRootPath: project?.rootPath || null, + canvasState, + setChatMessages, + updateCurrentImageWorkbenchState, + }); + const shellChromeRuntime = useWorkspaceShellChromeRuntime({ agentEntry, browserTaskPreflight, @@ -2602,7 +2652,7 @@ export function AgentChatWorkspace({ teamDispatchPreviewState, }); const themeWorkbenchShellRuntime = useWorkspaceThemeWorkbenchShellRuntime({ - showChatPanel, + showChatPanel: effectiveShowChatPanel, showSidebar, hasPendingA2UIForm, contextHarnessRuntime, @@ -2852,7 +2902,7 @@ export function AgentChatWorkspace({ handleOpenBrowserAssistInCanvas: handleOpenBrowserRuntimeForBrowserAssist, browserAssistLaunching, hideHistoryToggle, - showChatPanel, + showChatPanel: effectiveShowChatPanel, topBarChrome, onBackToProjectManagement, fromResources, diff --git a/src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.test.tsx b/src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.test.tsx deleted file mode 100644 index 24eded863..000000000 --- a/src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ClawHomeSolutionsPanel } from "./ClawHomeSolutionsPanel"; -import type { ClawSolutionHomeItem } from "./types"; - -const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; - -beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; -}); - -afterEach(() => { - while (mountedRoots.length > 0) { - const mounted = mountedRoots.pop(); - if (!mounted) break; - act(() => { - mounted.root.unmount(); - }); - mounted.container.remove(); - } - vi.clearAllMocks(); -}); - -function renderPanel( - props: React.ComponentProps, -) { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - act(() => { - root.render(); - }); - - mountedRoots.push({ root, container }); - return container; -} - -describe("ClawHomeSolutionsPanel", () => { - it("应渲染方案信息并透传选择回调", () => { - const solutions: ClawSolutionHomeItem[] = [ - { - id: "social-post-starter", - title: "社媒主稿生成", - summary: "进入社媒专项工作台并生成一版首稿。", - outputHint: "社媒首稿 + 平台结构", - recommendedCapabilities: ["模型", "社媒主题"], - readiness: "ready", - readinessMessage: "可直接开始", - badge: "社媒方案", - recentUsedAt: null, - isRecent: false, - readinessLabel: "可直接开始", - readinessTone: "emerald", - }, - ]; - const onSelect = vi.fn(); - - const container = renderPanel({ - solutions, - onSelect, - }); - - expect(container.textContent).toContain("推荐方案"); - expect(container.textContent).toContain("社媒主稿生成"); - expect(container.textContent).toContain("产出:社媒首稿 + 平台结构"); - expect(container.textContent).toContain("可直接开始"); - expect(container.textContent).toContain("立即开始"); - - const solutionButton = container.querySelector( - '[data-testid="claw-solution-social-post-starter"]', - ) as HTMLButtonElement | null; - - expect(solutionButton).toBeTruthy(); - - act(() => { - solutionButton?.click(); - }); - - expect(onSelect).toHaveBeenCalledWith(solutions[0]); - }); - - it("加载中且无方案时应展示加载状态", () => { - const container = renderPanel({ - solutions: [], - loading: true, - onSelect: vi.fn(), - }); - - expect(container.textContent).toContain("正在加载推荐方案"); - }); - - it("未就绪方案应展示去配置动作文案", () => { - const solutions: ClawSolutionHomeItem[] = [ - { - id: "web-research-brief", - title: "网页研究简报", - summary: "先配置模型后再开始。", - outputHint: "研究提纲 + 结论摘要", - recommendedCapabilities: ["模型"], - readiness: "needs_setup", - readinessMessage: "请先配置模型", - reasonCode: "missing_model", - badge: "Claw 方案", - recentUsedAt: null, - isRecent: false, - readinessLabel: "先配置模型", - readinessTone: "amber", - }, - ]; - - const container = renderPanel({ - solutions, - onSelect: vi.fn(), - }); - - expect(container.textContent).toContain("先配置模型"); - expect(container.textContent).toContain("去配置"); - }); -}); diff --git a/src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.tsx b/src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.tsx deleted file mode 100644 index 94997be24..000000000 --- a/src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useMemo } from "react"; -import { EmptyStateQuickActions } from "../components/EmptyStateQuickActions"; -import type { ClawSolutionHomeItem } from "./types"; - -interface ClawHomeSolutionsPanelProps { - solutions: ClawSolutionHomeItem[]; - loading?: boolean; - onSelect: (solution: ClawSolutionHomeItem) => void | Promise; -} - -export function ClawHomeSolutionsPanel({ - solutions, - loading = false, - onSelect, -}: ClawHomeSolutionsPanelProps) { - const items = useMemo( - () => - solutions.map((solution) => ({ - key: solution.id, - title: solution.title, - description: solution.summary, - badge: solution.badge, - prompt: "", - actionLabel: solution.readiness === "ready" ? "立即开始" : "去配置", - outputHint: solution.outputHint, - statusLabel: solution.readinessLabel, - statusTone: solution.readinessTone, - statusDescription: solution.readinessMessage, - solutionId: solution.id, - testId: `claw-solution-${solution.id}`, - })), - [solutions], - ); - - return ( - { - const solution = solutions.find( - (candidate) => candidate.id === item.solutionId, - ); - if (solution) { - void onSelect(solution); - } - }} - /> - ); -} - -export default ClawHomeSolutionsPanel; diff --git a/src/components/agent/chat/claw-solutions/actionDispatcher.test.ts b/src/components/agent/chat/claw-solutions/actionDispatcher.test.ts deleted file mode 100644 index e3d6b1442..000000000 --- a/src/components/agent/chat/claw-solutions/actionDispatcher.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - enableSubagentPreference, - resolveClawSolutionLaunch, - resolveClawSolutionSetupTarget, -} from "./actionDispatcher"; -import type { ChatToolPreferences } from "../utils/chatToolPreferences"; -import type { ClawSolutionPreparation } from "./types"; -import { SettingsTabs } from "@/types/settings"; - -const defaultPreferences: ChatToolPreferences = { - webSearch: false, - thinking: false, - task: false, - subagent: false, -}; - -describe("claw solution action dispatcher", () => { - it("应在未开启时启用多代理偏好", () => { - expect(enableSubagentPreference(defaultPreferences)).toEqual({ - nextToolPreferences: { - webSearch: false, - thinking: false, - task: false, - subagent: true, - }, - changed: true, - }); - }); - - it("已开启多代理时应保持原偏好", () => { - const current = { - ...defaultPreferences, - subagent: true, - }; - - expect(enableSubagentPreference(current)).toEqual({ - nextToolPreferences: current, - changed: false, - }); - }); - - it("应把社媒方案转换为工作区进入载荷", () => { - const preparation: ClawSolutionPreparation = { - solutionId: "social-post-starter", - actionType: "navigate_theme", - prompt: "请先帮我起草一版社媒内容首稿", - themeTarget: "social-media", - shouldLaunchBrowserAssist: false, - shouldEnableTeamMode: false, - readiness: "ready", - readinessMessage: "可直接开始", - }; - - expect(resolveClawSolutionLaunch(preparation, defaultPreferences)).toEqual({ - nextToolPreferences: defaultPreferences, - preferencesChanged: false, - shouldStartBrowserAssistLoading: false, - enterWorkspacePayload: { - prompt: "请先帮我起草一版社媒内容首稿", - openBrowserAssistOnMount: false, - toolPreferences: defaultPreferences, - themeOverride: "social-media", - }, - usageRecord: { - solutionId: "social-post-starter", - actionType: "navigate_theme", - themeTarget: "social-media", - }, - }); - }); - - it("网页研究简报应在进入工作区前开启联网研究偏好", () => { - const preparation: ClawSolutionPreparation = { - solutionId: "web-research-brief", - actionType: "fill_input", - prompt: "请围绕这个主题先给我做一版网页研究简报", - shouldLaunchBrowserAssist: false, - shouldEnableTeamMode: false, - readiness: "ready", - readinessMessage: "可直接开始", - }; - - expect(resolveClawSolutionLaunch(preparation, defaultPreferences)).toEqual({ - nextToolPreferences: { - webSearch: true, - thinking: false, - task: false, - subagent: false, - }, - preferencesChanged: true, - shouldStartBrowserAssistLoading: false, - enterWorkspacePayload: { - prompt: "请围绕这个主题先给我做一版网页研究简报", - openBrowserAssistOnMount: false, - toolPreferences: { - webSearch: true, - thinking: false, - task: false, - subagent: false, - }, - themeOverride: undefined, - }, - usageRecord: { - solutionId: "web-research-brief", - actionType: "fill_input", - themeTarget: null, - }, - }); - }); - - it("应把多代理方案转换为开启 team 模式的载荷", () => { - const preparation: ClawSolutionPreparation = { - solutionId: "team-breakdown", - actionType: "enable_team_mode", - prompt: "请把这个任务按多代理方式拆解", - shouldLaunchBrowserAssist: false, - shouldEnableTeamMode: true, - readiness: "ready", - readinessMessage: "可直接开始,进入后会启用多代理偏好", - }; - - expect(resolveClawSolutionLaunch(preparation, defaultPreferences)).toEqual({ - nextToolPreferences: { - webSearch: false, - thinking: false, - task: false, - subagent: true, - }, - preferencesChanged: true, - shouldStartBrowserAssistLoading: false, - enterWorkspacePayload: { - prompt: "请把这个任务按多代理方式拆解", - openBrowserAssistOnMount: false, - toolPreferences: { - webSearch: false, - thinking: false, - task: false, - subagent: true, - }, - themeOverride: undefined, - }, - usageRecord: { - solutionId: "team-breakdown", - actionType: "enable_team_mode", - themeTarget: null, - }, - }); - }); - - it("应把未就绪方案映射到对应配置入口", () => { - expect(resolveClawSolutionSetupTarget("needs_setup", "missing_model")).toBe( - SettingsTabs.Providers, - ); - expect( - resolveClawSolutionSetupTarget( - "needs_capability", - "missing_skill_dependency", - ), - ).toBe(SettingsTabs.Skills); - expect( - resolveClawSolutionSetupTarget( - "needs_capability", - "missing_browser_capability", - ), - ).toBe(SettingsTabs.ChromeRelay); - }); -}); diff --git a/src/components/agent/chat/claw-solutions/actionDispatcher.ts b/src/components/agent/chat/claw-solutions/actionDispatcher.ts deleted file mode 100644 index 478aa099c..000000000 --- a/src/components/agent/chat/claw-solutions/actionDispatcher.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { SettingsTabs } from "@/types/settings"; -import type { ChatToolPreferences } from "../utils/chatToolPreferences"; -import type { - ClawSolutionPreparation, - ClawSolutionReadiness, - ClawSolutionReasonCode, - RecordClawSolutionUsageInput, -} from "./types"; - -export interface ClawSolutionEnterWorkspacePayload { - prompt: string; - openBrowserAssistOnMount?: boolean; - toolPreferences: ChatToolPreferences; - themeOverride?: string; -} - -export interface ResolvedClawSolutionLaunch { - nextToolPreferences: ChatToolPreferences; - preferencesChanged: boolean; - shouldStartBrowserAssistLoading: boolean; - enterWorkspacePayload: ClawSolutionEnterWorkspacePayload; - usageRecord: RecordClawSolutionUsageInput; -} - -export function resolveClawSolutionSetupTarget( - readiness: ClawSolutionReadiness, - reasonCode?: ClawSolutionReasonCode, -): SettingsTabs | null { - if (readiness === "needs_setup") { - return SettingsTabs.Providers; - } - - if (readiness !== "needs_capability") { - return null; - } - - if (reasonCode === "missing_skill_dependency") { - return SettingsTabs.Skills; - } - - if (reasonCode === "missing_browser_capability") { - return SettingsTabs.ChromeRelay; - } - - return SettingsTabs.Providers; -} - -export function enableSubagentPreference(preferences: ChatToolPreferences): { - nextToolPreferences: ChatToolPreferences; - changed: boolean; -} { - if (preferences.subagent) { - return { - nextToolPreferences: preferences, - changed: false, - }; - } - - return { - nextToolPreferences: { - ...preferences, - subagent: true, - }, - changed: true, - }; -} - -function applyClawSolutionPreferencePreset( - preferences: ChatToolPreferences, - preset: Partial, -): { - nextToolPreferences: ChatToolPreferences; - changed: boolean; -} { - let changed = false; - const nextPreferences = { ...preferences }; - - for (const [key, value] of Object.entries(preset) as Array< - [keyof ChatToolPreferences, boolean | undefined] - >) { - if (!value || nextPreferences[key]) { - continue; - } - nextPreferences[key] = true; - changed = true; - } - - return { - nextToolPreferences: changed ? nextPreferences : preferences, - changed, - }; -} - -function resolveClawSolutionPreferencePreset( - preparation: ClawSolutionPreparation, -): Partial { - const preset: Partial = {}; - - if (preparation.solutionId === "web-research-brief") { - preset.webSearch = true; - } - - if (preparation.shouldEnableTeamMode) { - preset.subagent = true; - } - - return preset; -} - -export function resolveClawSolutionLaunch( - preparation: ClawSolutionPreparation, - currentToolPreferences: ChatToolPreferences, -): ResolvedClawSolutionLaunch { - const { nextToolPreferences, changed } = applyClawSolutionPreferencePreset( - currentToolPreferences, - resolveClawSolutionPreferencePreset(preparation), - ); - - return { - nextToolPreferences, - preferencesChanged: changed, - shouldStartBrowserAssistLoading: preparation.shouldLaunchBrowserAssist, - enterWorkspacePayload: { - prompt: preparation.prompt, - openBrowserAssistOnMount: preparation.shouldLaunchBrowserAssist, - toolPreferences: nextToolPreferences, - themeOverride: preparation.themeTarget, - }, - usageRecord: { - solutionId: preparation.solutionId, - actionType: preparation.actionType, - themeTarget: preparation.themeTarget ?? null, - }, - }; -} diff --git a/src/components/agent/chat/claw-solutions/storage.test.ts b/src/components/agent/chat/claw-solutions/storage.test.ts deleted file mode 100644 index 8feb63245..000000000 --- a/src/components/agent/chat/claw-solutions/storage.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - getClawSolutionUsageMap, - listClawSolutionUsage, - recordClawSolutionUsage, -} from "./storage"; - -describe("claw solution storage", () => { - beforeEach(() => { - window.localStorage.clear(); - }); - - afterEach(() => { - window.localStorage.clear(); - }); - - it("应按最近时间记录方案使用", () => { - recordClawSolutionUsage({ - solutionId: "web-research-brief", - usedAt: 100, - actionType: "fill_input", - }); - recordClawSolutionUsage({ - solutionId: "team-breakdown", - usedAt: 300, - actionType: "enable_team_mode", - }); - recordClawSolutionUsage({ - solutionId: "web-research-brief", - usedAt: 500, - actionType: "fill_input", - themeTarget: "general", - }); - - expect(listClawSolutionUsage()).toEqual([ - { - solutionId: "web-research-brief", - usedAt: 500, - actionType: "fill_input", - themeTarget: "general", - }, - { - solutionId: "team-breakdown", - usedAt: 300, - actionType: "enable_team_mode", - themeTarget: null, - }, - ]); - }); - - it("应提供按方案 ID 查询的映射", () => { - recordClawSolutionUsage({ - solutionId: "social-post-starter", - usedAt: 200, - actionType: "navigate_theme", - themeTarget: "social-media", - }); - - const usageMap = getClawSolutionUsageMap(); - expect(usageMap.get("social-post-starter")).toEqual({ - solutionId: "social-post-starter", - usedAt: 200, - actionType: "navigate_theme", - themeTarget: "social-media", - }); - }); -}); diff --git a/src/components/agent/chat/claw-solutions/storage.ts b/src/components/agent/chat/claw-solutions/storage.ts deleted file mode 100644 index f81f451f9..000000000 --- a/src/components/agent/chat/claw-solutions/storage.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { - ClawSolutionUsageRecord, - RecordClawSolutionUsageInput, -} from "./types"; - -const CLAW_SOLUTION_USAGE_STORAGE_KEY = "lime:claw-solution-usage:v1"; -const MAX_CLAW_SOLUTION_USAGE_RECORDS = 12; - -function isValidUsageRecord(value: unknown): value is ClawSolutionUsageRecord { - if (!value || typeof value !== "object") { - return false; - } - - const record = value as Partial; - return ( - typeof record.solutionId === "string" && - record.solutionId.length > 0 && - typeof record.usedAt === "number" && - Number.isFinite(record.usedAt) - ); -} - -export function listClawSolutionUsage(): ClawSolutionUsageRecord[] { - if (typeof window === "undefined") { - return []; - } - - try { - const raw = window.localStorage.getItem(CLAW_SOLUTION_USAGE_STORAGE_KEY); - if (!raw) { - return []; - } - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return []; - } - - return parsed - .filter(isValidUsageRecord) - .sort((left, right) => right.usedAt - left.usedAt) - .slice(0, MAX_CLAW_SOLUTION_USAGE_RECORDS); - } catch { - return []; - } -} - -export function getClawSolutionUsageMap(): Map< - string, - ClawSolutionUsageRecord -> { - return new Map( - listClawSolutionUsage().map((record) => [record.solutionId, record]), - ); -} - -export function recordClawSolutionUsage( - input: RecordClawSolutionUsageInput, -): ClawSolutionUsageRecord[] { - const nextRecord: ClawSolutionUsageRecord = { - solutionId: input.solutionId, - usedAt: input.usedAt ?? Date.now(), - actionType: input.actionType, - themeTarget: input.themeTarget ?? null, - }; - - const nextRecords = [ - nextRecord, - ...listClawSolutionUsage().filter( - (record) => record.solutionId !== nextRecord.solutionId, - ), - ].slice(0, MAX_CLAW_SOLUTION_USAGE_RECORDS); - - if (typeof window === "undefined") { - return nextRecords; - } - - try { - window.localStorage.setItem( - CLAW_SOLUTION_USAGE_STORAGE_KEY, - JSON.stringify(nextRecords), - ); - } catch { - // ignore write errors - } - - return nextRecords; -} diff --git a/src/components/agent/chat/claw-solutions/types.ts b/src/components/agent/chat/claw-solutions/types.ts deleted file mode 100644 index 71fbdbb8a..000000000 --- a/src/components/agent/chat/claw-solutions/types.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { - ClawSolutionActionType, - ClawSolutionDetail, - ClawSolutionPreparation, - ClawSolutionReadiness, - ClawSolutionReadinessResult, - ClawSolutionReasonCode, - ClawSolutionSummary, -} from "@/lib/api/clawSolutions"; - -export type { - ClawSolutionActionType, - ClawSolutionDetail, - ClawSolutionPreparation, - ClawSolutionReadiness, - ClawSolutionReadinessResult, - ClawSolutionReasonCode, - ClawSolutionSummary, -}; - -export type ClawSolutionTone = "slate" | "sky" | "emerald" | "amber"; - -export interface ClawSolutionHomeItem extends ClawSolutionSummary { - badge: string; - recentUsedAt: number | null; - isRecent: boolean; - readinessLabel: string; - readinessTone: ClawSolutionTone; -} - -export interface ClawSolutionUsageRecord { - solutionId: string; - usedAt: number; - actionType?: ClawSolutionActionType; - themeTarget?: string | null; -} - -export interface RecordClawSolutionUsageInput { - solutionId: string; - usedAt?: number; - actionType?: ClawSolutionActionType; - themeTarget?: string | null; -} diff --git a/src/components/agent/chat/claw-solutions/useClawSolutions.ts b/src/components/agent/chat/claw-solutions/useClawSolutions.ts deleted file mode 100644 index d32d05e73..000000000 --- a/src/components/agent/chat/claw-solutions/useClawSolutions.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { listClawSolutions } from "@/lib/api/clawSolutions"; -import { getClawSolutionUsageMap, recordClawSolutionUsage } from "./storage"; -import type { - ClawSolutionHomeItem, - ClawSolutionReadiness, - ClawSolutionTone, - ClawSolutionSummary, - RecordClawSolutionUsageInput, -} from "./types"; - -function getReadinessLabel(readiness: ClawSolutionReadiness): string { - if (readiness === "needs_setup") { - return "先配置模型"; - } - if (readiness === "needs_capability") { - return "补齐能力后开始"; - } - return "可直接开始"; -} - -function getReadinessTone(readiness: ClawSolutionReadiness): ClawSolutionTone { - if (readiness === "needs_setup") { - return "amber"; - } - if (readiness === "needs_capability") { - return "sky"; - } - return "emerald"; -} - -function getSolutionBadge( - summary: ClawSolutionSummary, - isRecent: boolean, -): string { - if (isRecent) { - return "最近使用"; - } - if (summary.id === "social-post-starter") { - return "社媒方案"; - } - if (summary.id === "browser-assist-task") { - return "浏览器协助"; - } - if (summary.id === "team-breakdown") { - return "多代理"; - } - return "Claw 方案"; -} - -function buildHomeItems( - summaries: ClawSolutionSummary[], -): ClawSolutionHomeItem[] { - const usageMap = getClawSolutionUsageMap(); - const items: InternalClawSolutionHomeItem[] = summaries.map( - (summary, index) => { - const recent = usageMap.get(summary.id); - const recentUsedAt = recent?.usedAt ?? null; - const isRecent = typeof recentUsedAt === "number"; - - return { - ...summary, - badge: getSolutionBadge(summary, isRecent), - recentUsedAt, - isRecent, - readinessLabel: getReadinessLabel(summary.readiness), - readinessTone: getReadinessTone(summary.readiness), - _sortIndex: index, - }; - }, - ); - - return items - .sort((left, right) => { - if (left.recentUsedAt && right.recentUsedAt) { - if (left.recentUsedAt !== right.recentUsedAt) { - return right.recentUsedAt - left.recentUsedAt; - } - } else if (left.recentUsedAt) { - return -1; - } else if (right.recentUsedAt) { - return 1; - } - - return left._sortIndex - right._sortIndex; - }) - .map(({ _sortIndex, ...item }) => item); -} - -type InternalClawSolutionHomeItem = ClawSolutionHomeItem & { - _sortIndex: number; -}; - -interface UseClawSolutionsResult { - solutions: ClawSolutionHomeItem[]; - isLoading: boolean; - error: string | null; - refresh: () => Promise; - recordUsage: (input: RecordClawSolutionUsageInput) => void; -} - -export function useClawSolutions(enabled = true): UseClawSolutionsResult { - const [summaries, setSummaries] = useState([]); - const [isLoading, setIsLoading] = useState(enabled); - const [error, setError] = useState(null); - const [usageVersion, setUsageVersion] = useState(0); - - const refresh = useCallback(async () => { - if (!enabled) { - setSummaries([]); - setError(null); - setIsLoading(false); - return; - } - - setIsLoading(true); - try { - const nextSummaries = await listClawSolutions(); - setSummaries(nextSummaries); - setError(null); - } catch (reason) { - setSummaries([]); - setError(reason instanceof Error ? reason.message : String(reason)); - } finally { - setIsLoading(false); - } - }, [enabled]); - - useEffect(() => { - void refresh(); - }, [refresh]); - - const recordUsageAndRefresh = useCallback( - (input: RecordClawSolutionUsageInput) => { - recordClawSolutionUsage(input); - setUsageVersion((previous) => previous + 1); - }, - [], - ); - - const solutions = useMemo(() => { - void usageVersion; - return buildHomeItems(summaries); - }, [summaries, usageVersion]); - - return { - solutions, - isLoading, - error, - refresh, - recordUsage: recordUsageAndRefresh, - }; -} diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx index 9d507ac3e..5b4a32e54 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx @@ -812,7 +812,7 @@ export const AgentThreadReliabilityPanel: React.FC<
当前入口属于 `compat` 线程级快速诊断,只覆盖当前 thread 的运行信号。 - 正式交给外部 Claude Code / Codex 分析时,请优先使用工作台“交接制品 + 正式交给外部 Claude / Codex 分析时,请优先使用工作台“交接制品 → 外部分析交接”的 `analysis-brief.md / analysis-context.json` 主链;这里的“快速复制给 AI”只适合临时排障,“复制原始 JSON(debug)”适合程序化分析、存档或二次处理。
diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index 7a3a2555a..170a80666 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -165,6 +165,7 @@ beforeEach(() => { } ).IS_REACT_ACT_ENVIRONMENT = true; mockGetConfig.mockImplementation(async () => ({})); + window.localStorage.clear(); }); afterEach(() => { @@ -176,6 +177,7 @@ afterEach(() => { }); mounted.container.remove(); } + window.localStorage.clear(); vi.clearAllMocks(); }); @@ -249,6 +251,156 @@ function createGithubSearchServiceSkill(): ServiceSkillHomeItem { } describe("EmptyState", () => { + it("新建任务首页不应再显示旧 Claw 首页品牌文案", async () => { + const container = renderEmptyState({ + activeTheme: "general", + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(container.textContent).toContain("新建任务"); + expect(container.textContent).toContain("开始一个新任务"); + expect(container.textContent).not.toContain("青柠一下,灵感即来"); + expect(container.textContent).not.toContain("CLAW WORKSPACE"); + }); + + it("通用首页应展示推荐方案并替换旧快速启动内容", async () => { + const container = renderEmptyState({ + activeTheme: "general", + onLaunchBrowserAssist: vi.fn(), + }); + + await act(async () => { + await Promise.resolve(); + }); + + 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).not.toContain("生成配图"); + expect(container.textContent).not.toContain("Team 冒烟测试"); + }); + + it("点击网页研究简报应开启联网搜索并记录最近使用", async () => { + const setInput = vi.fn<(value: string) => void>(); + const onWebSearchEnabledChange = vi.fn<(enabled: boolean) => void>(); + const container = renderEmptyState({ + activeTheme: "general", + setInput, + onWebSearchEnabledChange, + }); + + await act(async () => { + await Promise.resolve(); + }); + + const card = container.querySelector( + '[data-testid="home-recommended-web-research-brief"]', + ) as HTMLDivElement | null; + expect(card).toBeTruthy(); + + act(() => { + card?.click(); + }); + + expect(onWebSearchEnabledChange).toHaveBeenCalledWith(true); + expect(setInput).toHaveBeenCalledWith( + "请围绕这个主题先给我做一版网页研究简报:明确研究目标、关键信息来源、核心发现、风险点,以及接下来最值得继续追踪的问题。", + ); + expect(container.textContent).toContain("最近使用"); + }); + + it("点击社媒主稿生成应切换到社媒主题并写入起始动作", async () => { + const setInput = vi.fn<(value: string) => void>(); + const onThemeChange = vi.fn<(theme: string) => void>(); + const container = renderEmptyState({ + activeTheme: "general", + setInput, + onThemeChange, + }); + + await act(async () => { + await Promise.resolve(); + }); + + const card = container.querySelector( + '[data-testid="home-recommended-social-post-starter"]', + ) as HTMLDivElement | null; + expect(card).toBeTruthy(); + + act(() => { + card?.click(); + }); + + expect(onThemeChange).toHaveBeenCalledWith("social-media"); + expect(setInput).toHaveBeenCalledWith( + "请先帮我起草一版社媒内容首稿:明确目标受众、平台语境、标题方向、正文结构和可继续扩写的角度。", + ); + }); + + it("点击浏览器协助办事应打开浏览器工作台并写入起始动作", async () => { + const setInput = vi.fn<(value: string) => void>(); + const onLaunchBrowserAssist = vi.fn(); + const container = renderEmptyState({ + activeTheme: "general", + setInput, + onLaunchBrowserAssist, + }); + + await act(async () => { + await Promise.resolve(); + }); + + const card = container.querySelector( + '[data-testid="home-recommended-browser-assist-task"]', + ) as HTMLDivElement | null; + expect(card).toBeTruthy(); + + act(() => { + card?.click(); + }); + + expect(onLaunchBrowserAssist).toHaveBeenCalledTimes(1); + expect(setInput).toHaveBeenCalledWith( + "请协助我完成一个浏览器任务:先明确目标网页、目标动作、约束条件和预期结果,再进入执行。", + ); + }); + + it("点击多代理拆任务应开启多代理偏好并写入起始动作", async () => { + const setInput = vi.fn<(value: string) => void>(); + const onSubagentEnabledChange = vi.fn<(enabled: boolean) => void>(); + const container = renderEmptyState({ + activeTheme: "general", + setInput, + onSubagentEnabledChange, + subagentEnabled: false, + }); + + await act(async () => { + await Promise.resolve(); + }); + + const card = container.querySelector( + '[data-testid="home-recommended-team-breakdown"]', + ) as HTMLDivElement | null; + expect(card).toBeTruthy(); + + act(() => { + card?.click(); + }); + + expect(onSubagentEnabledChange).toHaveBeenCalledWith(true); + expect(setInput).toHaveBeenCalledWith( + "请把这个任务按多代理方式拆解:先定义目标和约束,再拆成并行子任务,明确每个子代理的职责、产出和回收方式。", + ); + }); + it("应挂载 CharacterMention,并透传角色与技能", async () => { const characters: Character[] = [ { diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index ff871b3a6..3edd1a1f9 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -31,6 +31,11 @@ import { getContextualRecommendations, isTeamRuntimeRecommendation, } from "../utils/contextualRecommendations"; +import { + listHomeRecommendedSolutions, + recordHomeRecommendedSolutionUsage, + type HomeRecommendedSolutionItem, +} from "../utils/homeRecommendedSolutions"; import { EmptyStateComposerPanel } from "./EmptyStateComposerPanel"; import { EmptyStateHero } from "./EmptyStateHero"; import { EmptyStateQuickActions } from "./EmptyStateQuickActions"; @@ -240,10 +245,11 @@ const THEME_WORKBENCH_COPY: Record< } > = { general: { - title: "青柠一下,灵感即来", - description: "从一句想法,到成稿、成图、成片、成事。", + title: "开始一个新任务", + description: + "把目标、上下文和限制告诉我,我会围绕当前任务持续推进,而不是只回答一次。", supportingDescription: - "Claw 工作台会围绕一个目标持续对话、检索网页、补充素材,并把结果沉淀到工作台与项目资源,而不是只停留在一次性提问。", + "你可以直接输入需求,也可以先挂载技能、开启联网搜索或浏览器工作台,再开始执行。", }, "social-media": { title: "社媒内容工作台", @@ -359,6 +365,8 @@ export const EmptyState: React.FC = ({ appendSelectedTextToRecommendation, setAppendSelectedTextToRecommendation, ] = useState(true); + const [homeRecommendedSolutionsVersion, setHomeRecommendedSolutionsVersion] = + useState(0); // 加载配置 useEffect(() => { @@ -498,6 +506,14 @@ export const EmptyState: React.FC = ({ subagentEnabled, ]); + const homeRecommendedSolutions = useMemo( + () => { + void homeRecommendedSolutionsVersion; + return listHomeRecommendedSolutions(); + }, + [homeRecommendedSolutionsVersion], + ); + const selectedTextPreview = useMemo(() => { const normalized = (recommendationSelectedText || "") .trim() @@ -679,6 +695,36 @@ export const EmptyState: React.FC = ({ setInput(promptWithSelection); }; + const handleApplyHomeRecommendedSolution = ( + solution: HomeRecommendedSolutionItem, + ) => { + recordHomeRecommendedSolutionUsage(solution.id); + setHomeRecommendedSolutionsVersion((previous) => previous + 1); + + if (solution.shouldEnableWebSearch && !webSearchEnabled) { + onWebSearchEnabledChange?.(true); + } + + if (solution.shouldEnableTeamMode && !subagentEnabled) { + onSubagentEnabledChange?.(true); + } + + if (solution.themeTarget) { + handleThemeChange(solution.themeTarget); + } + + if (solution.shouldLaunchBrowserAssist) { + void onLaunchBrowserAssist?.(); + } + + const promptWithSelection = buildRecommendationPrompt( + solution.prompt, + selectedText, + appendSelectedTextToRecommendation, + ); + setInput(promptWithSelection); + }; + const themeTabs = showThemeTabs ? (
{categories.map((cat) => ( @@ -1055,6 +1101,34 @@ export const EmptyState: React.FC = ({ /> ); + const generalRecommendedSolutionsPanel = ( + ({ + key: solution.id, + title: solution.title, + description: solution.summary, + badge: solution.badge, + prompt: solution.prompt, + actionLabel: solution.actionLabel, + outputHint: solution.outputHint, + statusLabel: solution.statusLabel, + statusTone: solution.statusTone, + testId: `home-recommended-${solution.id}`, + }))} + embedded + onAction={(item) => { + const solution = homeRecommendedSolutions.find( + (candidate) => candidate.id === item.key, + ); + if (solution) { + handleApplyHomeRecommendedSolution(solution); + } + }} + /> + ); + const headerControls = onProjectChange ? (
@@ -1101,11 +1175,7 @@ export const EmptyState: React.FC = ({ = ({ cards={workspaceCards} features={workspaceFeatures} prioritySlot={composerPanel} - supportingSlot={supportingSlotOverride ?? defaultQuickActionsPanel} + supportingSlot={ + supportingSlotOverride ?? + (isGeneralTheme + ? generalRecommendedSolutionsPanel + : defaultQuickActionsPanel) + } themeTabs={themeTabs} headerControls={headerControls} /> diff --git a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx index 35cb02655..4b608f28f 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx @@ -273,7 +273,7 @@ function createToolInventory(): AgentRuntimeToolInventory { ], extension_surfaces: [ { - extension_name: "lime-browser", + extension_name: "mcp__lime-browser", description: "浏览器桥接工具面", source_kind: "mcp_bridge", deferred_loading: true, @@ -291,7 +291,7 @@ function createToolInventory(): AgentRuntimeToolInventory { { name: "mcp__lime-browser__navigate", description: "打开网页", - extension_name: "lime-browser", + extension_name: "mcp__lime-browser", source_kind: "mcp_bridge", deferred_loading: false, allowed_caller: "assistant", diff --git a/src/components/agent/chat/components/HarnessStatusPanel.tsx b/src/components/agent/chat/components/HarnessStatusPanel.tsx index a6ea54d6d..4e904b1cb 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.tsx @@ -57,6 +57,7 @@ import { exportAgentRuntimeReviewDecisionTemplate, saveAgentRuntimeReviewDecision, } from "@/lib/api/agentRuntime"; +import { getMcpInnerToolName } from "@/lib/api/mcp"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -3535,7 +3536,7 @@ export function HarnessStatusPanel({
把 handoff / evidence / replay 主链重新包装成外部 - Claude Code / Codex + Claude / Codex 可直接消费的分析交接;复制后可直接粘贴给 AI, 不需要你再手写补充 prompt。
@@ -3785,7 +3786,7 @@ export function HarnessStatusPanel({
尚未导出外部分析交接。点击“一键复制给 AI”时会自动先导出再复制, 用于把当前 Lime - 证据链直接交给外部 Claude Code / Codex + 证据链直接交给外部 Claude / Codex 做诊断与最小修复。
)} @@ -3799,7 +3800,7 @@ export function HarnessStatusPanel({ 人工审核记录
- 把外部 Claude Code / Codex 的分析结论回挂为 + 把外部 Claude / Codex 的分析结论回挂为 `review-decision.md/json` 模板,固定接受、延后、拒绝与回归要求;最终决策仍由开发者审核,不是 Lime 自动闭环。 @@ -5098,7 +5099,10 @@ export function HarnessStatusPanel({ >
- {entry.name} + {getMcpInnerToolName( + entry.name, + entry.server_name, + )} {entry.server_name} diff --git a/src/components/agent/chat/components/ImageWorkbenchCanvas.test.tsx b/src/components/agent/chat/components/ImageWorkbenchCanvas.test.tsx index 87a271e27..f6efde997 100644 --- a/src/components/agent/chat/components/ImageWorkbenchCanvas.test.tsx +++ b/src/components/agent/chat/components/ImageWorkbenchCanvas.test.tsx @@ -113,7 +113,9 @@ describe("ImageWorkbenchCanvas", () => { container.querySelector('[data-testid="image-workbench-view-scale"]') ?.textContent, ).toContain("100%"); - expect(container.textContent).toContain("结果直接留在任务卡内,聊天区只记录调度"); + expect(container.textContent).toContain( + "结果留在任务卡内,对话区会同步进度与结果摘要", + ); expect(container.textContent).toContain("1/1 输出"); expect(container.textContent).not.toContain("主图优先展示当前选中结果"); expect(container.textContent).not.toContain("X 0 / Y 0"); @@ -287,4 +289,55 @@ describe("ImageWorkbenchCanvas", () => { expect(onStopGeneration).toHaveBeenCalledTimes(1); }); + + it("仅展示处理中状态时,不应出现误导性的停止按钮", () => { + const { container } = mountHarness( + ImageWorkbenchCanvas, + { + tasks: [ + { + id: "task-4", + mode: "generate", + status: "queued", + prompt: "异步图片任务", + rawText: "@配图 生成 异步图片任务", + expectedCount: 1, + outputIds: [], + createdAt: Date.now(), + }, + ], + outputs: [], + selectedOutputId: null, + viewport: { x: 0, y: 0, scale: 1 }, + preferenceSummary: null, + preferenceWarning: null, + availableProviders: [{ id: "fal", name: "Fal" }], + selectedProviderId: "fal", + onProviderChange: vi.fn(), + availableModels: [ + { + id: "nano-banana", + name: "nano-banana", + supportedSizes: ["1024x1024"], + }, + ], + selectedModelId: "nano-banana", + onModelChange: vi.fn(), + selectedSize: "1024x1024", + onSizeChange: vi.fn(), + generating: true, + savingToResource: false, + onViewportChange: vi.fn(), + onSelectOutput: vi.fn(), + onSaveSelectedToLibrary: vi.fn(), + onApplySelectedOutput: vi.fn(), + onSeedFollowUpCommand: vi.fn(), + onOpenImage: vi.fn(), + }, + mountedRoots, + ); + + expect(container.textContent).toContain("处理中"); + expect(container.textContent).not.toContain("停止"); + }); }); diff --git a/src/components/agent/chat/components/ImageWorkbenchCanvas.tsx b/src/components/agent/chat/components/ImageWorkbenchCanvas.tsx index 5a74cdea7..f7cb9a2df 100644 --- a/src/components/agent/chat/components/ImageWorkbenchCanvas.tsx +++ b/src/components/agent/chat/components/ImageWorkbenchCanvas.tsx @@ -553,7 +553,7 @@ export const ImageWorkbenchCanvas: React.FC = ({ ) : null}
- 结果直接留在任务卡内,聊天区只记录调度。 + 结果留在任务卡内,对话区会同步进度与结果摘要。 {preferenceSummary ? ( 1 + ? `共生成 ${preview.imageCount} 张结果,点击查看图片画布。` + : "结果已返回,点击查看图片画布。"; + case "partial": + return preview.imageCount && preview.imageCount > 0 + ? `已返回 ${preview.imageCount} 张结果,点击查看图片画布。` + : "部分结果已返回,点击查看图片画布。"; + case "failed": + return "这次没有拿到可用图片结果,可在图片画布里继续排查。"; + case "running": + default: + return "正在生成预览图,完成后会在这里直接替换结果。"; + } +} + +function resolveStatusClassName( + status: MessageImageWorkbenchPreview["status"], +): string { + switch (status) { + case "complete": + return "border-emerald-200 bg-emerald-50 text-emerald-700"; + case "partial": + return "border-amber-200 bg-amber-50 text-amber-700"; + case "failed": + return "border-rose-200 bg-rose-50 text-rose-700"; + case "running": + default: + return "border-sky-200 bg-sky-50 text-sky-700"; + } +} + +export const ImageWorkbenchMessagePreview: React.FC< + ImageWorkbenchMessagePreviewProps +> = ({ preview }) => { + const hasImage = Boolean(preview.imageUrl?.trim()); + const showRunningPlaceholder = !hasImage && preview.status === "running"; + const showFailedPlaceholder = !hasImage && preview.status === "failed"; + const showSyncedPlaceholder = + !hasImage && + (preview.status === "complete" || preview.status === "partial"); + + return ( + + ); +}; diff --git a/src/components/agent/chat/components/MessageList.test.tsx b/src/components/agent/chat/components/MessageList.test.tsx index 369fd9ce4..b8c25c9ab 100644 --- a/src/components/agent/chat/components/MessageList.test.tsx +++ b/src/components/agent/chat/components/MessageList.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IMAGE_WORKBENCH_FOCUS_EVENT } from "@/lib/imageWorkbenchEvents"; import { MessageList } from "./MessageList"; import type { Message } from "../types"; @@ -199,6 +200,85 @@ describe("MessageList", () => { ); }); + it("图片任务消息卡应在聊天区渲染预览并支持展开图片画布", () => { + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-assistant-image-workbench", + role: "assistant", + content: "图片生成已完成,共生成 1 张。", + timestamp: now, + imageWorkbenchPreview: { + taskId: "task-1", + prompt: "一颗戴耳机的青柠,科技感插画风格", + status: "complete", + imageUrl: "https://example.com/generated.png", + imageCount: 1, + size: "1024x1024", + projectId: "project-1", + contentId: "content-1", + }, + }, + ]; + + let focusDetail: Record | null = null; + const handleFocus = (event: Event) => { + if (!(event instanceof CustomEvent)) { + return; + } + focusDetail = event.detail as Record; + }; + window.addEventListener(IMAGE_WORKBENCH_FOCUS_EVENT, handleFocus); + + const container = render(messages); + const previewCard = container.querySelector( + '[data-testid="image-workbench-message-preview-task-1"]', + ) as HTMLButtonElement | null; + + expect(previewCard?.textContent).toContain("图片任务卡"); + expect(previewCard?.textContent).toContain("点击查看图片画布"); + expect(previewCard?.textContent).toContain("1024x1024"); + + act(() => { + previewCard?.click(); + }); + + expect(focusDetail).toEqual({ + projectId: "project-1", + contentId: "content-1", + }); + window.removeEventListener(IMAGE_WORKBENCH_FOCUS_EVENT, handleFocus); + }); + + it("图片任务完成但图片仍在工作台时,不应继续显示生成中占位", () => { + const now = new Date(); + const messages: Message[] = [ + { + id: "msg-assistant-image-workbench-complete-without-image", + role: "assistant", + content: "图片任务已完成。", + timestamp: now, + imageWorkbenchPreview: { + taskId: "task-complete-without-image", + prompt: "赛博青柠实验室,电影感光影", + status: "complete", + imageCount: 2, + projectId: "project-1", + contentId: "content-1", + }, + }, + ]; + + const container = render(messages); + const previewCard = container.querySelector( + '[data-testid="image-workbench-message-preview-task-complete-without-image"]', + ); + + expect(previewCard?.textContent).toContain("结果已同步"); + expect(previewCard?.textContent).toContain("点击查看图片画布"); + expect(previewCard?.textContent).not.toContain("正在生成预览"); + }); + it("当前由聊天区底部承载的 assistant A2UI 不应继续在正文里内联渲染", () => { const now = new Date(); const messages: Message[] = [ diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index fdb5cba42..8d1941b49 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -22,6 +22,7 @@ import { MarkdownRenderer } from "./MarkdownRenderer"; import { StreamingRenderer } from "./StreamingRenderer"; import { TokenUsageDisplay } from "./TokenUsageDisplay"; import { AgentThreadTimeline } from "./AgentThreadTimeline"; +import { ImageWorkbenchMessagePreview } from "./ImageWorkbenchMessagePreview"; import { formatArtifactWritePhaseLabel, resolveArtifactPreviewText, @@ -513,6 +514,11 @@ const MessageListInner: React.FC = ({ : undefined } /> + {msg.imageWorkbenchPreview ? ( + + ) : null} ) : displayContent ? ( { ]); }); - it("选中其他子代理时应支持 send_input 与 interrupt send_input", async () => { + it("选中其他子代理时应支持 SendMessage 与 interrupt SendMessage", async () => { const onSendSubagentInput = vi.fn(); const container = await renderBoard({ currentSessionId: "parent-1", diff --git a/src/components/agent/chat/components/ThemeWorkbenchSidebar.test.tsx b/src/components/agent/chat/components/ThemeWorkbenchSidebar.test.tsx index c444b3b9f..421cbeb2d 100644 --- a/src/components/agent/chat/components/ThemeWorkbenchSidebar.test.tsx +++ b/src/components/agent/chat/components/ThemeWorkbenchSidebar.test.tsx @@ -548,7 +548,7 @@ describe("ThemeWorkbenchSidebar", () => { }, { id: "tool-input-1", - name: "request_user_input", + name: "AskUserQuestion", arguments: JSON.stringify({ question: "需要继续吗?" }), status: "running", startTime: new Date("2026-03-12T10:40:02.000Z"), diff --git a/src/components/agent/chat/components/ToolCallDisplay.test.tsx b/src/components/agent/chat/components/ToolCallDisplay.test.tsx index c6860cf7c..b08c3705e 100644 --- a/src/components/agent/chat/components/ToolCallDisplay.test.tsx +++ b/src/components/agent/chat/components/ToolCallDisplay.test.tsx @@ -466,7 +466,7 @@ describe("ToolCallDisplay", () => { }, { id: "tool-subagent-1", - name: "spawn_agent", + name: "Agent", arguments: JSON.stringify({ description: "修复登录页" }), status: "running", startTime: new Date("2026-03-20T12:20:02.000Z"), @@ -500,11 +500,65 @@ describe("ToolCallDisplay", () => { }, { id: "tool-input-1", - name: "request_user_input", + name: "AskUserQuestion", arguments: JSON.stringify({ question: "需要继续吗?" }), status: "running", startTime: new Date("2026-03-20T12:20:09.000Z"), }, + { + id: "tool-send-user-message-1", + name: "SendUserMessage", + arguments: JSON.stringify({ message: "修复已完成" }), + status: "completed", + result: { success: true, output: "Message delivered to user." }, + startTime: new Date("2026-03-20T12:20:09.500Z"), + endTime: new Date("2026-03-20T12:20:09.900Z"), + }, + { + id: "tool-list-peers-1", + name: "ListPeers", + arguments: JSON.stringify({}), + status: "completed", + result: { success: true, output: "[]" }, + startTime: new Date("2026-03-20T12:20:10.000Z"), + endTime: new Date("2026-03-20T12:20:11.000Z"), + }, + { + id: "tool-team-create-1", + name: "TeamCreate", + arguments: JSON.stringify({ team_name: "当前团队" }), + status: "completed", + result: { success: true, output: "{}" }, + startTime: new Date("2026-03-20T12:20:11.000Z"), + endTime: new Date("2026-03-20T12:20:12.000Z"), + }, + { + id: "tool-team-delete-1", + name: "TeamDelete", + arguments: JSON.stringify({ team_name: "当前团队" }), + status: "completed", + result: { success: true, output: "{}" }, + startTime: new Date("2026-03-20T12:20:12.000Z"), + endTime: new Date("2026-03-20T12:20:13.000Z"), + }, + { + id: "tool-remote-trigger-1", + name: "RemoteTrigger", + arguments: JSON.stringify({ action: "run", trigger_id: "remote-1" }), + status: "completed", + result: { success: true, output: "ok" }, + startTime: new Date("2026-03-20T12:20:14.000Z"), + endTime: new Date("2026-03-20T12:20:15.000Z"), + }, + { + id: "tool-cron-delete-1", + name: "CronDelete", + arguments: JSON.stringify({ id: "cron-job-1" }), + status: "completed", + result: { success: true, output: "ok" }, + startTime: new Date("2026-03-20T12:20:16.000Z"), + endTime: new Date("2026-03-20T12:20:17.000Z"), + }, ]} />, ); @@ -518,6 +572,13 @@ describe("ToolCallDisplay", () => { expect(container.textContent).toContain("已加载技能 lime-governance"); expect(container.textContent).toContain("已列出 src/**/*.tsx"); 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("已处理 remote-1"); + expect(container.textContent).toContain("已删除 cron-job-1"); }); it("写文件工具应通过 artifact protocol 解析嵌套产物路径", () => { diff --git a/src/components/agent/chat/hooks/agentChatCoreUtils.ts b/src/components/agent/chat/hooks/agentChatCoreUtils.ts index 684ce9490..004765633 100644 --- a/src/components/agent/chat/hooks/agentChatCoreUtils.ts +++ b/src/components/agent/chat/hooks/agentChatCoreUtils.ts @@ -73,6 +73,9 @@ export const parseJsonObject = ( export const isAskToolName = (toolName: string): boolean => { const normalized = toolName.toLowerCase().trim(); return ( + normalized === "askuserquestion" || + normalized === "request_user_input" || + normalized === "requestuserinput" || normalized === "ask" || normalized === "ask_user" || /(^|[_-])ask($|[_-])/.test(normalized) diff --git a/src/components/agent/chat/hooks/useHomeShellAgentPreferences.test.tsx b/src/components/agent/chat/hooks/useHomeShellAgentPreferences.test.tsx deleted file mode 100644 index c69ae6e11..000000000 --- a/src/components/agent/chat/hooks/useHomeShellAgentPreferences.test.tsx +++ /dev/null @@ -1,458 +0,0 @@ -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const { - mockWechatChannelSetRuntimeModel, - mockListAgentRuntimeSessions, - mockGetAgentRuntimeSession, -} = vi.hoisted(() => ({ - mockWechatChannelSetRuntimeModel: vi.fn(async () => undefined), - mockListAgentRuntimeSessions: vi.fn(), - mockGetAgentRuntimeSession: vi.fn(), -})); - -vi.mock("@/lib/api/channelsRuntime", () => ({ - wechatChannelSetRuntimeModel: mockWechatChannelSetRuntimeModel, -})); - -vi.mock("@/lib/api/agentRuntime", () => ({ - listAgentRuntimeSessions: mockListAgentRuntimeSessions, - getAgentRuntimeSession: mockGetAgentRuntimeSession, -})); - -import { useHomeShellAgentPreferences } from "./useHomeShellAgentPreferences"; - -interface HookHarness { - getValue: () => ReturnType; - rerender: (projectId?: string | null) => void; - unmount: () => void; -} - -function mountHook(initialProjectId?: string | null): HookHarness { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - let hookValue: ReturnType | null = null; - - function TestComponent({ projectId }: { projectId?: string | null }) { - hookValue = useHomeShellAgentPreferences(projectId); - return null; - } - - const render = (projectId?: string | null) => { - act(() => { - root.render(); - }); - }; - - render(initialProjectId); - - return { - getValue: () => { - if (!hookValue) { - throw new Error("hook 尚未初始化"); - } - return hookValue; - }, - rerender: render, - unmount: () => { - act(() => { - root.unmount(); - }); - container.remove(); - }, - }; -} - -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((nextResolve, nextReject) => { - resolve = nextResolve; - reject = nextReject; - }); - return { promise, resolve, reject }; -} - -async function flushAsyncEffects() { - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); -} - -describe("useHomeShellAgentPreferences", () => { - beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; - localStorage.clear(); - mockWechatChannelSetRuntimeModel.mockReset(); - mockListAgentRuntimeSessions.mockReset(); - mockGetAgentRuntimeSession.mockReset(); - mockListAgentRuntimeSessions.mockResolvedValue([]); - mockGetAgentRuntimeSession.mockResolvedValue({ - id: "session-default", - created_at: 0, - updated_at: 0, - messages: [], - execution_runtime: null, - }); - }); - - afterEach(() => { - localStorage.clear(); - }); - - it("应按项目作用域读取 provider、model 与执行策略", () => { - localStorage.setItem( - "agent_pref_provider_project-home-a", - JSON.stringify("openai"), - ); - localStorage.setItem( - "agent_pref_model_project-home-a", - JSON.stringify("gpt-5-mini"), - ); - localStorage.setItem( - "aster_execution_strategy_project-home-a", - JSON.stringify("auto"), - ); - - const harness = mountHook("project-home-a"); - - try { - expect(harness.getValue().providerType).toBe("openai"); - expect(harness.getValue().model).toBe("gpt-5-mini"); - expect(harness.getValue().executionStrategy).toBe("auto"); - } finally { - harness.unmount(); - } - }); - - it("命中最近 session runtime 时应优先回灌首页 provider、model 与执行策略", async () => { - localStorage.setItem( - "agent_pref_provider_project-home-a", - JSON.stringify("openai"), - ); - localStorage.setItem( - "agent_pref_model_project-home-a", - JSON.stringify("gpt-5-mini"), - ); - localStorage.setItem( - "aster_execution_strategy_project-home-a", - JSON.stringify("auto"), - ); - mockListAgentRuntimeSessions.mockResolvedValue([ - { - id: "session-other", - created_at: 1, - updated_at: 999, - workspace_id: "project-other", - }, - { - id: "session-home-latest", - created_at: 2, - updated_at: 200, - workspace_id: "project-home-a", - execution_strategy: "code_orchestrated", - }, - { - id: "session-home-old", - created_at: 1, - updated_at: 100, - workspace_id: "project-home-a", - execution_strategy: "react", - }, - ]); - mockGetAgentRuntimeSession.mockResolvedValue({ - id: "session-home-latest", - created_at: 2, - updated_at: 200, - execution_strategy: "code_orchestrated", - messages: [], - execution_runtime: { - session_id: "session-home-latest", - provider_selector: "claude", - provider_name: "claude", - model_name: "claude-sonnet-4-5", - execution_strategy: "code_orchestrated", - output_schema_runtime: null, - recent_preferences: null, - recent_team_selection: { - disabled: false, - theme: "general", - preferredTeamPresetId: "code-triage-team", - selectedTeamId: "code-triage-team", - selectedTeamSource: "builtin", - selectedTeamLabel: "代码排障团队", - }, - source: "session", - mode: null, - latest_turn_id: null, - latest_turn_status: null, - }, - }); - - const harness = mountHook("project-home-a"); - - try { - await flushAsyncEffects(); - - expect(mockListAgentRuntimeSessions).toHaveBeenCalledTimes(1); - expect(mockGetAgentRuntimeSession).toHaveBeenCalledWith( - "session-home-latest", - ); - expect(harness.getValue().providerType).toBe("claude"); - expect(harness.getValue().model).toBe("claude-sonnet-4-5"); - expect(harness.getValue().executionStrategy).toBe("code_orchestrated"); - expect( - harness.getValue().recentExecutionRuntime?.recent_team_selection - ?.selectedTeamId, - ).toBe("code-triage-team"); - expect( - JSON.parse( - localStorage.getItem("agent_pref_provider_project-home-a") || "null", - ), - ).toBe("claude"); - expect( - JSON.parse( - localStorage.getItem("agent_pref_model_project-home-a") || "null", - ), - ).toBe("claude-sonnet-4-5"); - expect( - JSON.parse( - localStorage.getItem( - "aster_execution_strategy_project-home-a", - ) || "null", - ), - ).toBe("code_orchestrated"); - } finally { - harness.unmount(); - } - }); - - it("没有最近 session runtime 时应继续沿用项目本地 fallback", async () => { - localStorage.setItem( - "agent_pref_provider_project-home-fallback", - JSON.stringify("deepseek"), - ); - localStorage.setItem( - "agent_pref_model_project-home-fallback", - JSON.stringify("deepseek-chat"), - ); - localStorage.setItem( - "aster_execution_strategy_project-home-fallback", - JSON.stringify("auto"), - ); - mockListAgentRuntimeSessions.mockResolvedValue([ - { - id: "session-other", - created_at: 1, - updated_at: 100, - workspace_id: "project-other", - }, - ]); - - const harness = mountHook("project-home-fallback"); - - try { - await flushAsyncEffects(); - - expect(harness.getValue().providerType).toBe("deepseek"); - expect(harness.getValue().model).toBe("deepseek-chat"); - expect(harness.getValue().executionStrategy).toBe("auto"); - expect(harness.getValue().recentExecutionRuntime).toBeNull(); - expect(mockGetAgentRuntimeSession).not.toHaveBeenCalled(); - } finally { - harness.unmount(); - } - }); - - it("用户已手动改首页偏好时,不应再被异步 runtime 回灌覆盖", async () => { - const sessionDetailDeferred = createDeferred<{ - id: string; - created_at: number; - updated_at: number; - execution_strategy: "code_orchestrated"; - messages: []; - execution_runtime: { - session_id: string; - provider_selector: string; - provider_name: string; - model_name: string; - execution_strategy: "code_orchestrated"; - output_schema_runtime: null; - recent_preferences: null; - recent_team_selection: { - disabled: false; - theme: "general"; - preferredTeamPresetId: "research-team"; - selectedTeamId: "research-team"; - selectedTeamSource: "builtin"; - selectedTeamLabel: "研究协作团队"; - }; - source: "session"; - mode: null; - latest_turn_id: null; - latest_turn_status: null; - }; - }>(); - mockListAgentRuntimeSessions.mockResolvedValue([ - { - id: "session-home-latest", - created_at: 2, - updated_at: 200, - workspace_id: "project-home-race", - execution_strategy: "code_orchestrated", - }, - ]); - mockGetAgentRuntimeSession.mockReturnValue(sessionDetailDeferred.promise); - - const harness = mountHook("project-home-race"); - - try { - await flushAsyncEffects(); - - act(() => { - harness.getValue().setProviderType("gemini"); - harness.getValue().setModel("gemini-2.5-pro"); - harness.getValue().setExecutionStrategy("auto"); - }); - - sessionDetailDeferred.resolve({ - id: "session-home-latest", - created_at: 2, - updated_at: 200, - execution_strategy: "code_orchestrated", - messages: [], - execution_runtime: { - session_id: "session-home-latest", - provider_selector: "claude", - provider_name: "claude", - model_name: "claude-sonnet-4-5", - execution_strategy: "code_orchestrated", - output_schema_runtime: null, - recent_preferences: null, - recent_team_selection: { - disabled: false, - theme: "general", - preferredTeamPresetId: "research-team", - selectedTeamId: "research-team", - selectedTeamSource: "builtin", - selectedTeamLabel: "研究协作团队", - }, - source: "session", - mode: null, - latest_turn_id: null, - latest_turn_status: null, - }, - }); - - await flushAsyncEffects(); - - expect(harness.getValue().providerType).toBe("gemini"); - expect(harness.getValue().model).toBe("gemini-2.5-pro"); - expect(harness.getValue().executionStrategy).toBe("auto"); - expect( - harness.getValue().recentExecutionRuntime?.recent_team_selection - ?.selectedTeamId, - ).toBe("research-team"); - expect( - JSON.parse( - localStorage.getItem("agent_pref_provider_project-home-race") || - "null", - ), - ).toBe("gemini"); - expect( - JSON.parse( - localStorage.getItem("agent_pref_model_project-home-race") || "null", - ), - ).toBe("gemini-2.5-pro"); - } finally { - harness.unmount(); - } - }); - - it("切换项目时应重载对应偏好,并把变更写回当前作用域", () => { - localStorage.setItem( - "agent_pref_provider_project-home-a", - JSON.stringify("openai"), - ); - localStorage.setItem( - "agent_pref_model_project-home-a", - JSON.stringify("gpt-5-mini"), - ); - localStorage.setItem( - "aster_execution_strategy_project-home-a", - JSON.stringify("auto"), - ); - localStorage.setItem( - "agent_pref_provider_project-home-b", - JSON.stringify("claude"), - ); - localStorage.setItem( - "agent_pref_model_project-home-b", - JSON.stringify("claude-sonnet-4-5"), - ); - - const harness = mountHook("project-home-a"); - - try { - harness.rerender("project-home-b"); - - expect(harness.getValue().providerType).toBe("claude"); - expect(harness.getValue().model).toBe("claude-sonnet-4-5"); - expect(harness.getValue().executionStrategy).toBe("react"); - - act(() => { - harness.getValue().setProviderType("gemini"); - harness.getValue().setModel("gemini-2.5-pro"); - harness.getValue().setExecutionStrategy("code_orchestrated"); - }); - - expect( - JSON.parse( - localStorage.getItem("agent_pref_provider_project-home-b") || "null", - ), - ).toBe("gemini"); - expect( - JSON.parse( - localStorage.getItem("agent_pref_model_project-home-b") || "null", - ), - ).toBe("gemini-2.5-pro"); - expect( - JSON.parse( - localStorage.getItem( - "aster_execution_strategy_project-home-b", - ) || "null", - ), - ).toBe("code_orchestrated"); - } finally { - harness.unmount(); - } - }); - - it("更新首页 Claw 模型时应同步微信运行时模型", async () => { - const harness = mountHook("project-home-sync"); - - try { - await act(async () => { - harness.getValue().setProviderType("deepseek"); - harness.getValue().setModel("deepseek-reasoner"); - await Promise.resolve(); - }); - - expect(mockWechatChannelSetRuntimeModel).toHaveBeenCalledWith({ - providerId: "deepseek", - modelId: "deepseek-reasoner", - }); - } finally { - harness.unmount(); - } - }); -}); diff --git a/src/components/agent/chat/hooks/useHomeShellAgentPreferences.ts b/src/components/agent/chat/hooks/useHomeShellAgentPreferences.ts deleted file mode 100644 index c98c18e21..000000000 --- a/src/components/agent/chat/hooks/useHomeShellAgentPreferences.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { - getAgentRuntimeSession, - listAgentRuntimeSessions, - type AsterExecutionStrategy, - type AsterSessionExecutionRuntime, -} from "@/lib/api/agentRuntime"; -import { normalizeProjectId } from "../utils/topicProjectResolution"; -import { - createExecutionRuntimeFromSessionDetail, - createSessionModelPreferenceFromExecutionRuntime, -} from "../utils/sessionExecutionRuntime"; -import { normalizeExecutionStrategy } from "./agentChatCoreUtils"; -import { - getAgentPreferenceKeys, - getExecutionStrategyStorageKey, - resolvePersistedExecutionStrategy, - resolveWorkspaceAgentPreferences, - savePersisted, -} from "./agentChatStorage"; -import { useWechatRuntimeModelSync } from "./useWechatRuntimeModelSync"; - -export function useHomeShellAgentPreferences(projectId?: string | null) { - const normalizedProjectId = normalizeProjectId(projectId); - const initialPreferences = - resolveWorkspaceAgentPreferences(normalizedProjectId); - const manualMutationVersionRef = useRef(0); - const [providerType, setProviderTypeState] = useState( - initialPreferences.providerType, - ); - const [model, setModelState] = useState(initialPreferences.model); - const [executionStrategy, setExecutionStrategyState] = - useState(() => - resolvePersistedExecutionStrategy(normalizedProjectId), - ); - const [recentExecutionRuntime, setRecentExecutionRuntime] = - useState(null); - - useWechatRuntimeModelSync({ - providerId: providerType, - modelId: model, - source: "home-shell", - }); - - useEffect(() => { - const scopedPreferences = - resolveWorkspaceAgentPreferences(normalizedProjectId); - const persistedExecutionStrategy = - resolvePersistedExecutionStrategy(normalizedProjectId); - setProviderTypeState(scopedPreferences.providerType); - setModelState(scopedPreferences.model); - setExecutionStrategyState(persistedExecutionStrategy); - setRecentExecutionRuntime(null); - manualMutationVersionRef.current = 0; - - if (!normalizedProjectId) { - return; - } - - let cancelled = false; - const hydrationVersion = manualMutationVersionRef.current; - - void (async () => { - try { - const sessions = await listAgentRuntimeSessions(); - const latestSession = sessions - .filter((session) => session.workspace_id === normalizedProjectId) - .sort((left, right) => right.updated_at - left.updated_at)[0]; - if (!latestSession) { - return; - } - - const detail = await getAgentRuntimeSession(latestSession.id); - if (cancelled) { - return; - } - - setRecentExecutionRuntime(createExecutionRuntimeFromSessionDetail(detail)); - - if (manualMutationVersionRef.current !== hydrationVersion) { - return; - } - - const runtimePreference = - createSessionModelPreferenceFromExecutionRuntime( - detail.execution_runtime, - ); - const nextExecutionStrategy = normalizeExecutionStrategy( - detail.execution_strategy || - latestSession.execution_strategy || - persistedExecutionStrategy, - ); - - if (runtimePreference) { - setProviderTypeState(runtimePreference.providerType); - setModelState(runtimePreference.model); - const { providerKey, modelKey } = - getAgentPreferenceKeys(normalizedProjectId); - savePersisted(providerKey, runtimePreference.providerType); - savePersisted(modelKey, runtimePreference.model); - } - - setExecutionStrategyState(nextExecutionStrategy); - const executionStrategyStorageKey = - getExecutionStrategyStorageKey(normalizedProjectId); - if (executionStrategyStorageKey) { - savePersisted(executionStrategyStorageKey, nextExecutionStrategy); - } - } catch (error) { - if (cancelled) { - return; - } - console.warn( - "[AgentChatHomeShell] 读取最近 session runtime 失败,继续沿用本地项目偏好:", - error, - ); - } - })(); - - return () => { - cancelled = true; - }; - }, [normalizedProjectId]); - - const setProviderType = useCallback( - (nextProviderType: string) => { - manualMutationVersionRef.current += 1; - setProviderTypeState(nextProviderType); - const { providerKey } = getAgentPreferenceKeys(normalizedProjectId); - savePersisted(providerKey, nextProviderType); - }, - [normalizedProjectId], - ); - - const setModel = useCallback( - (nextModel: string) => { - manualMutationVersionRef.current += 1; - setModelState(nextModel); - const { modelKey } = getAgentPreferenceKeys(normalizedProjectId); - savePersisted(modelKey, nextModel); - }, - [normalizedProjectId], - ); - - const setExecutionStrategy = useCallback( - (nextExecutionStrategy: AsterExecutionStrategy) => { - manualMutationVersionRef.current += 1; - const normalized = normalizeExecutionStrategy(nextExecutionStrategy); - setExecutionStrategyState(normalized); - const storageKey = getExecutionStrategyStorageKey(normalizedProjectId); - if (!storageKey) { - return; - } - savePersisted(storageKey, normalized); - }, - [normalizedProjectId], - ); - - return { - providerType, - setProviderType, - model, - setModel, - executionStrategy, - setExecutionStrategy, - recentExecutionRuntime, - }; -} diff --git a/src/components/agent/chat/hooks/useHomeShellProjectMemory.test.tsx b/src/components/agent/chat/hooks/useHomeShellProjectMemory.test.tsx deleted file mode 100644 index 1b85a6d5d..000000000 --- a/src/components/agent/chat/hooks/useHomeShellProjectMemory.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useHomeShellProjectMemory } from "./useHomeShellProjectMemory"; - -const mockGetProjectMemory = vi.hoisted(() => vi.fn()); -const mockLogAgentDebug = vi.hoisted(() => vi.fn()); - -vi.mock("@/lib/api/memory", () => ({ - getProjectMemory: mockGetProjectMemory, -})); - -vi.mock("@/lib/agentDebug", () => ({ - logAgentDebug: mockLogAgentDebug, -})); - -interface HookHarness { - getValue: () => ReturnType; - rerender: (projectId?: string | null) => void; - unmount: () => void; -} - -function mountHook(initialProjectId?: string | null): HookHarness { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - let hookValue: ReturnType | null = null; - - function TestComponent({ projectId }: { projectId?: string | null }) { - hookValue = useHomeShellProjectMemory(projectId); - return null; - } - - const render = (projectId?: string | null) => { - act(() => { - root.render(); - }); - }; - - render(initialProjectId); - - return { - getValue: () => hookValue, - rerender: render, - unmount: () => { - act(() => { - root.unmount(); - }); - container.remove(); - }, - }; -} - -async function flushEffects(times = 4) { - for (let index = 0; index < times; index += 1) { - await act(async () => { - await Promise.resolve(); - }); - } -} - -describe("useHomeShellProjectMemory", () => { - beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; - vi.clearAllMocks(); - mockGetProjectMemory.mockResolvedValue({ - characters: [{ id: "char-1", name: "主角" }], - outline: [], - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("无项目时应直接返回空记忆且不触发加载", async () => { - const harness = mountHook(); - - try { - await flushEffects(); - expect(harness.getValue()).toBeNull(); - expect(mockGetProjectMemory).not.toHaveBeenCalled(); - } finally { - harness.unmount(); - } - }); - - it("切换项目时应加载对应项目记忆", async () => { - const harness = mountHook("project-memory-a"); - - try { - await flushEffects(); - expect(mockGetProjectMemory).toHaveBeenCalledWith("project-memory-a"); - expect(harness.getValue()?.characters[0]?.name).toBe("主角"); - - mockGetProjectMemory.mockResolvedValueOnce({ - characters: [{ id: "char-2", name: "配角" }], - outline: [], - }); - - harness.rerender("project-memory-b"); - await flushEffects(); - - expect(mockGetProjectMemory).toHaveBeenLastCalledWith("project-memory-b"); - expect(harness.getValue()?.characters[0]?.name).toBe("配角"); - } finally { - harness.unmount(); - } - }); -}); diff --git a/src/components/agent/chat/hooks/useHomeShellProjectMemory.ts b/src/components/agent/chat/hooks/useHomeShellProjectMemory.ts deleted file mode 100644 index 59aca384b..000000000 --- a/src/components/agent/chat/hooks/useHomeShellProjectMemory.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useEffect, useState } from "react"; -import { getProjectMemory, type ProjectMemory } from "@/lib/api/memory"; -import { logAgentDebug } from "@/lib/agentDebug"; -import { normalizeProjectId } from "../utils/topicProjectResolution"; - -export function useHomeShellProjectMemory(projectId?: string | null) { - const normalizedProjectId = normalizeProjectId(projectId); - const [projectMemory, setProjectMemory] = useState( - null, - ); - - useEffect(() => { - if (!normalizedProjectId) { - setProjectMemory(null); - return; - } - - let cancelled = false; - const startedAt = Date.now(); - logAgentDebug("useHomeShellProjectMemory", "load.start", { - projectId: normalizedProjectId, - }); - - void getProjectMemory(normalizedProjectId) - .then((memory) => { - if (cancelled) { - return; - } - setProjectMemory(memory); - logAgentDebug("useHomeShellProjectMemory", "load.success", { - durationMs: Date.now() - startedAt, - projectId: normalizedProjectId, - charactersCount: memory.characters.length, - }); - }) - .catch((error) => { - if (cancelled) { - return; - } - setProjectMemory(null); - logAgentDebug( - "useHomeShellProjectMemory", - "load.error", - { - durationMs: Date.now() - startedAt, - error, - projectId: normalizedProjectId, - }, - { level: "warn" }, - ); - }); - - return () => { - cancelled = true; - }; - }, [normalizedProjectId]); - - return projectMemory; -} diff --git a/src/components/agent/chat/hooks/useHomeShellSkills.test.tsx b/src/components/agent/chat/hooks/useHomeShellSkills.test.tsx deleted file mode 100644 index 63ca6f081..000000000 --- a/src/components/agent/chat/hooks/useHomeShellSkills.test.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useHomeShellSkills } from "./useHomeShellSkills"; - -const mockGetLocal = vi.hoisted(() => vi.fn()); -const mockGetAll = vi.hoisted(() => vi.fn()); -const mockLogAgentDebug = vi.hoisted(() => vi.fn()); - -vi.mock("@/lib/api/skills", () => ({ - skillsApi: { - getLocal: mockGetLocal, - getAll: mockGetAll, - }, -})); - -vi.mock("@/lib/agentDebug", () => ({ - logAgentDebug: mockLogAgentDebug, -})); - -interface HookHarness { - getValue: () => ReturnType; - unmount: () => void; -} - -function mountHook(): HookHarness { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - - let hookValue: ReturnType | null = null; - - function TestComponent() { - hookValue = useHomeShellSkills(); - return null; - } - - act(() => { - root.render(); - }); - - return { - getValue: () => { - if (!hookValue) { - throw new Error("hook 尚未初始化"); - } - return hookValue; - }, - unmount: () => { - act(() => { - root.unmount(); - }); - container.remove(); - }, - }; -} - -async function flushEffects() { - await act(async () => { - await Promise.resolve(); - }); -} - -describe("useHomeShellSkills", () => { - beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; - vi.clearAllMocks(); - vi.useFakeTimers(); - mockGetLocal.mockResolvedValue([ - { - key: "local-skill", - name: "本地技能", - description: "默认加载", - directory: "local-skill", - installed: true, - sourceKind: "builtin", - }, - ]); - mockGetAll.mockResolvedValue([ - { - key: "remote-skill", - name: "远端技能", - description: "刷新加载", - directory: "remote-skill", - installed: true, - sourceKind: "builtin", - }, - ]); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - }); - - it("应在 idle/fallback 阶段加载本地 skills", async () => { - const harness = mountHook(); - - try { - act(() => { - vi.advanceTimersByTime(200); - }); - await flushEffects(); - - expect(mockGetLocal).toHaveBeenCalledWith("lime"); - expect(harness.getValue().skills).toHaveLength(1); - expect(harness.getValue().skills[0]?.key).toBe("local-skill"); - expect(harness.getValue().skillsLoading).toBe(false); - } finally { - harness.unmount(); - } - }); - - it("手动刷新时应走远端聚合入口", async () => { - const harness = mountHook(); - - try { - act(() => { - vi.advanceTimersByTime(200); - }); - await flushEffects(); - - await act(async () => { - await harness.getValue().refreshSkills(true); - }); - - expect(mockGetAll).toHaveBeenCalledWith("lime"); - expect(harness.getValue().skills[0]?.key).toBe("remote-skill"); - } finally { - harness.unmount(); - } - }); -}); diff --git a/src/components/agent/chat/hooks/useHomeShellSkills.ts b/src/components/agent/chat/hooks/useHomeShellSkills.ts deleted file mode 100644 index 49ca60692..000000000 --- a/src/components/agent/chat/hooks/useHomeShellSkills.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useLimeSkills } from "./useLimeSkills"; - -export function useHomeShellSkills() { - return useLimeSkills({ - autoLoad: "deferred", - logScope: "useHomeShellSkills", - }); -} diff --git a/src/components/agent/chat/index.shell-routing.test.tsx b/src/components/agent/chat/index.shell-routing.test.tsx index c7a371875..f836ed905 100644 --- a/src/components/agent/chat/index.shell-routing.test.tsx +++ b/src/components/agent/chat/index.shell-routing.test.tsx @@ -15,31 +15,6 @@ const latestWorkspaceProps = vi.hoisted( }) as { value: Record | null }, ); -vi.mock("./AgentChatHomeShell", () => ({ - AgentChatHomeShell: ({ - onEnterWorkspace, - }: { - onEnterWorkspace: (payload: Record) => void; - }) => ( -
- -
- ), -})); - vi.mock("./AgentChatWorkspace", () => ({ AgentChatWorkspace: (props: Record) => { latestWorkspaceProps.value = props; @@ -112,8 +87,8 @@ async function flushEffects(times = 8) { }); } -describe("AgentChatPage 首页壳路由", () => { - it("标准 new-task 空白入口应渲染首页壳,而不是旧工作区空页", async () => { +describe("AgentChatPage 工作区路由", () => { + it("标准 new-task 空白入口应保留当前首页语义", async () => { const container = renderPage({ agentEntry: "new-task", projectId: "project-standard", @@ -122,13 +97,16 @@ describe("AgentChatPage 首页壳路由", () => { await flushEffects(); - expect( - container.querySelector('[data-testid="home-shell"]'), - ).not.toBeNull(); - expect(container.querySelector('[data-testid="workspace"]')).toBeNull(); + const workspace = container.querySelector( + '[data-testid="workspace"]', + ) as HTMLDivElement | null; + + expect(workspace).not.toBeNull(); + expect(workspace?.dataset.agentEntry).toBe("new-task"); + expect(workspace?.dataset.showChatPanel).toBe("false"); }); - it("immersiveHome 模式同样应渲染首页壳", async () => { + it("immersiveHome 模式同样应保留当前首页语义", async () => { const container = renderPage({ agentEntry: "new-task", immersiveHome: true, @@ -137,70 +115,16 @@ describe("AgentChatPage 首页壳路由", () => { await flushEffects(); - expect( - container.querySelector('[data-testid="home-shell"]'), - ).not.toBeNull(); - expect(container.querySelector('[data-testid="workspace"]')).toBeNull(); - }); - - it("从首页壳进入工作区后应按 claw 语义渲染", async () => { - renderPage({ - agentEntry: "new-task", - initialUserPrompt: "预热工作区模块", - showChatPanel: false, - }); - await flushEffects(); - latestWorkspaceProps.value = null; - - const container = renderPage({ - agentEntry: "new-task", - immersiveHome: true, - showChatPanel: false, - }); - - await flushEffects(); - - const enterButton = container.querySelector( - '[data-testid="enter-workspace"]', - ) as HTMLButtonElement | null; - - expect(enterButton).toBeTruthy(); - - act(() => { - enterButton?.click(); - }); - - await flushEffects(); - await act( - async () => - await new Promise((resolve) => { - window.setTimeout(resolve, 220); - }), - ); - await flushEffects(); - const workspace = container.querySelector( '[data-testid="workspace"]', ) as HTMLDivElement | null; - expect(container.querySelector('[data-testid="home-shell"]')).toBeNull(); - expect( - container.querySelector('[data-testid="claw-empty-state"]'), - ).toBeNull(); expect(workspace).not.toBeNull(); - expect(workspace?.dataset.agentEntry).toBe("claw"); - expect(workspace?.dataset.showChatPanel).toBe("true"); - expect(latestWorkspaceProps.value).toMatchObject({ - projectId: "project-shell", - theme: "general", - autoRunInitialPromptOnMount: true, - newChatAt: 123, - agentEntry: "claw", - showChatPanel: true, - }); + expect(workspace?.dataset.agentEntry).toBe("new-task"); + expect(workspace?.dataset.showChatPanel).toBe("false"); }); - it("new-task 携带首条上下文时应直接按 claw 语义渲染", async () => { + it("new-task 携带首条上下文时应继续按当前工作区语义渲染", async () => { const container = renderPage({ agentEntry: "new-task", projectId: "project-standard", @@ -217,5 +141,10 @@ describe("AgentChatPage 首页壳路由", () => { expect(workspace).not.toBeNull(); expect(workspace?.dataset.agentEntry).toBe("claw"); expect(workspace?.dataset.showChatPanel).toBe("true"); + expect(latestWorkspaceProps.value).toMatchObject({ + initialUserPrompt: "请直接开始处理这个任务", + agentEntry: "claw", + showChatPanel: true, + }); }); }); diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 57e8e2629..204709853 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -1565,6 +1565,31 @@ describe("AgentChatPage 侧栏显示控制", () => { expect(container.querySelector('[data-testid="chat-sidebar"]')).toBeNull(); }); + it("new-task 执行态即使初始 showChatPanel=false 也应允许从顶栏展开对话侧栏", async () => { + installMockAgentChatUnifiedState( + createMockAgentChatUnifiedState({ + messages: [{ id: "msg-new-task", role: "user", content: "继续执行" }], + isSending: true, + }), + ); + + const container = renderPage({ + agentEntry: "new-task", + showChatPanel: false, + theme: "general", + }); + await flushEffects(); + + expect(container.querySelector('[data-testid="chat-sidebar"]')).toBeNull(); + + clickButton(container, "toggle-history"); + await flushEffects(); + + expect( + container.querySelector('[data-testid="chat-sidebar"]'), + ).not.toBeNull(); + }); + it("Claw 模式无激活任务时应展示任务选择空态", async () => { mockUseAgentChatUnified.mockImplementation( ({ workspaceId }: { workspaceId: string }) => { @@ -1619,6 +1644,48 @@ describe("AgentChatPage 侧栏显示控制", () => { }); describe("AgentChatPage 通用工作台", () => { + it("空白新建任务首页不应自动打开 fallback 新文档画布", async () => { + const container = renderPage({ + agentEntry: "new-task", + showChatPanel: false, + theme: "general", + }); + await flushEffects(10); + + expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="canvas-workbench-layout-mock"]'), + ).toBeNull(); + }); + + it("新建任务首页即使记住了文档项目也应保留真实首页", async () => { + mockIsSpecializedWorkbenchTheme.mockImplementation( + (theme?: string) => theme !== "general", + ); + mockUseThemeContextWorkspace.mockImplementation( + ({ activeTheme }: { activeTheme?: string }) => + createMockThemeContextWorkspaceState({ + enabled: activeTheme !== "general", + }), + ); + mockGetProject.mockResolvedValue({ + ...createProject("project-document-home"), + workspaceType: "document", + }); + + const container = renderPage({ + agentEntry: "new-task", + projectId: "project-document-home", + theme: "general", + }); + await flushEffects(10); + + expect(container.querySelector('[data-testid="empty-state"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="canvas-workbench-layout-mock"]'), + ).toBeNull(); + }); + it("聊天态应通过顶栏按钮展开画布,并支持在展开后再次折叠", async () => { const container = renderPage({ theme: "general", diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index 3d3b20f25..ad1d67ebf 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -1,8 +1,4 @@ -import { Suspense, lazy, useCallback, useEffect, useState } from "react"; -import { - AgentChatHomeShell, - type AgentChatWorkspaceBootstrap, -} from "./AgentChatHomeShell"; +import { Suspense, lazy, useEffect } from "react"; import type { AgentChatWorkspaceProps } from "./agentChatWorkspaceContract"; const WORKSPACE_PREFETCH_IDLE_TIMEOUT_MS = 1_500; @@ -47,85 +43,36 @@ export type { export function AgentChatPage(props: AgentChatWorkspaceProps) { const { - onNavigate, - projectId, - contentId, agentEntry = "claw", - immersiveHome: _immersiveHome = false, - theme, - initialCreationMode, - lockTheme = false, - fromResources = false, - initialUserPrompt, - initialUserImages, - initialSessionName, - entryBannerMessage, - newChatAt, - autoRunInitialPromptOnMount = false, - initialAutoSendRequestMetadata, initialSiteSkillLaunch, + initialUserImages, + initialUserPrompt, + openBrowserAssistOnMount = false, onHasMessagesChange, onSessionChange, onWorkflowProgressChange, - openBrowserAssistOnMount = false, } = props; - const shouldRenderHomeShell = - agentEntry === "new-task" && - !contentId && - !initialUserPrompt && - !(initialUserImages && initialUserImages.length > 0) && - !initialSessionName && - !entryBannerMessage && - !initialSiteSkillLaunch && - !openBrowserAssistOnMount && - !fromResources; + const hasDirectWorkspaceIntent = + Boolean(initialUserPrompt?.trim()) || + Boolean(initialUserImages?.length) || + Boolean(initialSiteSkillLaunch) || + openBrowserAssistOnMount; const shouldForceClawWorkspace = - agentEntry === "new-task" && - (Boolean(initialUserPrompt?.trim()) || - Boolean(initialUserImages?.length) || - Boolean(initialSessionName?.trim()) || - Boolean(entryBannerMessage?.trim()) || - Boolean(initialSiteSkillLaunch) || - openBrowserAssistOnMount); - - const [workspaceBootstrap, setWorkspaceBootstrap] = - useState(null); - const activeBootstrap = shouldRenderHomeShell ? workspaceBootstrap : null; - const effectiveAgentEntry = - activeBootstrap || shouldForceClawWorkspace ? "claw" : agentEntry; - const effectiveShowChatPanel = - activeBootstrap || shouldForceClawWorkspace ? true : props.showChatPanel; - - const handleEnterWorkspace = useCallback( - (payload: AgentChatWorkspaceBootstrap) => { - void loadAgentChatWorkspace(); - setWorkspaceBootstrap(payload); - }, - [], - ); + agentEntry === "new-task" && hasDirectWorkspaceIntent; + const effectiveAgentEntry = shouldForceClawWorkspace ? "claw" : agentEntry; + const effectiveShowChatPanel = shouldForceClawWorkspace + ? true + : props.showChatPanel; useEffect(() => { - if (!shouldRenderHomeShell) { - setWorkspaceBootstrap(null); - return; - } - return scheduleWorkspacePrefetch(() => { void loadAgentChatWorkspace(); }); - }, [shouldRenderHomeShell]); + }, []); useEffect(() => { - if (!shouldRenderHomeShell || typeof newChatAt !== "number") { - return; - } - - setWorkspaceBootstrap(null); - }, [newChatAt, shouldRenderHomeShell]); - - useEffect(() => { - if (!shouldRenderHomeShell || activeBootstrap) { + if (!shouldForceClawWorkspace) { return; } @@ -133,66 +80,18 @@ export function AgentChatPage(props: AgentChatWorkspaceProps) { onSessionChange?.(null); onWorkflowProgressChange?.(null); }, [ - activeBootstrap, onHasMessagesChange, onSessionChange, onWorkflowProgressChange, - shouldRenderHomeShell, + shouldForceClawWorkspace, ]); - const homeShellNode = ( - - ); - - if (shouldRenderHomeShell && !activeBootstrap) { - return homeShellNode; - } - return ( - + ); diff --git a/src/components/agent/chat/types.ts b/src/components/agent/chat/types.ts index 3567427f6..a4e94b80f 100644 --- a/src/components/agent/chat/types.ts +++ b/src/components/agent/chat/types.ts @@ -17,6 +17,17 @@ export interface MessageImage { mediaType: string; } +export interface MessageImageWorkbenchPreview { + taskId: string; + prompt: string; + status: "running" | "complete" | "partial" | "failed"; + projectId?: string | null; + contentId?: string | null; + imageUrl?: string | null; + imageCount?: number; + size?: string; +} + /** * 内容片段类型(用于交错显示) * @@ -241,6 +252,8 @@ export interface Message { contextTrace?: ContextTraceStep[]; /** 与当前消息关联的产物列表 */ artifacts?: Artifact[]; + /** 图片工作台消息卡预览 */ + imageWorkbenchPreview?: MessageImageWorkbenchPreview; /** 首个流式事件到达前的本地运行态 */ runtimeStatus?: AgentRuntimeStatus; /** 消息用途(用于跳过特定副作用) */ diff --git a/src/components/agent/chat/utils/agentThreadGrouping.ts b/src/components/agent/chat/utils/agentThreadGrouping.ts index 824100656..cc35a181d 100644 --- a/src/components/agent/chat/utils/agentThreadGrouping.ts +++ b/src/components/agent/chat/utils/agentThreadGrouping.ts @@ -535,6 +535,70 @@ function summarizeCommandItem(item: AgentThreadItem): string | null { return null; } +function summarizeCollaborationItem(item: AgentThreadItem): string | null { + if (item.type !== "tool_call") { + return null; + } + + const normalized = normalizeToolName(item.tool_name); + const args = asRecord(item.arguments); + + if (normalized === "agent") { + return prefixAction( + readString(args, ["description", "task", "taskType", "role", "agent_type"]) || + resolveToolDisplayLabel(item.tool_name), + "分给协作成员处理 ", + ["分给协作成员", "协作中 ", "邀请 ", "已邀请 "], + ); + } + + if (normalized === "sendmessage") { + return prefixAction( + readString(args, ["id", "agent_id", "message"]) || "目标协作成员", + "补充说明 ", + ["补充说明 ", "已补充说明 ", "发送给 "], + ); + } + + if (normalized === "teamcreate") { + return prefixAction( + readString(args, ["team_name", "teamName"]) || "当前团队", + "已创建 ", + ["已创建 ", "创建了 "], + ); + } + + if (normalized === "teamdelete") { + return prefixAction( + readString(args, ["team_name", "teamName"]) || "当前团队", + "已删除 ", + ["已删除 ", "删除了 "], + ); + } + + if (normalized === "listpeers") { + return prefixAction( + readString(args, ["team_name", "teamName"]) || "当前团队", + "已列出 ", + ["已列出 ", "列出了 ", "查看了 "], + ); + } + + if ( + normalized === "waitagent" || + normalized === "resumeagent" || + normalized === "closeagent" + ) { + return prefixAction( + readString(args, ["id", "ids", "session_id"]) || resolveToolDisplayLabel(item.tool_name), + "处理了 ", + ["处理了 ", "继续了 ", "暂停了 ", "查看了 "], + ); + } + + return null; +} + function summarizeSubagentItem(item: AgentThreadItem): string | null { if (item.type !== "subagent_activity") { return null; @@ -569,6 +633,17 @@ function summarizeAlertItem(item: AgentThreadItem): string | null { function summarizeOtherItem(item: AgentThreadItem): string | null { if (item.type === "tool_call") { + const normalized = normalizeToolName(item.tool_name); + const args = asRecord(item.arguments); + + if (normalized === "sendusermessage" || normalized === "brief") { + return prefixAction( + readString(args, ["message"]) || resolveToolDisplayLabel(item.tool_name), + "已发送 ", + ["已发送 ", "发送了 "], + ); + } + return prefixAction( resolveToolDisplayLabel(item.tool_name), "执行了 ", @@ -627,6 +702,12 @@ function summarizeGroupPreviewLine( ) { return summarizeThinkingItem(item); } + { + const collaborationSummary = summarizeCollaborationItem(item); + if (collaborationSummary) { + return collaborationSummary; + } + } if (isBrowserItem(item)) { return summarizeBrowserItem(item); } diff --git a/src/components/agent/chat/utils/contextualRecommendations.ts b/src/components/agent/chat/utils/contextualRecommendations.ts index 106466638..9033386bc 100644 --- a/src/components/agent/chat/utils/contextualRecommendations.ts +++ b/src/components/agent/chat/utils/contextualRecommendations.ts @@ -198,7 +198,7 @@ function buildGeneralRecommendations( return [ [ "Team 冒烟测试", - `请按 team runtime 方式处理“${subject}”:先在主线程拆成两个子任务,再创建 explorer 与 executor 两个子代理并行推进;至少等待一个子代理结束,必要时继续 send_input,最后回到主线程汇总每个 agent 的状态、结论和下一步。${teamSetupHint}`, + `请按 team runtime 方式处理“${subject}”:先在主线程拆成两个子任务,再创建 explorer 与 executor 两个子代理并行推进;至少等待一个子代理结束,必要时继续使用 SendMessage 追加说明,最后回到主线程汇总每个 agent 的状态、结论和下一步。${teamSetupHint}`, ], [ "父子线程联调", diff --git a/src/components/agent/chat/utils/homeRecommendedSolutions.ts b/src/components/agent/chat/utils/homeRecommendedSolutions.ts new file mode 100644 index 000000000..c68c89b11 --- /dev/null +++ b/src/components/agent/chat/utils/homeRecommendedSolutions.ts @@ -0,0 +1,238 @@ +export interface HomeRecommendedSolutionItem { + id: string; + title: string; + summary: string; + outputHint: string; + prompt: string; + badge: string; + actionLabel: string; + statusLabel: string; + statusTone: "emerald"; + recentUsedAt: number | null; + isRecent: boolean; + themeTarget?: string; + shouldEnableWebSearch?: boolean; + shouldEnableTeamMode?: boolean; + shouldLaunchBrowserAssist?: boolean; +} + +interface HomeRecommendedSolutionUsageRecord { + solutionId: string; + usedAt: number; +} + +interface HomeRecommendedSolutionDefinition { + id: string; + title: string; + summary: string; + outputHint: string; + prompt: string; + themeTarget?: string; + shouldEnableWebSearch?: boolean; + shouldEnableTeamMode?: boolean; + shouldLaunchBrowserAssist?: boolean; +} + +const HOME_RECOMMENDED_SOLUTION_USAGE_STORAGE_KEY = + "lime:home-recommended-solution-usage:v1"; +const MAX_HOME_RECOMMENDED_SOLUTION_USAGE_RECORDS = 12; + +const HOME_RECOMMENDED_SOLUTIONS: HomeRecommendedSolutionDefinition[] = [ + { + id: "web-research-brief", + title: "网页研究简报", + summary: + "快速整理调研范围、关键信息与结论框架,适合先把研究任务落成一版结构化简报。", + outputHint: "研究提纲 + 结论简报", + prompt: + "请围绕这个主题先给我做一版网页研究简报:明确研究目标、关键信息来源、核心发现、风险点,以及接下来最值得继续追踪的问题。", + shouldEnableWebSearch: true, + }, + { + id: "social-post-starter", + title: "社媒主稿生成", + summary: + "先进入社媒专项工作台,再围绕平台语境、结构和表达生成可继续迭代的首稿。", + outputHint: "社媒首稿 + 平台结构", + prompt: + "请先帮我起草一版社媒内容首稿:明确目标受众、平台语境、标题方向、正文结构和可继续扩写的角度。", + themeTarget: "social-media", + }, + { + id: "frontend-concept", + title: "前端概念方案", + summary: + "快速产出信息架构、关键模块与页面关系,适合产品概念、后台台架或工作台原型讨论。", + outputHint: "IA + 模块方案", + prompt: + "请帮我先整理一版前端概念方案:输出信息架构、核心页面、关键模块、交互流程和第一轮组件拆分建议。", + }, + { + id: "slide-outline", + title: "演示提纲草案", + summary: + "先拿到一版可讲述的演示结构,覆盖封面、问题、观点、案例与行动建议。", + outputHint: "PPT 大纲 + 讲述线", + prompt: + "请基于这个目标先生成一版演示提纲:包含封面定位、目录、核心论点、案例支撑、结论和下一步行动。", + }, + { + id: "browser-assist-task", + title: "浏览器协助办事", + summary: + "适合登录、表单、网页操作和信息采集任务,进入工作区后直接接管浏览器协助链路。", + outputHint: "浏览器任务执行", + prompt: + "请协助我完成一个浏览器任务:先明确目标网页、目标动作、约束条件和预期结果,再进入执行。", + shouldLaunchBrowserAssist: true, + }, + { + id: "team-breakdown", + title: "多代理拆任务", + summary: + "适合需要并行调研、方案拆解或多角色协作的任务,进入后默认启用 team runtime 偏好。", + outputHint: "任务拆解 + 分工执行", + prompt: + "请把这个任务按多代理方式拆解:先定义目标和约束,再拆成并行子任务,明确每个子代理的职责、产出和回收方式。", + shouldEnableTeamMode: true, + }, +]; + +function isValidUsageRecord( + value: unknown, +): value is HomeRecommendedSolutionUsageRecord { + if (!value || typeof value !== "object") { + return false; + } + + const record = value as Partial; + return ( + typeof record.solutionId === "string" && + record.solutionId.length > 0 && + typeof record.usedAt === "number" && + Number.isFinite(record.usedAt) + ); +} + +function listHomeRecommendedSolutionUsage(): HomeRecommendedSolutionUsageRecord[] { + if (typeof window === "undefined") { + return []; + } + + try { + const raw = window.localStorage.getItem( + HOME_RECOMMENDED_SOLUTION_USAGE_STORAGE_KEY, + ); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter(isValidUsageRecord) + .sort((left, right) => right.usedAt - left.usedAt) + .slice(0, MAX_HOME_RECOMMENDED_SOLUTION_USAGE_RECORDS); + } catch { + return []; + } +} + +function getHomeRecommendedSolutionUsageMap(): Map< + string, + HomeRecommendedSolutionUsageRecord +> { + return new Map( + listHomeRecommendedSolutionUsage().map((record) => [record.solutionId, record]), + ); +} + +function resolveHomeRecommendedSolutionBadge( + solutionId: string, + isRecent: boolean, +): string { + if (isRecent) { + return "最近使用"; + } + + if (solutionId === "social-post-starter") { + return "社媒方案"; + } + + if (solutionId === "browser-assist-task") { + return "浏览器协助"; + } + + if (solutionId === "team-breakdown") { + return "多代理"; + } + + return "Claw 方案"; +} + +export function listHomeRecommendedSolutions(): HomeRecommendedSolutionItem[] { + const usageMap = getHomeRecommendedSolutionUsageMap(); + + return HOME_RECOMMENDED_SOLUTIONS.map((solution, index) => { + const recentRecord = usageMap.get(solution.id); + const recentUsedAt = recentRecord?.usedAt ?? null; + + return { + ...solution, + badge: resolveHomeRecommendedSolutionBadge( + solution.id, + typeof recentUsedAt === "number", + ), + actionLabel: "立即开始", + statusLabel: "可直接开始", + statusTone: "emerald" as const, + recentUsedAt, + isRecent: typeof recentUsedAt === "number", + _sortIndex: index, + }; + }) + .sort((left, right) => { + if (left.recentUsedAt && right.recentUsedAt) { + if (left.recentUsedAt !== right.recentUsedAt) { + return right.recentUsedAt - left.recentUsedAt; + } + } else if (left.recentUsedAt) { + return -1; + } else if (right.recentUsedAt) { + return 1; + } + + return left._sortIndex - right._sortIndex; + }) + .map(({ _sortIndex, ...solution }) => solution); +} + +export function recordHomeRecommendedSolutionUsage(solutionId: string): void { + const nextRecord: HomeRecommendedSolutionUsageRecord = { + solutionId, + usedAt: Date.now(), + }; + + const nextRecords = [ + nextRecord, + ...listHomeRecommendedSolutionUsage().filter( + (record) => record.solutionId !== solutionId, + ), + ].slice(0, MAX_HOME_RECOMMENDED_SOLUTION_USAGE_RECORDS); + + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem( + HOME_RECOMMENDED_SOLUTION_USAGE_STORAGE_KEY, + JSON.stringify(nextRecords), + ); + } catch { + // ignore write errors + } +} diff --git a/src/components/agent/chat/utils/teamSuggestion.ts b/src/components/agent/chat/utils/teamSuggestion.ts index 9519021f9..f097adfa9 100644 --- a/src/components/agent/chat/utils/teamSuggestion.ts +++ b/src/components/agent/chat/utils/teamSuggestion.ts @@ -43,10 +43,13 @@ const EXPLICIT_TEAM_PATTERNS = [ /子代理/, /父子线程/, /subagent/i, - /spawn_agent/i, - /wait_agent/i, - /resume_agent/i, - /close_agent/i, + /agent/i, + /sendmessage/i, + /agent\s*tool/i, + /agent工具/, + /teamcreate/i, + /teamdelete/i, + /listpeers/i, /explorer/i, /executor/i, /orchestrator/i, @@ -158,7 +161,7 @@ function inferSuggestedRoles( activeTheme?: string, ): SuggestedTeamRole[] { if ( - /team runtime|team|多代理|子代理|父子线程|subagent|explorer|executor|orchestrator/i.test( + /team runtime|team|多代理|子代理|父子线程|subagent|agent|sendmessage|teamcreate|teamdelete|listpeers|explorer|executor|orchestrator/i.test( normalizedInput, ) ) { diff --git a/src/components/agent/chat/utils/toolDisplayInfo.test.ts b/src/components/agent/chat/utils/toolDisplayInfo.test.ts new file mode 100644 index 000000000..e5bff5bb2 --- /dev/null +++ b/src/components/agent/chat/utils/toolDisplayInfo.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeToolNameKey, resolveToolDisplayLabel } from "./toolDisplayInfo"; + +const REFERENCE_JS_TOOL_NAME_MAPPINGS = [ + ["AgentTool", "agent"], + ["AskUserQuestionTool", "askuserquestion"], + ["BashTool", "bash"], + ["BriefTool", "sendusermessage"], + ["ConfigTool", "config"], + ["EnterPlanModeTool", "enterplanmode"], + ["EnterWorktreeTool", "enterworktree"], + ["ExitPlanModeTool", "exitplanmode"], + ["ExitWorktreeTool", "exitworktree"], + ["FileEditTool", "edit"], + ["FileReadTool", "read"], + ["FileWriteTool", "write"], + ["GlobTool", "glob"], + ["GrepTool", "grep"], + ["LSPTool", "lsp"], + ["ListMcpResourcesTool", "listmcpresources"], + ["NotebookEditTool", "notebookedit"], + ["PowerShellTool", "powershell"], + ["ReadMcpResourceTool", "readmcpresource"], + ["RemoteTriggerTool", "remotetrigger"], + ["ScheduleCronTool", "croncreate"], + ["SendMessageTool", "sendmessage"], + ["SkillTool", "skill"], + ["SleepTool", "sleep"], + ["SyntheticOutputTool", "structuredoutput"], + ["TaskCreateTool", "taskcreate"], + ["TaskGetTool", "taskget"], + ["TaskListTool", "tasklist"], + ["TaskOutputTool", "taskoutput"], + ["TaskStopTool", "taskstop"], + ["TaskUpdateTool", "taskupdate"], + ["TeamCreateTool", "teamcreate"], + ["TeamDeleteTool", "teamdelete"], + ["ToolSearchTool", "toolsearch"], + ["WebFetchTool", "webfetch"], + ["WebSearchTool", "websearch"], +] as const; + +describe("toolDisplayInfo", () => { + it("应把参考 JS 工具目录名归一化为现役展示键", () => { + for (const [toolName, expected] of REFERENCE_JS_TOOL_NAME_MAPPINGS) { + expect(normalizeToolNameKey(toolName)).toBe(expected); + } + + expect(normalizeToolNameKey("RequestUserInputTool")).toBe( + "askuserquestion", + ); + expect(normalizeToolNameKey("AgentOutputTool")).toBe("taskoutput"); + expect(normalizeToolNameKey("BashOutputTool")).toBe("taskoutput"); + }); + + it("应为参考 JS 工具目录名解析出当前展示文案", () => { + expect(resolveToolDisplayLabel("AskUserQuestionTool")).toBe("用户确认"); + expect(resolveToolDisplayLabel("BriefTool")).toBe("用户消息"); + expect(resolveToolDisplayLabel("FileReadTool")).toBe("文件读取"); + expect(resolveToolDisplayLabel("AgentTool")).toBe("邀请协作成员"); + expect(resolveToolDisplayLabel("SendMessageTool")).toBe("补充说明"); + expect(resolveToolDisplayLabel("TeamCreateTool")).toBe("创建团队"); + expect(resolveToolDisplayLabel("TeamDeleteTool")).toBe("删除团队"); + expect(resolveToolDisplayLabel("ScheduleCronTool")).toBe("定时触发器"); + expect(resolveToolDisplayLabel("SyntheticOutputTool")).toBe("结构化输出"); + expect(resolveToolDisplayLabel("AgentOutputTool")).toBe("任务输出"); + expect(resolveToolDisplayLabel("BashOutputTool")).toBe("任务输出"); + }); +}); diff --git a/src/components/agent/chat/utils/toolDisplayInfo.ts b/src/components/agent/chat/utils/toolDisplayInfo.ts index 46f43de0f..6216342e2 100644 --- a/src/components/agent/chat/utils/toolDisplayInfo.ts +++ b/src/components/agent/chat/utils/toolDisplayInfo.ts @@ -724,7 +724,7 @@ const EXACT_TOOL_CONFIGS = new Map([ }, ], [ - "ask", + "askuserquestion", { family: "generic", label: "用户确认", @@ -740,23 +740,55 @@ const EXACT_TOOL_CONFIGS = new Map([ }, ], [ - "requestuserinput", + "sendusermessage", { family: "generic", - label: "用户确认", - verb: "收集", - icon: Wrench, - groupTitle: "交互", + label: "用户消息", + verb: "发送", + icon: FileText, + groupTitle: "用户消息", actionKey: "generic", actions: { - failed: "收集失败", - completed: "已收集", - running: "等待输入", + failed: "发送失败", + completed: "已发送", + running: "发送中", }, }, ], [ - "spawnagent", + "structuredoutput", + { + family: "generic", + label: "结构化输出", + verb: "返回", + icon: FileText, + groupTitle: "回复", + actionKey: "generic", + actions: { + failed: "返回失败", + completed: "已返回结构化结果", + running: "返回结构化结果中", + }, + }, + ], + [ + "brief", + { + family: "generic", + label: "用户消息", + verb: "发送", + icon: FileText, + groupTitle: "用户消息", + actionKey: "generic", + actions: { + failed: "发送失败", + completed: "已发送", + running: "发送中", + }, + }, + ], + [ + "agent", { family: "subagent", label: "邀请协作成员", @@ -767,7 +799,7 @@ const EXACT_TOOL_CONFIGS = new Map([ }, ], [ - "sendinput", + "sendmessage", { family: "subagent", label: "补充说明", @@ -777,6 +809,49 @@ const EXACT_TOOL_CONFIGS = new Map([ actionKey: "subagent", }, ], + [ + "teamcreate", + { + family: "subagent", + label: "创建团队", + verb: "创建", + icon: Globe, + groupTitle: "创建团队", + actionKey: "subagent", + actions: { + failed: "创建失败", + completed: "已创建", + running: "创建中", + }, + }, + ], + [ + "teamdelete", + { + family: "subagent", + label: "删除团队", + verb: "删除", + icon: Globe, + groupTitle: "删除团队", + actionKey: "subagent", + actions: { + failed: "删除失败", + completed: "已删除", + running: "删除中", + }, + }, + ], + [ + "listpeers", + { + family: "list", + label: "协作成员", + verb: "查看", + icon: Globe, + groupTitle: "协作", + actionKey: "list", + }, + ], [ "waitagent", { @@ -811,14 +886,62 @@ const EXACT_TOOL_CONFIGS = new Map([ }, ], [ - "subagenttask", + "croncreate", { - family: "subagent", - label: "协作分工", - verb: "分工", + family: "task", + label: "定时触发器", + verb: "创建", + icon: Settings, + groupTitle: "定时触发", + actionKey: "generic", + actions: { + failed: "创建失败", + completed: "已创建", + running: "创建中", + }, + }, + ], + [ + "cronlist", + { + family: "list", + label: "定时触发器", + verb: "查看", + icon: Settings, + groupTitle: "定时触发", + actionKey: "list", + }, + ], + [ + "crondelete", + { + family: "task", + label: "定时触发器", + verb: "删除", + icon: Settings, + groupTitle: "定时触发", + actionKey: "generic", + actions: { + failed: "删除失败", + completed: "已删除", + running: "删除中", + }, + }, + ], + [ + "remotetrigger", + { + family: "command", + label: "远程触发器", + verb: "处理", icon: Globe, - groupTitle: "协作", - actionKey: "subagent", + groupTitle: "远程触发", + actionKey: "generic", + actions: { + failed: "处理失败", + completed: "已处理", + running: "处理中", + }, }, ], [ @@ -1303,21 +1426,72 @@ const getFileName = (filePath: string): string => { return parts[parts.length - 1] || filePath; }; +const TOOL_NAME_KEY_ALIASES: Record = { + ask: "askuserquestion", + requestuserinput: "askuserquestion", + requestuserinputtool: "askuserquestion", + askuserquestiontool: "askuserquestion", + brief: "sendusermessage", + brieftool: "sendusermessage", + sendusermessage: "sendusermessage", + sendusermessagetool: "sendusermessage", + spawnagent: "agent", + subagenttask: "agent", + agenttool: "agent", + sendinput: "sendmessage", + sendmessagetool: "sendmessage", + bashtool: "bash", + configtool: "config", + enterplanmodetool: "enterplanmode", + exitplanmodetool: "exitplanmode", + enterworktreetool: "enterworktree", + exitworktreetool: "exitworktree", + filereadtool: "read", + readfiletool: "read", + filewritetool: "write", + writefiletool: "write", + createfiletool: "write", + fileedittool: "edit", + globtool: "glob", + greptool: "grep", + lsptool: "lsp", + listmcpresourcestool: "listmcpresources", + readmcpresourcetool: "readmcpresource", + notebookedittool: "notebookedit", + powershelltool: "powershell", + remotetriggertool: "remotetrigger", + schedulecrontool: "croncreate", + croncreatetool: "croncreate", + cronlisttool: "cronlist", + crondeletetool: "crondelete", + skilltool: "skill", + sleeptool: "sleep", + syntheticoutputtool: "structuredoutput", + taskcreatetool: "taskcreate", + taskgettool: "taskget", + tasklisttool: "tasklist", + taskoutputtool: "taskoutput", + agentoutputtool: "taskoutput", + bashoutputtool: "taskoutput", + taskstoptool: "taskstop", + taskupdatetool: "taskupdate", + teamcreatetool: "teamcreate", + teamdeletetool: "teamdelete", + toolsearchtool: "toolsearch", + webfetchtool: "webfetch", + websearchtool: "websearch", + task: "bash", + killshell: "taskstop", + todowrite: "taskupdate", + writetodos: "taskupdate", +}; + export const normalizeToolNameKey = (value: string): string => { const normalized = value .replace(/[\s_-]+/g, "") .trim() .toLowerCase(); - if (normalized === "task") { - return "bash"; - } - if (normalized === "killshell") { - return "taskstop"; - } - if (normalized === "todowrite" || normalized === "writetodos") { - return "taskupdate"; - } - return normalized; + return TOOL_NAME_KEY_ALIASES[normalized] || normalized; }; export const humanizeToolName = (toolName: string): string => @@ -1387,7 +1561,7 @@ export const resolveToolPrimarySubject = ( return resolveToolArgumentPreview(args, ["command", "cmd", "cwd"]); } - if (normalizedName === "spawnagent" || normalizedName === "subagenttask") { + if (normalizedName === "agent") { return resolveToolArgumentPreview(args, [ "description", "task", @@ -1398,13 +1572,25 @@ export const resolveToolPrimarySubject = ( ]); } - if (normalizedName === "sendinput") { + if (normalizedName === "sendmessage") { return ( resolveToolArgumentPreview(args, ["message", "id", "agent_id"]) || "目标协作成员" ); } + if (normalizedName === "sendusermessage" || normalizedName === "brief") { + return resolveToolArgumentPreview(args, ["message"]) || "用户"; + } + + if (normalizedName === "teamcreate" || normalizedName === "teamdelete") { + return resolveToolArgumentPreview(args, ["team_name", "teamName"]) || "当前团队"; + } + + if (normalizedName === "listpeers") { + return resolveToolArgumentPreview(args, ["team_name", "teamName"]) || "当前团队"; + } + if ( normalizedName === "waitagent" || normalizedName === "resumeagent" || @@ -1516,7 +1702,7 @@ export const resolveToolPrimarySubject = ( return resolveToolArgumentPreview(args, ["query", "q"]) || "站点能力"; } - if (normalizedName === "ask" || normalizedName === "requestuserinput") { + if (normalizedName === "askuserquestion") { return resolveToolArgumentPreview(args, [ "question", "header", @@ -1525,6 +1711,28 @@ export const resolveToolPrimarySubject = ( ]); } + if (normalizedName === "remotetrigger") { + return ( + resolveToolArgumentPreview(args, [ + "trigger_id", + "triggerId", + "action", + "organization_uuid", + ]) || "远程触发器" + ); + } + + if ( + normalizedName === "croncreate" || + normalizedName === "cronlist" || + normalizedName === "crondelete" + ) { + return ( + resolveToolArgumentPreview(args, ["id", "cron", "schedule", "prompt"]) || + "定时触发器" + ); + } + return ( resolveToolArgumentPreview(args, [ "pattern", diff --git a/src/components/agent/chat/workspace/imageWorkbenchHelpers.ts b/src/components/agent/chat/workspace/imageWorkbenchHelpers.ts index c32d87c52..1766abc63 100644 --- a/src/components/agent/chat/workspace/imageWorkbenchHelpers.ts +++ b/src/components/agent/chat/workspace/imageWorkbenchHelpers.ts @@ -60,6 +60,10 @@ export function createInitialSessionImageWorkbenchState(): SessionImageWorkbench }; } +export function resolveImageWorkbenchAssistantMessageId(taskId: string): string { + return `image-workbench:${taskId}:assistant`; +} + export function buildImageWorkbenchDispatchMessages(params: { rawText: string; images: MessageImage[]; @@ -67,6 +71,9 @@ export function buildImageWorkbenchDispatchMessages(params: { prompt: string; mode: ImageWorkbenchTaskMode; count: number; + size?: string; + projectId?: string | null; + contentId?: string | null; }): Message[] { const timestamp = new Date(); const modeLabel = @@ -85,17 +92,27 @@ export function buildImageWorkbenchDispatchMessages(params: { timestamp, }, { - id: `image-workbench:${params.taskId}:assistant`, + id: resolveImageWorkbenchAssistantMessageId(params.taskId), role: "assistant", content: `已创建${modeLabel}任务,正在准备 ${params.count} 张结果。`, timestamp: new Date(timestamp.getTime() + 1), + isThinking: true, + imageWorkbenchPreview: { + taskId: params.taskId, + prompt: params.prompt, + status: "running", + projectId: params.projectId ?? null, + contentId: params.contentId ?? null, + imageCount: params.count, + size: params.size, + }, runtimeStatus: { phase: "routing", - title: `${modeLabel}已进入工作台`, + title: `${modeLabel}进行中`, detail: params.prompt.trim() - ? `主画布已接管当前任务:${params.prompt.trim()}` - : "主画布已接管当前任务,正在准备图片服务与结果卡片。", - checkpoints: ["记录当前调度", "创建画布任务卡", "等待结果回填"], + ? `任务已进入图片工作台,对话区会持续同步进度:${params.prompt.trim()}` + : "任务已进入图片工作台,对话区会持续同步进度与结果摘要。", + checkpoints: ["记录当前调度", "提交图片服务", "回填结果摘要"], }, }, ]; @@ -106,6 +123,11 @@ export function buildImageWorkbenchCompletionMessage(params: { successCount: number; failedCount: number; mode: ImageWorkbenchTaskMode; + prompt: string; + projectId?: string | null; + contentId?: string | null; + imageUrl?: string | null; + size?: string; }): Message { const timestamp = new Date(); const modeLabel = @@ -120,10 +142,61 @@ export function buildImageWorkbenchCompletionMessage(params: { : `${modeLabel}已完成,共生成 ${params.successCount} 张。`; return { - id: `image-workbench:${params.taskId}:complete`, + id: resolveImageWorkbenchAssistantMessageId(params.taskId), role: "assistant", content: detail, timestamp, + isThinking: false, + imageWorkbenchPreview: { + taskId: params.taskId, + prompt: params.prompt, + status: + params.successCount === 0 + ? "failed" + : params.failedCount > 0 + ? "partial" + : "complete", + projectId: params.projectId ?? null, + contentId: params.contentId ?? null, + imageUrl: params.imageUrl ?? null, + imageCount: params.successCount, + size: params.size, + }, + }; +} + +export function buildImageWorkbenchFailureMessage(params: { + taskId: string; + failureMessage: string; + mode: ImageWorkbenchTaskMode; + prompt: string; + projectId?: string | null; + contentId?: string | null; + size?: string; +}): Message { + const timestamp = new Date(); + const modeLabel = + params.mode === "edit" + ? "图片编辑" + : params.mode === "variation" + ? "图片变体" + : "图片生成"; + + return { + id: resolveImageWorkbenchAssistantMessageId(params.taskId), + role: "assistant", + content: `${modeLabel}失败:${params.failureMessage}`, + timestamp, + isThinking: false, + imageWorkbenchPreview: { + taskId: params.taskId, + prompt: params.prompt, + status: "failed", + projectId: params.projectId ?? null, + contentId: params.contentId ?? null, + imageCount: 0, + size: params.size, + }, }; } diff --git a/src/components/agent/chat/workspace/themeWorkbenchHelpers.test.ts b/src/components/agent/chat/workspace/themeWorkbenchHelpers.test.ts index b2c71bd84..a4f138264 100644 --- a/src/components/agent/chat/workspace/themeWorkbenchHelpers.test.ts +++ b/src/components/agent/chat/workspace/themeWorkbenchHelpers.test.ts @@ -55,6 +55,53 @@ describe("themeWorkbenchHelpers", () => { ); }); + it("应为 lime media CLI bash 调用生成明确的媒体任务标题", () => { + const messages: Message[] = [ + { + id: "user-1", + role: "user", + content: "/image_generate 请生成配图", + timestamp: new Date("2026-04-03T10:00:00.000Z"), + }, + { + id: "assistant-1", + role: "assistant", + content: "", + timestamp: new Date("2026-04-03T10:00:01.000Z"), + isThinking: true, + toolCalls: [ + { + id: "tool-bash-1", + name: "Bash", + arguments: JSON.stringify({ + command: + "lime media image generate --prompt '未来城市插图' --json", + }), + status: "completed", + startTime: new Date("2026-04-03T10:00:01.500Z"), + endTime: new Date("2026-04-03T10:00:02.000Z"), + }, + ], + }, + ]; + + const workflowSteps = buildThemeWorkbenchWorkflowSteps( + messages, + null, + true, + {}, + ); + + expect(workflowSteps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + title: "提交配图任务", + status: "completed", + }), + ]), + ); + }); + it("应读取后端持久化的主题工作台版本元数据", () => { const persisted = readPersistedThemeWorkbenchDocument({ theme_workbench_document_v1: { diff --git a/src/components/agent/chat/workspace/themeWorkbenchHelpers.ts b/src/components/agent/chat/workspace/themeWorkbenchHelpers.ts index ef5164965..2a708ef50 100644 --- a/src/components/agent/chat/workspace/themeWorkbenchHelpers.ts +++ b/src/components/agent/chat/workspace/themeWorkbenchHelpers.ts @@ -16,8 +16,7 @@ import { parseSkillSlashCommand } from "../hooks/skillCommand"; import type { Message } from "../types"; import type { TaskFile } from "../components/TaskFiles"; -export const THEME_WORKBENCH_DOCUMENT_META_KEY = - "theme_workbench_document_v1"; +export const THEME_WORKBENCH_DOCUMENT_META_KEY = "theme_workbench_document_v1"; export const MAX_PERSISTED_DOCUMENT_VERSIONS = 40; export const SOCIAL_ARTICLE_SKILL_KEY = "social_post_with_cover"; export const THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS = 45 * 1000; @@ -195,6 +194,15 @@ function resolveThemeWorkbenchToolTaskTitle(toolCall: ToolCallState): string { if (normalized.includes("bash") || normalized.includes("shell")) { const commandValue = resolveThemeWorkbenchTextArg(args, ["command", "cmd"]); const commandProbe = commandValue.toLowerCase(); + if (commandProbe.includes("lime media image generate")) { + return "提交配图任务"; + } + if (commandProbe.includes("lime media cover generate")) { + return "提交封面任务"; + } + if (commandProbe.includes("lime media video generate")) { + return "提交视频任务"; + } if (commandProbe.includes("ffmpeg")) { return "处理音视频素材"; } diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx index 7a155f641..564e39a9c 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.test.tsx @@ -29,6 +29,7 @@ function renderHook(props?: Partial) { autoCollapsedTopicSidebarRef: { current: false }, mappedTheme: "general", normalizedEntryTheme: "general", + shouldPreserveBlankHomeSurface: false, shouldBootstrapCanvasOnEntry: false, canvasState: null, generalCanvasState: { @@ -228,4 +229,29 @@ describe("useWorkspaceCanvasLayoutRuntime", () => { expect(suppressGeneralCanvasArtifactAutoOpen).toHaveBeenCalledTimes(1); expect(suppressBrowserAssistCanvasAutoOpen).toHaveBeenCalledTimes(1); }); + + it("空白 new-task 首页在隐藏聊天面板时不应自动生成 fallback 画布", async () => { + const setLayoutMode = vi.fn(); + const setGeneralCanvasState = vi.fn(); + const setCanvasState = vi.fn(); + const { render } = renderHook({ + showChatPanel: false, + hasMessages: false, + layoutMode: "chat", + activeTheme: "general", + normalizedEntryTheme: "general", + shouldPreserveBlankHomeSurface: true, + setLayoutMode, + setGeneralCanvasState, + setCanvasState, + }); + + await render(); + + expect( + setLayoutMode.mock.calls.some((call) => call[0] === "canvas"), + ).toBe(false); + expect(setGeneralCanvasState).not.toHaveBeenCalled(); + expect(setCanvasState).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts index a19d1f40b..33b08962b 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasLayoutRuntime.ts @@ -1,7 +1,10 @@ import { useCallback, useEffect, useMemo, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; import { createInitialCanvasState, type CanvasStateUnion } from "@/lib/workspace/workbenchCanvas"; import { createInitialDocumentState } from "@/lib/workspace/workbenchCanvas"; -import type { CanvasState as GeneralCanvasState } from "@/components/general-chat/bridge"; +import { + DEFAULT_CANVAS_STATE, + type CanvasState as GeneralCanvasState, +} from "@/components/general-chat/bridge"; import type { LayoutMode, ThemeType } from "@/lib/workspace/workbenchContract"; import { isSpecializedWorkbenchTheme } from "@/lib/workspace/workbenchContract"; import type { CanvasWorkbenchLayoutMode } from "../components/CanvasWorkbenchLayout"; @@ -22,6 +25,7 @@ interface UseWorkspaceCanvasLayoutRuntimeParams { autoCollapsedTopicSidebarRef: MutableRefObject; mappedTheme: ThemeType; normalizedEntryTheme: ThemeType; + shouldPreserveBlankHomeSurface: boolean; shouldBootstrapCanvasOnEntry: boolean; canvasState: CanvasStateUnion | null; generalCanvasState: GeneralCanvasState; @@ -56,6 +60,7 @@ export function useWorkspaceCanvasLayoutRuntime({ autoCollapsedTopicSidebarRef, mappedTheme, normalizedEntryTheme, + shouldPreserveBlankHomeSurface, shouldBootstrapCanvasOnEntry, canvasState, generalCanvasState, @@ -93,6 +98,30 @@ export function useWorkspaceCanvasLayoutRuntime({ setShowSidebar(false); + if (shouldPreserveBlankHomeSurface) { + if (layoutMode !== "chat") { + setLayoutMode("chat"); + } + + if (activeTheme === "general") { + const isDefaultGeneralCanvasState = + generalCanvasState.isOpen === DEFAULT_CANVAS_STATE.isOpen && + generalCanvasState.contentType === DEFAULT_CANVAS_STATE.contentType && + generalCanvasState.content === DEFAULT_CANVAS_STATE.content && + generalCanvasState.isEditing === DEFAULT_CANVAS_STATE.isEditing; + + if (!isDefaultGeneralCanvasState) { + setGeneralCanvasState(DEFAULT_CANVAS_STATE); + } + } + + if (canvasState) { + setCanvasState(null); + } + + return; + } + if (layoutMode === "canvas") { return; } @@ -121,6 +150,10 @@ export function useWorkspaceCanvasLayoutRuntime({ }, [ activeTheme, canvasState, + generalCanvasState.content, + generalCanvasState.contentType, + generalCanvasState.isEditing, + generalCanvasState.isOpen, layoutMode, mappedTheme, setCanvasState, @@ -128,6 +161,7 @@ export function useWorkspaceCanvasLayoutRuntime({ setLayoutMode, setShowSidebar, showChatPanel, + shouldPreserveBlankHomeSurface, ]); useEffect(() => { diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx index 546e9c9ea..7bb74a74d 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx @@ -150,6 +150,13 @@ export function useWorkspaceCanvasSceneRuntime({ teamDispatchPreviewState, teamMemorySnapshot, }: UseWorkspaceCanvasSceneRuntimeParams) { + const imageWorkbenchHasPendingTasks = currentImageWorkbenchState.tasks.some( + (task) => + task.status === "queued" || + task.status === "routing" || + task.status === "running", + ); + return useWorkspaceCanvasScenePresentation({ shouldBootstrapCanvasOnEntry, normalizedEntryTheme, @@ -202,10 +209,9 @@ export function useWorkspaceCanvasSceneRuntime({ onModelChange: imageWorkbenchGenerationRuntime.setSelectedModelId, selectedSize: imageWorkbenchGenerationRuntime.selectedSize, onSizeChange: imageWorkbenchGenerationRuntime.setSelectedSize, - generating: imageWorkbenchGenerationRuntime.generating, + generating: imageWorkbenchHasPendingTasks, savingToResource: imageWorkbenchGenerationRuntime.savingToResource, - onStopGeneration: - imageWorkbenchActionRuntime.handleStopImageWorkbenchGeneration, + onStopGeneration: undefined, onViewportChange: imageWorkbenchActionRuntime.handleImageWorkbenchViewportChange, onSelectOutput: diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts new file mode 100644 index 000000000..db8e91693 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts @@ -0,0 +1,198 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useWorkspaceConversationSceneRuntime } from "./useWorkspaceConversationSceneRuntime"; + +const { mockPresentation } = vi.hoisted(() => ({ + mockPresentation: vi.fn((params) => params), +})); + +vi.mock("./useWorkspaceConversationScenePresentation", () => ({ + useWorkspaceConversationScenePresentation: mockPresentation, +})); + +function createBaseParams(overrides: Record = {}) { + const noop = vi.fn(); + const setCanvasWorkbenchLayoutMode = vi.fn(); + + return { + navigationActions: { + handleDismissEntryBanner: noop, + handleWorkspaceAlertSelectDirectory: noop, + handleDismissWorkspaceAlert: noop, + handleManageProviders: noop, + }, + inputbarScene: { + inputbarNode: null, + teamWorkbenchSurfaceProps: {}, + activeCanvasTaskFile: null, + }, + canvasScene: { + canvasWorkbenchDefaultPreview: null, + handleOpenCanvasWorkbenchPath: noop, + handleRevealCanvasWorkbenchPath: noop, + renderCanvasWorkbenchPreview: noop, + }, + conversationSendRuntime: { + handleSendFromEmptyState: noop, + }, + shellChromeRuntime: { + showChatLayout: true, + isWorkspaceCompactChrome: false, + themeWorkbenchLayoutBottomSpacing: { + messageViewportBottomPadding: "0px", + }, + shouldHideThemeWorkbenchInputForTheme: false, + shouldRenderInlineA2UI: false, + }, + themeWorkbenchHarnessDialog: undefined, + entryBannerVisible: false, + entryBannerMessage: undefined, + serviceSkillExecutionCard: undefined, + contextWorkspaceEnabled: false, + input: "", + setInput: noop, + providerType: "mock-provider", + setProviderType: noop, + model: "mock-model", + setModel: noop, + executionStrategy: "default", + setExecutionStrategy: noop, + accessMode: "default", + setAccessMode: noop, + chatToolPreferences: { + webSearch: false, + thinking: false, + task: false, + subagent: false, + }, + setChatToolPreferences: noop, + selectedTeam: null, + handleSelectTeam: noop, + handleEnableSuggestedTeam: noop, + creationMode: "guided", + setCreationMode: noop, + activeTheme: "general", + setActiveTheme: noop, + lockTheme: false, + artifacts: [], + generalCanvasContent: "", + resolvedCanvasState: null, + contentId: null, + selectedText: "", + handleRecommendationClick: noop, + projectCharacters: [], + skills: [], + skillsLoading: false, + handleNavigateToSkillSettings: noop, + handleRefreshSkills: noop, + handleOpenBrowserAssistInCanvas: noop, + browserAssistLaunching: false, + projectId: "project-1", + hideHistoryToggle: false, + showChatPanel: true, + showSidebar: false, + topBarChrome: "full", + onBackToProjectManagement: undefined, + fromResources: false, + handleBackHome: noop, + handleToggleSidebar: noop, + chatMode: "chat", + isBrowserAssistCanvasVisible: false, + browserAssistAttentionLevel: "idle", + browserAssistEntryLabel: undefined, + showHarnessToggle: false, + navbarHarnessPanelVisible: false, + handleToggleHarnessPanel: noop, + harnessPendingCount: 0, + harnessAttentionLevel: "idle", + sessionId: null, + syncStatus: "idle", + pendingA2UIForm: undefined, + pendingA2UISource: null, + a2uiSubmissionNotice: undefined, + handlePendingA2UISubmit: noop, + handleToggleCanvas: noop, + currentImageWorkbenchActive: false, + hideInlineStepProgress: false, + isSpecializedThemeMode: false, + hasMessages: false, + steps: [], + currentStepIndex: 0, + goToStep: noop, + displayMessages: [], + turns: [], + effectiveThreadItems: [], + currentTurnId: null, + threadRead: false, + pendingActions: [], + submittedActionsInFlight: [], + queuedTurns: [], + isSending: false, + stopSending: noop, + resumeThread: noop, + replayPendingAction: noop, + promoteQueuedTurn: noop, + deleteMessage: noop, + editMessage: noop, + handleA2UISubmit: noop, + handleWriteFile: noop, + handleFileClick: noop, + handleOpenArtifactFromTimeline: noop, + handleOpenSavedSiteContent: noop, + handleArtifactClick: noop, + handleOpenSubagentSession: noop, + handlePermissionResponseWithBrowserPreflight: noop, + pendingPromotedA2UIActionRequest: null, + shouldCollapseCodeBlocks: false, + shouldCollapseCodeBlockInChat: noop, + handleCodeBlockClick: noop, + showTeamWorkspaceBoard: false, + layoutMode: "chat-canvas", + handleActivateTeamWorkbench: noop, + isThemeWorkbench: false, + settledWorkbenchArtifacts: [], + taskFiles: [], + selectedFileId: undefined, + projectRootPath: "/tmp/project-1", + handleHarnessLoadFilePreview: noop, + setCanvasWorkbenchLayoutMode, + workspacePathMissing: false, + workspaceHealthError: false, + focusedTimelineItemId: null, + timelineFocusRequestKey: 0, + ...overrides, + } as any; +} + +describe("useWorkspaceConversationSceneRuntime", () => { + beforeEach(() => { + mockPresentation.mockClear(); + }); + + it("通用 Claw 双栏场景应继续同步 stacked/split 布局状态", () => { + const params = createBaseParams(); + const setCanvasWorkbenchLayoutMode = params.setCanvasWorkbenchLayoutMode; + + useWorkspaceConversationSceneRuntime(params); + + const presentationParams = mockPresentation.mock.calls.at(-1)?.[0]; + expect( + presentationParams?.canvasWorkbenchLayout?.onLayoutModeChange, + ).toBe(setCanvasWorkbenchLayoutMode); + }); + + it("主题工作台场景不应再向外回写 stacked/split 布局状态", () => { + const params = createBaseParams({ + activeTheme: "social-media", + isThemeWorkbench: true, + isSpecializedThemeMode: true, + layoutMode: "canvas", + }); + + useWorkspaceConversationSceneRuntime(params); + + const presentationParams = mockPresentation.mock.calls.at(-1)?.[0]; + expect( + presentationParams?.canvasWorkbenchLayout?.onLayoutModeChange, + ).toBeUndefined(); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx index ca0cb4e2d..d2862fa07 100644 --- a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx @@ -291,6 +291,10 @@ export function useWorkspaceConversationSceneRuntime({ const teamWorkspaceDockLayoutMode = layoutMode === "chat" ? "chat" : "chat-canvas"; + const shouldSyncCanvasWorkbenchLayoutMode = + !isThemeWorkbench && + activeTheme === "general" && + layoutMode === "chat-canvas"; return useWorkspaceConversationScenePresentation({ scene: { @@ -465,7 +469,9 @@ export function useWorkspaceConversationSceneRuntime({ onOpenPath: canvasScene.handleOpenCanvasWorkbenchPath, onRevealPath: canvasScene.handleRevealCanvasWorkbenchPath, renderPreview: canvasScene.renderCanvasWorkbenchPreview, - onLayoutModeChange: setCanvasWorkbenchLayoutMode, + onLayoutModeChange: shouldSyncCanvasWorkbenchLayoutMode + ? setCanvasWorkbenchLayoutMode + : undefined, }, }); } diff --git a/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx new file mode 100644 index 000000000..7b7deef37 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx @@ -0,0 +1,422 @@ +import React, { useState } from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + listDirectory, + readFilePreview, + type FilePreview, +} from "@/lib/api/fileBrowser"; +import { safeListen } from "@/lib/dev-bridge"; +import type { Message } from "../types"; +import { + createInitialSessionImageWorkbenchState, + type SessionImageWorkbenchState, +} from "./imageWorkbenchHelpers"; +import { useWorkspaceImageTaskPreviewRuntime } from "./useWorkspaceImageTaskPreviewRuntime"; +import type { DirectoryListing } from "@/lib/api/fileBrowser"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeListen: vi.fn(), +})); + +vi.mock("@/lib/api/fileBrowser", () => ({ + listDirectory: vi.fn(), + readFilePreview: vi.fn(), +})); + +type HookProps = Parameters[0]; +type RuntimeHarnessProps = Omit< + HookProps, + "setChatMessages" | "updateCurrentImageWorkbenchState" +>; +type CreationTaskListener = Parameters[1]; + +const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = []; + +function createDeferred() { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function createFilePreviewResult( + path: string, + content: Record, +): FilePreview { + const serialized = JSON.stringify(content); + return { + path, + content: serialized, + isBinary: false, + size: serialized.length, + error: null, + }; +} + +function createDirectoryListingResult( + path: string, + entries: DirectoryListing["entries"], +): DirectoryListing { + return { + path, + parentPath: null, + entries, + error: null, + }; +} + +function renderHook(props?: Partial) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let latestValue: + | { + messages: Message[]; + imageWorkbenchState: SessionImageWorkbenchState; + } + | null = null; + + const defaultProps: RuntimeHarnessProps = { + sessionId: "session-image-1", + projectId: "project-image-1", + contentId: "content-image-1", + projectRootPath: "/workspace/project-image-1", + canvasState: null, + }; + + function Probe(currentProps: RuntimeHarnessProps) { + const [messages, setMessages] = useState([]); + const [imageWorkbenchState, setImageWorkbenchState] = + useState( + createInitialSessionImageWorkbenchState(), + ); + + latestValue = { + messages, + imageWorkbenchState, + }; + + useWorkspaceImageTaskPreviewRuntime({ + ...currentProps, + setChatMessages: setMessages, + updateCurrentImageWorkbenchState: setImageWorkbenchState, + }); + + return null; + } + + const render = async (nextProps?: Partial) => { + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ container, root }); + + return { + render, + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + }; +} + +describe("useWorkspaceImageTaskPreviewRuntime", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.mocked(safeListen).mockResolvedValue(vi.fn()); + vi.mocked(listDirectory).mockResolvedValue( + createDirectoryListingResult("/workspace/project-image-1/.lime/tasks", []), + ); + }); + + afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("应先插入运行中占位卡,再根据 task file 回填图片与工作台状态", async () => { + let listener: CreationTaskListener | null = null; + vi.mocked(safeListen).mockImplementationOnce(async (event, handler) => { + expect(event).toBe("lime://creation_task_submitted"); + listener = handler; + return vi.fn(); + }); + + const taskPath = + "/workspace/project-image-1/.lime/tasks/image/image_generate/task-image-1.json"; + const firstPreview = createDeferred(); + vi.mocked(readFilePreview).mockImplementationOnce(() => firstPreview.promise); + vi.mocked(readFilePreview).mockResolvedValueOnce( + createFilePreviewResult(taskPath, { + task_id: "task-image-1", + task_type: "image_generate", + task_family: "image", + status: "completed", + normalized_status: "succeeded", + created_at: "2026-04-04T10:00:00Z", + current_attempt_id: "attempt-1", + payload: { + prompt: "[img:未来感实验室里的青柠主视觉]", + count: 1, + size: "1024x1024", + }, + result: { + images: [{ url: "https://example.com/generated-lime.png" }], + }, + attempts: [ + { + attempt_id: "attempt-1", + provider: "fal", + model: "flux-pro", + result_snapshot: { + images: [{ url: "https://example.com/generated-lime.png" }], + }, + }, + ], + }), + ); + + const { render, getValue } = renderHook(); + await render(); + + await act(async () => { + listener?.({ + payload: { + task_id: "task-image-1", + task_type: "image_generate", + task_family: "image", + status: "pending_submit", + path: ".lime/tasks/image/image_generate/task-image-1.json", + }, + }); + await Promise.resolve(); + }); + + expect(readFilePreview).toHaveBeenCalledWith(taskPath, 256 * 1024); + expect(getValue().messages).toEqual([ + expect.objectContaining({ + id: "image-workbench:task-image-1:assistant", + content: "图片任务已创建,正在准备执行。", + isThinking: true, + imageWorkbenchPreview: expect.objectContaining({ + taskId: "task-image-1", + status: "running", + projectId: "project-image-1", + contentId: "content-image-1", + }), + }), + ]); + expect(getValue().imageWorkbenchState.active).toBe(false); + expect(getValue().imageWorkbenchState.tasks).toEqual([ + expect.objectContaining({ + id: "task-image-1", + status: "queued", + }), + ]); + expect(getValue().imageWorkbenchState.outputs).toEqual([]); + + firstPreview.resolve( + createFilePreviewResult(taskPath, { + task_id: "task-image-1", + task_type: "image_generate", + task_family: "image", + status: "running", + normalized_status: "running", + created_at: "2026-04-04T10:00:00Z", + current_attempt_id: "attempt-1", + payload: { + prompt: "[img:未来感实验室里的青柠主视觉]", + count: 1, + size: "1024x1024", + }, + progress: { + message: "正在绘制第一版构图", + }, + attempts: [ + { + attempt_id: "attempt-1", + provider: "fal", + model: "flux-pro", + }, + ], + }), + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(getValue().messages).toEqual([ + expect.objectContaining({ + content: "图片任务正在生成中。", + imageWorkbenchPreview: expect.objectContaining({ + taskId: "task-image-1", + status: "running", + prompt: "未来感实验室里的青柠主视觉", + imageCount: 1, + size: "1024x1024", + }), + runtimeStatus: expect.objectContaining({ + detail: "正在绘制第一版构图", + }), + }), + ]); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + await Promise.resolve(); + }); + + expect(getValue().messages).toEqual([ + expect.objectContaining({ + content: "图片任务已完成,共生成 1 张。", + isThinking: false, + imageWorkbenchPreview: expect.objectContaining({ + taskId: "task-image-1", + status: "complete", + prompt: "未来感实验室里的青柠主视觉", + imageUrl: "https://example.com/generated-lime.png", + imageCount: 1, + size: "1024x1024", + }), + }), + ]); + expect(getValue().imageWorkbenchState.tasks).toEqual([ + expect.objectContaining({ + id: "task-image-1", + status: "complete", + outputIds: ["task-image-1:output:1"], + }), + ]); + expect(getValue().imageWorkbenchState.outputs).toEqual([ + expect.objectContaining({ + id: "task-image-1:output:1", + taskId: "task-image-1", + url: "https://example.com/generated-lime.png", + prompt: "未来感实验室里的青柠主视觉", + providerName: "fal", + modelName: "flux-pro", + size: "1024x1024", + }), + ]); + }); + + it("应在进入会话时从 task file 恢复最近的图片任务", async () => { + vi.mocked(listDirectory) + .mockResolvedValueOnce( + createDirectoryListingResult("/workspace/project-image-1/.lime/tasks", [ + { + name: "image_generate", + path: "/workspace/project-image-1/.lime/tasks/image_generate", + isDir: true, + size: 0, + modifiedAt: Date.now(), + }, + ]), + ) + .mockResolvedValueOnce( + createDirectoryListingResult( + "/workspace/project-image-1/.lime/tasks/image_generate", + [ + { + name: "task-image-restored.json", + path: "/workspace/project-image-1/.lime/tasks/image_generate/task-image-restored.json", + isDir: false, + size: 512, + modifiedAt: Date.now(), + }, + ], + ), + ); + vi.mocked(readFilePreview).mockResolvedValueOnce( + createFilePreviewResult( + "/workspace/project-image-1/.lime/tasks/image_generate/task-image-restored.json", + { + task_id: "task-image-restored", + task_type: "image_generate", + task_family: "image", + status: "completed", + normalized_status: "succeeded", + created_at: new Date().toISOString(), + payload: { + prompt: "[img:恢复出来的青柠主视觉]", + count: 1, + size: "1024x1024", + }, + result: { + images: [{ url: "https://example.com/restored-lime.png" }], + }, + attempts: [ + { + attempt_id: "attempt-restore-1", + provider: "fal", + model: "flux-pro", + result_snapshot: { + images: [{ url: "https://example.com/restored-lime.png" }], + }, + }, + ], + }, + ), + ); + + const { render, getValue } = renderHook(); + await render(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(listDirectory).toHaveBeenCalledWith( + "/workspace/project-image-1/.lime/tasks", + ); + expect(readFilePreview).toHaveBeenCalledWith( + "/workspace/project-image-1/.lime/tasks/image_generate/task-image-restored.json", + 256 * 1024, + ); + expect(getValue().messages).toEqual([ + expect.objectContaining({ + id: "image-workbench:task-image-restored:assistant", + content: "图片任务已完成,共生成 1 张。", + imageWorkbenchPreview: expect.objectContaining({ + taskId: "task-image-restored", + status: "complete", + imageUrl: "https://example.com/restored-lime.png", + }), + }), + ]); + expect(getValue().imageWorkbenchState.tasks).toEqual([ + expect.objectContaining({ + id: "task-image-restored", + status: "complete", + }), + ]); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.ts new file mode 100644 index 000000000..4f881c6a4 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.ts @@ -0,0 +1,1309 @@ +import { useEffect, useRef, type Dispatch, type SetStateAction } from "react"; +import { listDirectory, readFilePreview } from "@/lib/api/fileBrowser"; +import { safeListen } from "@/lib/dev-bridge"; +import { resolveAbsoluteWorkspacePath } from "./workspacePath"; +import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas"; +import type { Message, MessageImageWorkbenchPreview } from "../types"; +import { + resolveImageWorkbenchAssistantMessageId, + resolveScopedImageWorkbenchApplyTarget, + type ImageWorkbenchOutput, + type ImageWorkbenchTask, + type SessionImageWorkbenchState, +} from "./imageWorkbenchHelpers"; + +const IMAGE_TASK_EVENT_NAME = "lime://creation_task_submitted"; +const IMAGE_TASK_FILE_PREVIEW_MAX_SIZE = 256 * 1024; +const IMAGE_TASK_POLL_INTERVAL_MS = 3000; +const IMAGE_TASK_RESTORE_LOOKBACK_MS = 24 * 60 * 60 * 1000; +const IMAGE_TASK_RESTORE_LIMIT = 8; +const IMAGE_TASKS_ROOT_RELATIVE_PATH = ".lime/tasks"; +const IMAGE_TASKS_RESTORE_SCAN_DEPTH = 2; + +interface CreationTaskSubmittedPayload { + task_id?: string; + task_type?: string; + task_family?: string; + status?: string; + current_attempt_id?: string; + path?: string; + absolute_path?: string; + prompt?: string; + size?: string; + mode?: string; + raw_text?: string; + count?: number; + reused_existing?: boolean; + session_id?: string; + project_id?: string; + content_id?: string; + entry_source?: string; + requested_target?: string; +} + +interface TrackedImageTask { + taskId: string; + taskType: string; + taskFamily: string; + artifactPath: string; + absolutePath: string; + timerId: number | null; + polling: boolean; +} + +interface ParsedImageTaskSnapshot { + taskId: string; + message: Message; + task: ImageWorkbenchTask; + outputs: ImageWorkbenchOutput[]; + terminal: boolean; + updatedAt: number; +} + +interface UseWorkspaceImageTaskPreviewRuntimeParams { + sessionId?: string | null; + projectId?: string | null; + contentId?: string | null; + projectRootPath?: string | null; + canvasState: CanvasStateUnion | null; + setChatMessages: Dispatch>; + updateCurrentImageWorkbenchState: ( + updater: ( + current: SessionImageWorkbenchState, + ) => SessionImageWorkbenchState, + ) => void; +} + +interface TaskContextMetadata { + sessionId?: string; + projectId?: string; + contentId?: string; +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function readString( + candidates: Array | null | undefined>, + keys: string[], +): string | undefined { + for (const candidate of candidates) { + if (!candidate) { + continue; + } + for (const key of keys) { + const value = candidate[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + } + return undefined; +} + +function readPositiveNumber( + candidates: Array | null | undefined>, + keys: string[], +): number | undefined { + for (const candidate of candidates) { + if (!candidate) { + continue; + } + for (const key of keys) { + const value = candidate[key]; + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + } + } + return undefined; +} + +function normalizeTaskFamily( + taskType: string, + taskFamily?: string, +): string | undefined { + const normalizedFamily = taskFamily?.trim().toLowerCase(); + if (normalizedFamily) { + return normalizedFamily; + } + + const normalizedType = taskType.trim().toLowerCase(); + if (normalizedType.includes("image") || normalizedType.includes("cover")) { + return "image"; + } + if (normalizedType.includes("video")) { + return "video"; + } + return undefined; +} + +function normalizeTaskRef(value?: string | null): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +function normalizeTaskStatus(status?: string): string { + switch ((status || "").trim().toLowerCase()) { + case "pending_submit": + case "pending": + return "pending"; + case "queued": + return "queued"; + case "running": + case "processing": + case "in_progress": + return "running"; + case "partial": + return "partial"; + case "completed": + case "success": + case "succeeded": + return "succeeded"; + case "failed": + case "error": + return "failed"; + case "cancelled": + case "canceled": + return "cancelled"; + default: + return "pending"; + } +} + +function matchesRuntimeEventContext(params: { + payload: CreationTaskSubmittedPayload; + sessionId?: string | null; + projectId?: string | null; + contentId?: string | null; +}): boolean { + const normalizedSessionId = normalizeTaskRef(params.sessionId); + const normalizedProjectId = normalizeTaskRef(params.projectId); + const normalizedContentId = normalizeTaskRef(params.contentId); + const payloadSessionId = normalizeTaskRef(params.payload.session_id); + const payloadProjectId = normalizeTaskRef(params.payload.project_id); + const payloadContentId = normalizeTaskRef(params.payload.content_id); + + if ( + normalizedSessionId && + payloadSessionId && + payloadSessionId !== normalizedSessionId + ) { + return false; + } + if ( + normalizedProjectId && + payloadProjectId && + payloadProjectId !== normalizedProjectId + ) { + return false; + } + if ( + normalizedContentId && + payloadContentId && + payloadContentId !== normalizedContentId + ) { + return false; + } + + return true; +} + +function resolveTaskRecordTimestamp(taskRecord: Record): number { + const timestampRaw = + readString([taskRecord], ["updated_at", "updatedAt", "created_at", "createdAt"]) || + ""; + const timestamp = Date.parse(timestampRaw); + return Number.isFinite(timestamp) ? timestamp : Date.now(); +} + +function resolveTaskContextMetadata( + taskRecord: Record, +): TaskContextMetadata { + const payload = asRecord(taskRecord.payload); + return { + sessionId: normalizeTaskRef( + readString([taskRecord, payload], ["session_id", "sessionId"]), + ), + projectId: normalizeTaskRef( + readString([taskRecord, payload], ["project_id", "projectId"]), + ), + contentId: normalizeTaskRef( + readString([taskRecord, payload], ["content_id", "contentId"]), + ), + }; +} + +function shouldRestoreImageTaskRecord(params: { + taskRecord: Record; + sessionId?: string | null; + projectId?: string | null; + contentId?: string | null; +}): boolean { + const taskType = + readString([params.taskRecord], ["task_type", "taskType"]) || ""; + const taskFamily = normalizeTaskFamily( + taskType, + readString([params.taskRecord], ["task_family", "taskFamily"]), + ); + if (taskFamily !== "image") { + return false; + } + + const metadata = resolveTaskContextMetadata(params.taskRecord); + const normalizedSessionId = normalizeTaskRef(params.sessionId); + const normalizedProjectId = normalizeTaskRef(params.projectId); + const normalizedContentId = normalizeTaskRef(params.contentId); + + if ( + normalizedSessionId && + metadata.sessionId && + metadata.sessionId !== normalizedSessionId + ) { + return false; + } + if ( + normalizedProjectId && + metadata.projectId && + metadata.projectId !== normalizedProjectId + ) { + return false; + } + if ( + normalizedContentId && + metadata.contentId && + metadata.contentId !== normalizedContentId + ) { + return false; + } + + if (normalizedSessionId && metadata.sessionId === normalizedSessionId) { + return true; + } + if (normalizedContentId && metadata.contentId === normalizedContentId) { + return true; + } + if (normalizedProjectId && metadata.projectId === normalizedProjectId) { + return true; + } + + const normalizedStatus = normalizeTaskStatus( + readString([params.taskRecord], ["normalized_status", "status"]), + ); + if ( + normalizedStatus === "pending" || + normalizedStatus === "queued" || + normalizedStatus === "running" || + normalizedStatus === "partial" + ) { + return true; + } + + return ( + Date.now() - resolveTaskRecordTimestamp(params.taskRecord) <= + IMAGE_TASK_RESTORE_LOOKBACK_MS + ); +} + +async function collectImageTaskCandidatePaths( + projectRootPath: string, +): Promise { + const normalizedProjectRoot = projectRootPath.trim(); + if (!normalizedProjectRoot) { + return []; + } + + const rootPath = resolveAbsoluteWorkspacePath( + normalizedProjectRoot, + IMAGE_TASKS_ROOT_RELATIVE_PATH, + ); + if (!rootPath) { + return []; + } + + const pendingDirs: Array<{ path: string; depth: number }> = [ + { path: rootPath, depth: 0 }, + ]; + const discoveredPaths: string[] = []; + const visitedDirs = new Set(); + + while (pendingDirs.length > 0) { + const currentDir = pendingDirs.shift(); + if (!currentDir || visitedDirs.has(currentDir.path)) { + continue; + } + visitedDirs.add(currentDir.path); + + try { + const listing = await listDirectory(currentDir.path); + if (listing.error) { + continue; + } + + for (const entry of listing.entries) { + if (entry.isDir) { + if (currentDir.depth < IMAGE_TASKS_RESTORE_SCAN_DEPTH) { + pendingDirs.push({ + path: entry.path, + depth: currentDir.depth + 1, + }); + } + continue; + } + + if (entry.name.toLowerCase().endsWith(".json")) { + discoveredPaths.push(entry.path); + } + } + } catch { + continue; + } + } + + return discoveredPaths; +} + +function sanitizePreviewPrompt(value?: string): string { + const trimmed = value?.trim() || ""; + if (!trimmed) { + return ""; + } + + const mediaTagMatch = trimmed.match(/^\[(?:img|video):(.+)\]$/i); + if (mediaTagMatch?.[1]) { + return mediaTagMatch[1].trim(); + } + + return trimmed; +} + +function normalizeTaskModeValue( + value?: string, +): ImageWorkbenchTask["mode"] | undefined { + switch ((value || "").trim().toLowerCase()) { + case "edit": + return "edit"; + case "variation": + case "variant": + return "variation"; + case "generate": + return "generate"; + default: + return undefined; + } +} + +function resolveTaskLabel( + taskType: string, + taskMode: ImageWorkbenchTask["mode"], +): string { + const normalizedType = taskType.trim().toLowerCase(); + if (normalizedType === "cover_generate") { + return "封面任务"; + } + switch (taskMode) { + case "edit": + return "图片编辑任务"; + case "variation": + return "图片变体任务"; + case "generate": + default: + return normalizedType.includes("image") ? "图片任务" : "媒体任务"; + } +} + +function resolveTaskMode( + taskType: string, + taskRecord?: Record, +): ImageWorkbenchTask["mode"] { + const payloadMode = normalizeTaskModeValue( + readString([asRecord(taskRecord?.payload)], ["mode", "task_mode"]), + ); + if (payloadMode) { + return payloadMode; + } + const normalizedType = taskType.trim().toLowerCase(); + if (normalizedType.includes("edit")) { + return "edit"; + } + if ( + normalizedType.includes("variation") || + normalizedType.includes("variant") + ) { + return "variation"; + } + return "generate"; +} + +function resolveTaskRequestedTarget(taskType: string): "generate" | "cover" { + return taskType.trim().toLowerCase() === "cover_generate" + ? "cover" + : "generate"; +} + +function resolvePreviewStatus( + normalizedStatus: string, +): MessageImageWorkbenchPreview["status"] { + switch (normalizedStatus) { + case "partial": + return "partial"; + case "succeeded": + return "complete"; + case "failed": + case "cancelled": + return "failed"; + case "pending": + case "queued": + case "running": + default: + return "running"; + } +} + +function resolveWorkbenchStatus(normalizedStatus: string): ImageWorkbenchTask["status"] { + switch (normalizedStatus) { + case "pending": + case "queued": + return "queued"; + case "running": + return "running"; + case "partial": + return "partial"; + case "succeeded": + return "complete"; + case "failed": + case "cancelled": + return "error"; + default: + return "routing"; + } +} + +function resolvePreviewMessageContent(params: { + taskLabel: string; + normalizedStatus: string; + successCount: number; + failureMessage?: string; +}): string { + const label = params.taskLabel; + switch (params.normalizedStatus) { + case "partial": + return `${label}已返回部分结果。`; + case "succeeded": + return params.successCount > 0 + ? `${label}已完成,共生成 ${params.successCount} 张。` + : `${label}已完成。`; + case "failed": + case "cancelled": + return params.failureMessage + ? `${label}失败:${params.failureMessage}` + : `${label}失败。`; + case "queued": + return `${label}已进入队列,正在等待执行。`; + case "running": + return `${label}正在生成中。`; + case "pending": + default: + return `${label}已创建,正在准备执行。`; + } +} + +function resolveAttemptRecord( + taskRecord: Record, +): Record | null { + const attempts = Array.isArray(taskRecord.attempts) + ? taskRecord.attempts + : []; + if (attempts.length === 0) { + return null; + } + + const currentAttemptId = + typeof taskRecord.current_attempt_id === "string" + ? taskRecord.current_attempt_id.trim() + : ""; + if (currentAttemptId) { + const matched = attempts.find((attempt) => { + const attemptRecord = asRecord(attempt); + return attemptRecord?.attempt_id === currentAttemptId; + }); + if (matched) { + return asRecord(matched); + } + } + + return asRecord(attempts[attempts.length - 1]); +} + +interface ParsedImageOutputSeed { + url: string; + prompt?: string; + providerName?: string; + modelName?: string; + size?: string; +} + +function appendImageOutputSeed( + target: ParsedImageOutputSeed[], + seenUrls: Set, + value: unknown, + fallbackPrompt: string, + fallbackProviderName?: string, + fallbackModelName?: string, + fallbackSize?: string, + depth = 0, +): void { + if (value === null || value === undefined || depth > 4) { + return; + } + + if (typeof value === "string") { + const url = value.trim(); + if (!url || seenUrls.has(url)) { + return; + } + seenUrls.add(url); + target.push({ + url, + prompt: fallbackPrompt || undefined, + providerName: fallbackProviderName, + modelName: fallbackModelName, + size: fallbackSize, + }); + return; + } + + if (Array.isArray(value)) { + value.forEach((item) => + appendImageOutputSeed( + target, + seenUrls, + item, + fallbackPrompt, + fallbackProviderName, + fallbackModelName, + fallbackSize, + depth + 1, + ), + ); + return; + } + + const record = asRecord(value); + if (!record) { + return; + } + + const url = readString([record], ["url", "src", "imageUrl", "image_url"]); + const prompt = readString([record], ["prompt", "revised_prompt", "title"]); + const providerName = readString([record], [ + "providerName", + "provider_name", + "provider", + "providerId", + "provider_id", + ]); + const modelName = readString([record], ["modelName", "model_name", "model"]); + const size = readString([record], ["size", "resolution"]); + + if (url && !seenUrls.has(url)) { + seenUrls.add(url); + target.push({ + url, + prompt: prompt || fallbackPrompt || undefined, + providerName: providerName || fallbackProviderName, + modelName: modelName || fallbackModelName, + size: size || fallbackSize, + }); + } + + [ + record.images, + record.outputs, + record.results, + record.items, + record.data, + record.output, + record.result, + record.image, + record.asset, + record.assets, + ].forEach((nested) => + appendImageOutputSeed( + target, + seenUrls, + nested, + prompt || fallbackPrompt, + providerName || fallbackProviderName, + modelName || fallbackModelName, + size || fallbackSize, + depth + 1, + ), + ); +} + +function buildParsedImageTaskSnapshot(params: { + taskRecord: Record; + taskId: string; + taskType: string; + projectId?: string | null; + contentId?: string | null; + canvasState: CanvasStateUnion | null; +}): ParsedImageTaskSnapshot | null { + const payload = asRecord(params.taskRecord.payload); + const currentAttempt = resolveAttemptRecord(params.taskRecord); + const currentAttemptResult = currentAttempt?.result_snapshot; + const resultValue = params.taskRecord.result; + const normalizedStatus = normalizeTaskStatus( + typeof params.taskRecord.normalized_status === "string" + ? params.taskRecord.normalized_status + : typeof params.taskRecord.status === "string" + ? params.taskRecord.status + : undefined, + ); + const prompt = sanitizePreviewPrompt( + readString( + [payload, params.taskRecord, asRecord(params.taskRecord.ui_hints)], + ["prompt", "summary", "title", "placeholder_text", "placeholderText"], + ) || "", + ); + const fallbackProviderName = readString([currentAttempt, payload], [ + "provider", + "providerName", + "provider_name", + "providerId", + "provider_id", + ]); + const fallbackModelName = readString([currentAttempt, payload], [ + "model", + "modelName", + "model_name", + ]); + const fallbackSize = readString([payload], ["size", "resolution"]); + const requestedCount = readPositiveNumber([payload], [ + "count", + "imageCount", + "image_count", + ]); + const expectedCount = requestedCount || 1; + const taskMode = resolveTaskMode(params.taskType, params.taskRecord); + const taskLabel = resolveTaskLabel(params.taskType, taskMode); + const lastError = + readString( + [asRecord(params.taskRecord.last_error), asRecord(params.taskRecord.progress)], + ["message"], + ) || + (typeof params.taskRecord.last_error === "string" + ? params.taskRecord.last_error.trim() + : undefined); + + const outputSeeds: ParsedImageOutputSeed[] = []; + const seenUrls = new Set(); + [resultValue, currentAttemptResult, payload?.imageUrl, payload?.image_url].forEach( + (candidate) => + appendImageOutputSeed( + outputSeeds, + seenUrls, + candidate, + prompt, + fallbackProviderName, + fallbackModelName, + fallbackSize, + ), + ); + + const applyTarget = resolveScopedImageWorkbenchApplyTarget({ + canvasState: params.canvasState, + projectId: params.projectId ?? null, + contentId: params.contentId ?? null, + requestedTarget: resolveTaskRequestedTarget(params.taskType), + }); + const createdAtRaw = + readString([params.taskRecord], ["updated_at", "updatedAt", "created_at", "createdAt"]) || + new Date().toISOString(); + const createdAt = Number.isNaN(Date.parse(createdAtRaw)) + ? Date.now() + : Date.parse(createdAtRaw); + const outputs: ImageWorkbenchOutput[] = outputSeeds.map((output, index) => ({ + id: `${params.taskId}:output:${index + 1}`, + taskId: params.taskId, + hookImageId: `${params.taskId}:hook:${index + 1}`, + refId: `img-${params.taskId.slice(0, 6)}-${index + 1}`, + url: output.url, + prompt: output.prompt || prompt || `${taskLabel}结果`, + createdAt, + providerName: output.providerName, + modelName: output.modelName, + size: output.size, + parentOutputId: null, + resourceSaved: false, + applyTarget, + })); + const successCount = outputs.length; + const previewStatus = resolvePreviewStatus(normalizedStatus); + const preview: MessageImageWorkbenchPreview = { + taskId: params.taskId, + prompt: prompt || `${taskLabel}进行中`, + status: previewStatus, + projectId: params.projectId ?? null, + contentId: params.contentId ?? null, + imageUrl: outputs[0]?.url ?? null, + imageCount: + previewStatus === "running" + ? requestedCount + : successCount || undefined, + size: fallbackSize, + }; + + const runtimeStatus = + previewStatus === "running" + ? { + phase: + normalizedStatus === "pending" || normalizedStatus === "queued" + ? ("preparing" as const) + : ("routing" as const), + title: `${taskLabel}进行中`, + detail: + readString([asRecord(params.taskRecord.progress)], ["message"]) || + (prompt + ? `正在处理:${prompt}` + : "任务已提交到异步队列,结果会自动回填。"), + checkpoints: ["创建任务文件", "轮询任务状态", "回填图片结果"], + } + : previewStatus === "failed" + ? { + phase: "failed" as const, + title: `${taskLabel}失败`, + detail: lastError || "任务未返回可用结果。", + checkpoints: ["检查任务文件", "查看失败详情", "可在图片画布继续排查"], + } + : undefined; + + return { + taskId: params.taskId, + message: { + id: resolveImageWorkbenchAssistantMessageId(params.taskId), + role: "assistant", + content: resolvePreviewMessageContent({ + taskLabel, + normalizedStatus, + successCount, + failureMessage: lastError, + }), + timestamp: new Date(createdAt), + isThinking: previewStatus === "running", + imageWorkbenchPreview: preview, + runtimeStatus, + }, + task: { + sessionId: params.taskId, + id: params.taskId, + mode: taskMode, + status: resolveWorkbenchStatus(normalizedStatus), + prompt: prompt || `${taskLabel}进行中`, + rawText: prompt || `${taskLabel}进行中`, + expectedCount, + outputIds: outputs.map((output) => output.id), + targetOutputId: null, + createdAt, + failureMessage: lastError, + hookImageIds: outputs.map((output) => output.hookImageId), + applyTarget, + }, + outputs, + terminal: + normalizedStatus === "succeeded" || + normalizedStatus === "failed" || + normalizedStatus === "cancelled", + updatedAt: createdAt, + }; +} + +function buildPendingImageTaskSnapshot(params: { + taskId: string; + taskType: string; + status?: string; + payload?: Record; + progressMessage?: string; + projectId?: string | null; + contentId?: string | null; + canvasState: CanvasStateUnion | null; +}): ParsedImageTaskSnapshot { + const taskMode = resolveTaskMode(params.taskType, { + payload: params.payload || {}, + }); + const taskLabel = resolveTaskLabel(params.taskType, taskMode); + return ( + buildParsedImageTaskSnapshot({ + taskRecord: { + task_id: params.taskId, + task_type: params.taskType, + status: params.status || "pending_submit", + normalized_status: normalizeTaskStatus(params.status), + payload: params.payload || {}, + progress: { + message: + params.progressMessage || "任务已提交到异步队列,正在同步任务状态。", + }, + created_at: new Date().toISOString(), + }, + taskId: params.taskId, + taskType: params.taskType, + projectId: params.projectId ?? null, + contentId: params.contentId ?? null, + canvasState: params.canvasState, + }) || { + taskId: params.taskId, + message: { + id: resolveImageWorkbenchAssistantMessageId(params.taskId), + role: "assistant", + content: `${taskLabel}已创建,正在准备执行。`, + timestamp: new Date(), + isThinking: true, + imageWorkbenchPreview: { + taskId: params.taskId, + prompt: `${taskLabel}进行中`, + status: "running", + projectId: params.projectId ?? null, + contentId: params.contentId ?? null, + }, + }, + task: { + sessionId: params.taskId, + id: params.taskId, + mode: taskMode, + status: "queued", + prompt: `${taskLabel}进行中`, + rawText: `${taskLabel}进行中`, + expectedCount: 1, + outputIds: [], + targetOutputId: null, + createdAt: Date.now(), + hookImageIds: [], + applyTarget: resolveScopedImageWorkbenchApplyTarget({ + canvasState: params.canvasState, + projectId: params.projectId ?? null, + contentId: params.contentId ?? null, + requestedTarget: resolveTaskRequestedTarget(params.taskType), + }), + }, + outputs: [], + terminal: false, + updatedAt: Date.now(), + } + ); +} + +function upsertPreviewMessage(messages: Message[], nextMessage: Message): Message[] { + const nextMessages = [...messages]; + const existingIndex = nextMessages.findIndex( + (message) => message.id === nextMessage.id, + ); + if (existingIndex >= 0) { + nextMessages[existingIndex] = nextMessage; + return nextMessages; + } + nextMessages.push(nextMessage); + return nextMessages; +} + +function mergeImageTaskSnapshot( + current: SessionImageWorkbenchState, + snapshot: ParsedImageTaskSnapshot, +): SessionImageWorkbenchState { + const previousTask = current.tasks.find((task) => task.id === snapshot.taskId); + const previousOutputs = current.outputs.filter( + (output) => output.taskId === snapshot.taskId, + ); + const preservedSelectedOutputId = current.outputs.find( + (output) => + output.id === current.selectedOutputId && output.taskId === snapshot.taskId, + )?.id; + const mergedOutputs = snapshot.outputs.map((output) => { + const previousOutput = previousOutputs.find( + (candidate) => candidate.url === output.url, + ); + return previousOutput + ? { + ...previousOutput, + ...output, + } + : output; + }); + const selectedOutputId = + preservedSelectedOutputId && + mergedOutputs.some((output) => output.id === preservedSelectedOutputId) + ? preservedSelectedOutputId + : mergedOutputs[0]?.id || current.selectedOutputId; + + return { + ...current, + tasks: [ + { + ...snapshot.task, + sessionId: previousTask?.sessionId || snapshot.task.sessionId, + }, + ...current.tasks.filter((task) => task.id !== snapshot.taskId), + ], + outputs: [ + ...mergedOutputs, + ...current.outputs.filter((output) => output.taskId !== snapshot.taskId), + ], + selectedOutputId, + }; +} + +export function useWorkspaceImageTaskPreviewRuntime({ + sessionId, + projectId, + contentId, + projectRootPath, + canvasState, + setChatMessages, + updateCurrentImageWorkbenchState, +}: UseWorkspaceImageTaskPreviewRuntimeParams) { + const trackedTasksRef = useRef>(new Map()); + const runtimeContextRef = useRef({ + sessionId, + projectId, + contentId, + projectRootPath, + canvasState, + }); + + runtimeContextRef.current = { + sessionId, + projectId, + contentId, + projectRootPath, + canvasState, + }; + + useEffect(() => { + const trackedTasks = trackedTasksRef.current; + trackedTasks.forEach((trackedTask) => { + if (trackedTask.timerId !== null) { + window.clearTimeout(trackedTask.timerId); + } + }); + trackedTasks.clear(); + + let cancelled = false; + let unlisten: (() => void) | null = null; + + const scheduleNextPoll = (taskId: string) => { + const trackedTask = trackedTasks.get(taskId); + if (!trackedTask || cancelled) { + return; + } + if (trackedTask.timerId !== null) { + window.clearTimeout(trackedTask.timerId); + } + trackedTask.timerId = window.setTimeout(() => { + trackedTask.timerId = null; + void syncTaskFile(taskId); + }, IMAGE_TASK_POLL_INTERVAL_MS); + }; + + const syncTaskFile = async (taskId: string) => { + const trackedTask = trackedTasks.get(taskId); + if (!trackedTask || trackedTask.polling || cancelled) { + return; + } + + trackedTask.polling = true; + + try { + const preview = await readFilePreview( + trackedTask.absolutePath, + IMAGE_TASK_FILE_PREVIEW_MAX_SIZE, + ); + if (cancelled || !trackedTasks.has(taskId)) { + return; + } + + if (preview.error || !preview.content?.trim()) { + scheduleNextPoll(taskId); + return; + } + + const parsed = JSON.parse(preview.content) as Record; + const snapshot = buildParsedImageTaskSnapshot({ + taskRecord: parsed, + taskId: trackedTask.taskId, + taskType: trackedTask.taskType, + projectId: runtimeContextRef.current.projectId, + contentId: runtimeContextRef.current.contentId, + canvasState: runtimeContextRef.current.canvasState, + }); + if (!snapshot) { + scheduleNextPoll(taskId); + return; + } + + setChatMessages((previous) => + upsertPreviewMessage(previous, snapshot.message), + ); + updateCurrentImageWorkbenchState((current) => + mergeImageTaskSnapshot(current, snapshot), + ); + + if (snapshot.terminal) { + trackedTasks.delete(taskId); + return; + } + + scheduleNextPoll(taskId); + } catch { + scheduleNextPoll(taskId); + } finally { + const trackedTaskAfterSync = trackedTasks.get(taskId); + if (trackedTaskAfterSync) { + trackedTaskAfterSync.polling = false; + } + } + }; + + const restoreTrackedTasksFromWorkspace = async () => { + const currentProjectRootPath = runtimeContextRef.current.projectRootPath?.trim(); + if (!currentProjectRootPath || cancelled) { + return; + } + + const candidatePaths = await collectImageTaskCandidatePaths( + currentProjectRootPath, + ); + if (cancelled || candidatePaths.length === 0) { + return; + } + + const restoredSnapshots: Array<{ + snapshot: ParsedImageTaskSnapshot; + absolutePath: string; + taskType: string; + taskFamily: string; + }> = []; + const seenTaskIds = new Set(); + + for (const candidatePath of candidatePaths) { + try { + const preview = await readFilePreview( + candidatePath, + IMAGE_TASK_FILE_PREVIEW_MAX_SIZE, + ); + if (cancelled || preview.error || !preview.content?.trim()) { + continue; + } + + const parsed = JSON.parse(preview.content) as Record; + if ( + !shouldRestoreImageTaskRecord({ + taskRecord: parsed, + sessionId: runtimeContextRef.current.sessionId, + projectId: runtimeContextRef.current.projectId, + contentId: runtimeContextRef.current.contentId, + }) + ) { + continue; + } + + const taskId = readString([parsed], ["task_id", "taskId"]); + const taskType = readString([parsed], ["task_type", "taskType"]); + const taskFamily = normalizeTaskFamily( + taskType || "", + readString([parsed], ["task_family", "taskFamily"]), + ); + if (!taskId || !taskType || taskFamily !== "image" || seenTaskIds.has(taskId)) { + continue; + } + + const snapshot = buildParsedImageTaskSnapshot({ + taskRecord: parsed, + taskId, + taskType, + projectId: runtimeContextRef.current.projectId, + contentId: runtimeContextRef.current.contentId, + canvasState: runtimeContextRef.current.canvasState, + }); + if (!snapshot) { + continue; + } + + seenTaskIds.add(taskId); + restoredSnapshots.push({ + snapshot, + absolutePath: candidatePath, + taskType, + taskFamily, + }); + } catch { + continue; + } + } + + if (cancelled || restoredSnapshots.length === 0) { + return; + } + + const selectedSnapshots = restoredSnapshots + .sort((left, right) => right.snapshot.updatedAt - left.snapshot.updatedAt) + .slice(0, IMAGE_TASK_RESTORE_LIMIT) + .reverse(); + + setChatMessages((previous) => + selectedSnapshots.reduce( + (messages, item) => upsertPreviewMessage(messages, item.snapshot.message), + previous, + ), + ); + updateCurrentImageWorkbenchState((current) => + selectedSnapshots.reduce( + (state, item) => mergeImageTaskSnapshot(state, item.snapshot), + current, + ), + ); + + for (const item of selectedSnapshots) { + if (item.snapshot.terminal) { + continue; + } + trackedTasks.set(item.snapshot.taskId, { + taskId: item.snapshot.taskId, + taskType: item.taskType, + taskFamily: item.taskFamily, + artifactPath: item.absolutePath, + absolutePath: item.absolutePath, + timerId: null, + polling: false, + }); + scheduleNextPoll(item.snapshot.taskId); + } + }; + + safeListen( + IMAGE_TASK_EVENT_NAME, + (event) => { + if (cancelled) { + return; + } + + const payload = event.payload || {}; + const taskId = payload.task_id?.trim(); + const taskType = payload.task_type?.trim(); + const taskFamily = normalizeTaskFamily(taskType || "", payload.task_family); + const matchesRuntimeContext = matchesRuntimeEventContext({ + payload, + sessionId: runtimeContextRef.current.sessionId, + projectId: runtimeContextRef.current.projectId, + contentId: runtimeContextRef.current.contentId, + }); + if (!matchesRuntimeContext) { + return; + } + const artifactPath = + payload.path?.trim() || payload.absolute_path?.trim() || ""; + const pendingSnapshot = + taskId && taskType && taskFamily === "image" + ? buildPendingImageTaskSnapshot({ + taskId, + taskType, + status: payload.status, + payload: { + prompt: payload.prompt, + size: payload.size, + mode: payload.mode, + raw_text: payload.raw_text, + count: + typeof payload.count === "number" ? payload.count : undefined, + session_id: payload.session_id, + project_id: payload.project_id, + content_id: payload.content_id, + entry_source: payload.entry_source, + requested_target: payload.requested_target, + }, + progressMessage: payload.reused_existing + ? "已复用现有图片任务,正在同步最新状态。" + : "任务已提交到异步队列,正在同步任务状态。", + projectId: + payload.project_id || runtimeContextRef.current.projectId, + contentId: + payload.content_id || runtimeContextRef.current.contentId, + canvasState: runtimeContextRef.current.canvasState, + }) + : null; + if (pendingSnapshot) { + setChatMessages((previous) => + upsertPreviewMessage(previous, pendingSnapshot.message), + ); + updateCurrentImageWorkbenchState((current) => + mergeImageTaskSnapshot(current, pendingSnapshot), + ); + } + const absolutePath = resolveAbsoluteWorkspacePath( + runtimeContextRef.current.projectRootPath, + payload.absolute_path?.trim() || artifactPath, + ); + + if (!taskId || !taskType || taskFamily !== "image" || !absolutePath) { + return; + } + + const previousTracked = trackedTasks.get(taskId); + if (previousTracked && previousTracked.timerId !== null) { + window.clearTimeout(previousTracked.timerId); + } + trackedTasks.set(taskId, { + taskId, + taskType, + taskFamily, + artifactPath, + absolutePath, + timerId: null, + polling: false, + }); + + void syncTaskFile(taskId); + }, + ) + .then((dispose) => { + if (cancelled) { + void dispose(); + return; + } + unlisten = dispose; + }) + .catch((error) => { + console.warn("[AgentChatPage] 监听图片任务事件失败:", error); + }); + + void restoreTrackedTasksFromWorkspace(); + + return () => { + cancelled = true; + trackedTasks.forEach((trackedTask) => { + if (trackedTask.timerId !== null) { + window.clearTimeout(trackedTask.timerId); + } + }); + trackedTasks.clear(); + if (unlisten) { + unlisten(); + } + }; + }, [ + projectRootPath, + sessionId, + setChatMessages, + updateCurrentImageWorkbenchState, + ]); +} diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx new file mode 100644 index 000000000..dd4f8485f --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx @@ -0,0 +1,223 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createInitialSessionImageWorkbenchState } from "./imageWorkbenchHelpers"; +import { useWorkspaceImageWorkbenchActionRuntime } from "./useWorkspaceImageWorkbenchActionRuntime"; + +const { toast } = vi.hoisted(() => ({ + toast: { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + }, +})); + +vi.mock("sonner", () => ({ + toast, +})); + +type HookProps = Parameters[0]; + +const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = []; + +function createParsedCommand() { + return { + rawText: "@配图 生成 城市夜景主视觉", + trigger: "@配图" as const, + body: "生成 城市夜景主视觉", + mode: "generate" as const, + prompt: "城市夜景主视觉", + count: 1, + size: "1024x1024", + aspectRatio: undefined, + targetRef: undefined, + }; +} + +function renderHook(props?: Partial) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let latestValue: ReturnType | null = + null; + + const defaultProps: HookProps = { + appendLocalDispatchMessages: vi.fn(), + contentId: null, + createImageGenerationTask: vi.fn().mockResolvedValue({ + task_id: "task-image-1", + task_type: "image_generate", + }), + currentImageWorkbenchState: createInitialSessionImageWorkbenchState(), + imageWorkbenchSelectedModelId: "fal-ai/nano-banana-pro", + imageWorkbenchSelectedProviderId: "fal", + imageWorkbenchSelectedSize: "1024x1024", + imageWorkbenchSessionKey: "session-1", + projectId: "project-1", + projectRootPath: "/workspace/project-1", + saveImageWorkbenchImagesToResource: vi.fn().mockResolvedValue({ + saved: 0, + skipped: 0, + errors: [], + }), + setCanvasState: vi.fn(), + setInput: vi.fn(), + setLayoutMode: vi.fn(), + setMentionedCharacters: vi.fn(), + updateCurrentImageWorkbenchState: vi.fn(), + }; + + function Probe(currentProps: HookProps) { + latestValue = useWorkspaceImageWorkbenchActionRuntime(currentProps); + return null; + } + + const render = async (nextProps?: Partial) => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ container, root }); + + return { + render, + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + }; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + toast.error.mockReset(); + toast.info.mockReset(); + toast.success.mockReset(); + toast.warning.mockReset(); +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +}); + +describe("useWorkspaceImageWorkbenchActionRuntime", () => { + it("应创建标准图片任务 artifact,并只先写入用户消息", async () => { + const appendLocalDispatchMessages = vi.fn(); + const createImageGenerationTask = vi.fn().mockResolvedValue({ + task_id: "task-image-1", + task_type: "image_generate", + }); + const setInput = vi.fn(); + const setMentionedCharacters = vi.fn(); + const setLayoutMode = vi.fn(); + const { render, getValue } = renderHook({ + appendLocalDispatchMessages, + createImageGenerationTask, + setInput, + setMentionedCharacters, + setLayoutMode, + }); + + await render(); + + let handled = false; + await act(async () => { + handled = await getValue().handleImageWorkbenchCommand({ + rawText: "@配图 生成 城市夜景主视觉", + parsedCommand: createParsedCommand(), + images: [], + }); + }); + + expect(handled).toBe(true); + expect(appendLocalDispatchMessages).toHaveBeenCalledTimes(1); + expect(appendLocalDispatchMessages.mock.calls[0]?.[0]).toEqual([ + expect.objectContaining({ + role: "user", + content: "@配图 生成 城市夜景主视觉", + }), + ]); + expect(createImageGenerationTask).toHaveBeenCalledWith({ + projectRootPath: "/workspace/project-1", + prompt: "城市夜景主视觉", + title: "城市夜景主视觉", + mode: "generate", + rawText: "@配图 生成 城市夜景主视觉", + size: "1024x1024", + aspectRatio: undefined, + count: 1, + usage: "claw-image-workbench", + providerId: "fal", + model: "fal-ai/nano-banana-pro", + sessionId: "session-1", + projectId: "project-1", + contentId: undefined, + entrySource: "at_image_command", + requestedTarget: "generate", + targetOutputId: undefined, + targetOutputRefId: undefined, + referenceImages: [], + }); + expect(setInput).toHaveBeenCalledWith(""); + expect(setMentionedCharacters).toHaveBeenCalledWith([]); + expect(setLayoutMode).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it("创建 task artifact 失败时应回填本地失败消息", async () => { + const appendLocalDispatchMessages = vi.fn(); + const createImageGenerationTask = vi + .fn() + .mockRejectedValue(new Error("图片服务暂不可用")); + const { render, getValue } = renderHook({ + appendLocalDispatchMessages, + createImageGenerationTask, + }); + + await render(); + + await act(async () => { + await getValue().handleImageWorkbenchCommand({ + rawText: "@配图 生成 城市夜景主视觉", + parsedCommand: createParsedCommand(), + images: [], + }); + }); + + expect(appendLocalDispatchMessages).toHaveBeenCalledTimes(2); + expect(appendLocalDispatchMessages.mock.calls[0]?.[0]).toEqual([ + expect.objectContaining({ + role: "user", + content: "@配图 生成 城市夜景主视觉", + }), + ]); + expect(appendLocalDispatchMessages.mock.calls[1]?.[0]).toEqual([ + expect.objectContaining({ + role: "assistant", + content: "图片任务创建失败:图片服务暂不可用", + isThinking: false, + }), + ]); + expect(toast.error).toHaveBeenCalledWith("图片服务暂不可用"); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts index 11beb3b7a..0517ed27f 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts @@ -7,15 +7,12 @@ import { import { toast } from "sonner"; import type { CanvasStateUnion } from "@/lib/workspace/workbenchCanvas"; import type { LayoutMode } from "@/lib/workspace/workbenchContract"; -import { IMAGE_GENERATION_CANCELED_MESSAGE } from "@/components/image-gen/useImageGen"; -import type { GeneratedImage } from "@/components/image-gen/types"; import type { Character } from "@/lib/api/memory"; +import type { CreateImageGenerationTaskArtifactRequest } from "@/lib/api/mediaTasks"; import { emitCanvasImageInsertRequest } from "@/lib/canvasImageInsertBus"; import type { Message, MessageImage } from "../types"; import { parseImageWorkbenchCommand } from "../utils/imageWorkbenchCommand"; import { - buildImageWorkbenchCompletionMessage, - buildImageWorkbenchDispatchMessages, collapseWhitespace, resolveImageWorkbenchActionLabel, type ImageWorkbenchApplyTarget, @@ -30,20 +27,17 @@ interface SaveImagesToResourceResult { interface UseWorkspaceImageWorkbenchActionRuntimeParams { appendLocalDispatchMessages: (messages: Message[]) => void; - cancelImageWorkbenchGeneration: () => void; contentId?: string | null; + createImageGenerationTask: ( + request: CreateImageGenerationTaskArtifactRequest, + ) => Promise; currentImageWorkbenchState: SessionImageWorkbenchState; + imageWorkbenchSelectedModelId?: string; + imageWorkbenchSelectedProviderId?: string; imageWorkbenchSelectedSize: string; imageWorkbenchSessionKey: string; projectId?: string; - runImageWorkbenchGeneration: ( - prompt: string, - options: { - imageCount?: number; - referenceImages?: string[]; - size?: string; - }, - ) => Promise; + projectRootPath?: string | null; saveImageWorkbenchImagesToResource: ( imageIds: string[], targetProjectId: string, @@ -59,15 +53,29 @@ interface UseWorkspaceImageWorkbenchActionRuntimeParams { ) => void; } +function dedupeReferenceImages(values: Array): string[] { + const normalized: string[] = []; + for (const value of values) { + const trimmed = value?.trim(); + if (!trimmed || normalized.includes(trimmed)) { + continue; + } + normalized.push(trimmed); + } + return normalized; +} + export function useWorkspaceImageWorkbenchActionRuntime({ appendLocalDispatchMessages, - cancelImageWorkbenchGeneration, contentId, + createImageGenerationTask, currentImageWorkbenchState, + imageWorkbenchSelectedModelId, + imageWorkbenchSelectedProviderId, imageWorkbenchSelectedSize, imageWorkbenchSessionKey, projectId, - runImageWorkbenchGeneration, + projectRootPath, saveImageWorkbenchImagesToResource, setCanvasState, setInput, @@ -114,22 +122,8 @@ export function useWorkspaceImageWorkbenchActionRuntime({ }, []); const handleStopImageWorkbenchGeneration = useCallback(() => { - cancelImageWorkbenchGeneration(); - updateCurrentImageWorkbenchState((current) => ({ - ...current, - active: true, - tasks: current.tasks.map((task) => - task.status === "routing" || task.status === "running" - ? { - ...task, - status: "error", - failureMessage: IMAGE_GENERATION_CANCELED_MESSAGE, - } - : task, - ), - })); - toast.info(IMAGE_GENERATION_CANCELED_MESSAGE); - }, [cancelImageWorkbenchGeneration, updateCurrentImageWorkbenchState]); + toast.info("异步图片任务已进入队列,当前版本暂不支持前端直接取消"); + }, []); const handleSaveSelectedImageWorkbenchOutput = useCallback(async () => { const selectedOutput = currentImageWorkbenchState.outputs.find( @@ -277,6 +271,10 @@ export function useWorkspaceImageWorkbenchActionRuntime({ toast.error("请先选择项目后再开始配图"); return false; } + if (!projectRootPath?.trim()) { + toast.error("当前项目目录未就绪,暂时无法创建图片任务"); + return false; + } const { rawText, parsedCommand, images } = params; const targetOutput = parsedCommand.targetRef @@ -308,211 +306,82 @@ export function useWorkspaceImageWorkbenchActionRuntime({ return false; } - const taskId = `image-task-${Date.now()}-${Math.random() + const localDispatchId = `image-task-dispatch-${Date.now()}-${Math.random() .toString(36) .slice(2, 8)}`; - const referenceImages = [ - ...(targetOutput?.url ? [targetOutput.url] : []), - ...images.map((image) => image.data).filter(Boolean), - ]; - const now = Date.now(); + const referenceImages = dedupeReferenceImages([ + targetOutput?.url, + ...images.map((image) => image.data), + ]); + const requestedTarget = + effectiveApplyTarget?.kind === "document-cover" ? "cover" : "generate"; - updateCurrentImageWorkbenchState((current) => ({ - ...current, - active: true, - tasks: [ - { - sessionId: imageWorkbenchSessionKey, - id: taskId, - mode: parsedCommand.mode, - status: "routing", - prompt: effectivePrompt, - rawText, - expectedCount: parsedCommand.count, - outputIds: [], - targetOutputId: targetOutput?.id ?? null, - createdAt: now, - hookImageIds: [], - applyTarget: effectiveApplyTarget, - }, - ...current.tasks, - ], - selectedOutputId: targetOutput?.id ?? current.selectedOutputId, - })); + appendLocalDispatchMessages([ + { + id: `image-workbench:${localDispatchId}:user`, + role: "user", + content: rawText, + images: images.length > 0 ? images : undefined, + timestamp: new Date(), + }, + ]); - appendLocalDispatchMessages( - buildImageWorkbenchDispatchMessages({ - rawText, - images, - taskId, - prompt: effectivePrompt, - mode: parsedCommand.mode, - count: parsedCommand.count, - }), - ); - - setLayoutMode("chat-canvas"); setInput(""); setMentionedCharacters([]); - updateCurrentImageWorkbenchState((current) => ({ - ...current, - active: true, - tasks: current.tasks.map((task) => - task.id === taskId ? { ...task, status: "running" } : task, - ), - })); - try { - const generatedImages = await runImageWorkbenchGeneration( - effectivePrompt, - { - imageCount: parsedCommand.count, - referenceImages, - size: parsedCommand.size || imageWorkbenchSelectedSize, - }, - ); - const hookImageIds = generatedImages.map((image) => image.id); - - let successCount = 0; - updateCurrentImageWorkbenchState((current) => { - let nextOutputIndex = current.nextOutputIndex; - const nextOutputs = [...current.outputs]; - const createdOutputIds: string[] = []; - - for (const image of generatedImages) { - if (image.status !== "complete" || !image.url) { - continue; - } - - successCount += 1; - const outputId = `${taskId}:${image.id}`; - const refId = `img-${nextOutputIndex}`; - nextOutputIndex += 1; - nextOutputs.unshift({ - id: outputId, - taskId, - hookImageId: image.id, - refId, - url: image.url, - prompt: image.prompt, - createdAt: image.createdAt, - providerName: image.providerName, - modelName: image.model, - size: image.size, - parentOutputId: targetOutput?.refId ?? null, - resourceSaved: Boolean(image.resourceMaterialId), - applyTarget: effectiveApplyTarget, - }); - createdOutputIds.push(outputId); - } - - const failedCount = Math.max(0, parsedCommand.count - successCount); - const nextStatus = - successCount === 0 - ? "error" - : failedCount > 0 - ? "partial" - : "complete"; - - return { - ...current, - active: true, - outputs: nextOutputs, - selectedOutputId: - createdOutputIds[0] || - current.selectedOutputId || - targetOutput?.id || - null, - nextOutputIndex, - tasks: current.tasks.map((task) => - task.id === taskId - ? { - ...task, - status: nextStatus, - outputIds: createdOutputIds, - hookImageIds, - failureMessage: - successCount === 0 - ? "图片服务未返回可用结果" - : failedCount > 0 - ? `有 ${failedCount} 张结果生成失败` - : undefined, - } - : task, - ), - }; + await createImageGenerationTask({ + projectRootPath: projectRootPath.trim(), + prompt: effectivePrompt, + title: effectivePrompt, + mode: parsedCommand.mode, + rawText, + size: parsedCommand.size || imageWorkbenchSelectedSize, + aspectRatio: parsedCommand.aspectRatio, + count: parsedCommand.count, + usage: + requestedTarget === "cover" ? "cover" : "claw-image-workbench", + providerId: imageWorkbenchSelectedProviderId, + model: imageWorkbenchSelectedModelId, + sessionId: imageWorkbenchSessionKey, + projectId, + contentId: contentId ?? undefined, + entrySource: "at_image_command", + requestedTarget, + targetOutputId: targetOutput?.id ?? undefined, + targetOutputRefId: targetOutput?.refId ?? undefined, + referenceImages, }); - - appendLocalDispatchMessages([ - buildImageWorkbenchCompletionMessage({ - taskId, - successCount, - failedCount: Math.max(0, parsedCommand.count - successCount), - mode: parsedCommand.mode, - }), - ]); - - if (successCount === 0) { - toast.error("图片任务失败,未生成可用结果"); - } else if (parsedCommand.count - successCount > 0) { - toast.warning( - `图片任务已完成 ${successCount} 张,失败 ${Math.max( - 0, - parsedCommand.count - successCount, - )} 张`, - ); - } else { - toast.success(`图片任务已完成,共生成 ${successCount} 张`); - } return true; } catch (error) { const failureMessage = - error instanceof Error ? error.message : "图片任务执行失败"; - const canceled = failureMessage === IMAGE_GENERATION_CANCELED_MESSAGE; - updateCurrentImageWorkbenchState((current) => ({ - ...current, - active: true, - tasks: current.tasks.map((task) => - task.id === taskId - ? { - ...task, - status: "error", - failureMessage, - } - : task, - ), - })); - if (!canceled) { - appendLocalDispatchMessages([ - { - id: `image-workbench:${taskId}:failed`, - role: "assistant", - content: `当前图片任务失败:${failureMessage}`, - timestamp: new Date(), - runtimeStatus: { - phase: "failed", - title: "图片任务失败", - detail: failureMessage, - }, - }, - ]); - toast.error(failureMessage); - } + error instanceof Error ? error.message : "图片任务创建失败"; + appendLocalDispatchMessages([ + { + id: `image-workbench:${localDispatchId}:assistant-error`, + role: "assistant", + content: `图片任务创建失败:${failureMessage}`, + timestamp: new Date(), + isThinking: false, + }, + ]); + toast.error(failureMessage); return true; } }, [ appendLocalDispatchMessages, + createImageGenerationTask, + contentId, currentImageWorkbenchState.outputs, + imageWorkbenchSelectedModelId, + imageWorkbenchSelectedProviderId, imageWorkbenchSelectedSize, imageWorkbenchSessionKey, projectId, - runImageWorkbenchGeneration, + projectRootPath, setInput, - setLayoutMode, setMentionedCharacters, - updateCurrentImageWorkbenchState, ], ); diff --git a/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchScaffoldRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchScaffoldRuntime.test.tsx new file mode 100644 index 000000000..45677cf4e --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceThemeWorkbenchScaffoldRuntime.test.tsx @@ -0,0 +1,184 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { safeListen } from "@/lib/dev-bridge"; +import { useWorkspaceThemeWorkbenchScaffoldRuntime } from "./useWorkspaceThemeWorkbenchScaffoldRuntime"; + +const mockCreateInitialCanvasState = vi.hoisted(() => vi.fn(() => null)); +const mockCreateInitialDocumentState = vi.hoisted(() => + vi.fn(() => ({ + type: "document", + currentVersionId: null, + versions: [], + })), +); +const mockUseTopicBranchBoard = vi.hoisted(() => + vi.fn(() => ({ + branchItems: [], + setTopicStatus: vi.fn(), + })), +); + +vi.mock("@/lib/dev-bridge", () => ({ + safeListen: vi.fn(), +})); + +vi.mock("@/lib/workspace/workbenchCanvas", () => ({ + createInitialCanvasState: mockCreateInitialCanvasState, + createInitialDocumentState: mockCreateInitialDocumentState, +})); + +vi.mock("../hooks", () => ({ + useTopicBranchBoard: mockUseTopicBranchBoard, +})); + +type HookProps = Parameters< + typeof useWorkspaceThemeWorkbenchScaffoldRuntime +>[0]; +type CreationTaskListener = Parameters[1]; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function renderHook(props?: Partial) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let latestValue: ReturnType< + typeof useWorkspaceThemeWorkbenchScaffoldRuntime + > | null = null; + + const defaultProps: HookProps = { + isThemeWorkbench: true, + mappedTheme: "social-media", + sessionId: "session-theme-1", + projectId: "project-theme-1", + canvasState: null, + documentVersionStatusMap: {}, + setDocumentVersionStatusMap: vi.fn(), + clearThemeSkillsRailState: vi.fn(), + setCanvasState: vi.fn(), + setLayoutMode: vi.fn(), + }; + + function Probe(currentProps: HookProps) { + latestValue = useWorkspaceThemeWorkbenchScaffoldRuntime(currentProps); + return null; + } + + const render = async (nextProps?: Partial) => { + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ root, container }); + + return { + render, + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + }; +} + +describe("useWorkspaceThemeWorkbenchScaffoldRuntime", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.clearAllMocks(); + vi.mocked(safeListen).mockResolvedValue(vi.fn()); + mockCreateInitialCanvasState.mockReturnValue(null); + mockCreateInitialDocumentState.mockReturnValue({ + type: "document", + currentVersionId: null, + versions: [], + }); + mockUseTopicBranchBoard.mockReturnValue({ + branchItems: [], + setTopicStatus: vi.fn(), + }); + }); + + afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); + }); + + it("应接收并去重 creation_task_submitted 事件", async () => { + let listener: CreationTaskListener | null = null; + vi.mocked(safeListen).mockImplementationOnce(async (event, handler) => { + expect(event).toBe("lime://creation_task_submitted"); + listener = handler; + return vi.fn(); + }); + + const { render, getValue } = renderHook(); + await render(); + + expect(safeListen).toHaveBeenCalledWith( + "lime://creation_task_submitted", + expect.any(Function), + ); + + await act(async () => { + listener?.({ + payload: { + task_id: "task-cover-1", + task_type: "cover_generate", + path: " .lime/tasks/cover_generate/demo.json ", + absolute_path: " /tmp/demo.json ", + }, + }); + await Promise.resolve(); + }); + + expect(getValue().themeWorkbenchCreationTaskEvents).toEqual([ + expect.objectContaining({ + taskId: "task-cover-1", + taskType: "cover_generate", + path: ".lime/tasks/cover_generate/demo.json", + absolutePath: "/tmp/demo.json", + }), + ]); + + await act(async () => { + listener?.({ + payload: { + task_id: "task-cover-1", + task_type: "cover_generate", + path: ".lime/tasks/cover_generate/demo.json", + absolute_path: "/tmp/demo.json", + }, + }); + listener?.({ + payload: { + task_id: " ", + task_type: "cover_generate", + path: ".lime/tasks/cover_generate/ignored.json", + }, + }); + await Promise.resolve(); + }); + + expect(getValue().themeWorkbenchCreationTaskEvents).toHaveLength(1); + }); +}); diff --git a/src/components/clients/AppTabs.test.tsx b/src/components/clients/AppTabs.test.tsx new file mode 100644 index 000000000..adbdd8f64 --- /dev/null +++ b/src/components/clients/AppTabs.test.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { AppTabs } from "./AppTabs"; + +describe("clients/AppTabs", () => { + it("应展示 current 的 Claude 标签,不再回流旧品牌名称", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Claude"); + expect(html).toContain("Claude 配置"); + expect(html).not.toContain("Claude Code"); + }); +}); diff --git a/src/components/clients/AppTabs.tsx b/src/components/clients/AppTabs.tsx index d2424b5eb..a0de1bc98 100644 --- a/src/components/clients/AppTabs.tsx +++ b/src/components/clients/AppTabs.tsx @@ -15,8 +15,8 @@ const apps: { }[] = [ { id: "claude", - label: "Claude Code", - description: "Claude CLI 配置", + label: "Claude", + description: "Claude 配置", iconType: "claude", }, { diff --git a/src/components/clients/ProviderForm.tsx b/src/components/clients/ProviderForm.tsx index d758c9ba4..e62f71a04 100644 --- a/src/components/clients/ProviderForm.tsx +++ b/src/components/clients/ProviderForm.tsx @@ -373,11 +373,11 @@ export function ProviderForm({ const generateJsonFromFields = useCallback(() => { const env: Record = {}; if (apiKey) { - // Claude Code 只需要 ANTHROPIC_API_KEY,不需要 OPENAI_API_KEY + // Claude 只需要 ANTHROPIC_API_KEY,不需要 OPENAI_API_KEY env.ANTHROPIC_API_KEY = apiKey; } if (baseUrl) { - // Claude Code 只需要 ANTHROPIC_BASE_URL,不需要 OPENAI_BASE_URL + // Claude 只需要 ANTHROPIC_BASE_URL,不需要 OPENAI_BASE_URL env.ANTHROPIC_BASE_URL = baseUrl; } if (primaryModel) env.ANTHROPIC_MODEL = primaryModel; diff --git a/src/components/mcp/McpPage.tsx b/src/components/mcp/McpPage.tsx index bc9060d12..a7f6a81e9 100644 --- a/src/components/mcp/McpPage.tsx +++ b/src/components/mcp/McpPage.tsx @@ -277,7 +277,7 @@ export function McpPage({ hideHeader = false }: McpPageProps) { onClick={() => handleImport("claude")} className="w-full px-3 py-1.5 text-left text-sm hover:bg-muted" > - 从 Claude Code + 从 Claude
diff --git a/src/components/mcp/McpToolCaller.tsx b/src/components/mcp/McpToolCaller.tsx index 219ef4399..4755fc38b 100644 --- a/src/components/mcp/McpToolCaller.tsx +++ b/src/components/mcp/McpToolCaller.tsx @@ -9,7 +9,12 @@ import { useState } from "react"; import { Play, X, AlertCircle, CheckCircle } from "lucide-react"; import { cn } from "@/lib/utils"; -import { McpToolDefinition, McpToolResult, McpContent } from "@/lib/api/mcp"; +import { + getMcpInnerToolName, + McpToolDefinition, + McpToolResult, + McpContent, +} from "@/lib/api/mcp"; interface McpToolCallerProps { tool: McpToolDefinition; @@ -75,6 +80,7 @@ export function McpToolCaller({ onCallTool, onClose, }: McpToolCallerProps) { + const displayName = getMcpInnerToolName(tool.name, tool.server_name); const fields = extractFields(tool.input_schema); const [args, setArgs] = useState>({}); const [jsonMode, setJsonMode] = useState(false); @@ -120,7 +126,9 @@ export function McpToolCaller({
- {tool.name} + + {displayName} + ({tool.server_name}) diff --git a/src/components/mcp/McpToolsBrowser.test.tsx b/src/components/mcp/McpToolsBrowser.test.tsx new file mode 100644 index 000000000..dd492e5f6 --- /dev/null +++ b/src/components/mcp/McpToolsBrowser.test.tsx @@ -0,0 +1,140 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { McpToolDefinition } from "@/lib/api/mcp"; +import { McpToolsBrowser } from "./McpToolsBrowser"; + +interface RenderResult { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: RenderResult[] = []; + +function createTool( + overrides: Partial = {}, +): McpToolDefinition { + return { + name: "mcp__demo__search_docs", + description: "搜索文档", + input_schema: { type: "object" }, + server_name: "demo", + ...overrides, + }; +} + +async function renderBrowser( + props: Partial> = {}, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const defaultProps: React.ComponentProps = { + tools: [], + loading: false, + onRefresh: vi.fn(async () => undefined), + }; + + await act(async () => { + root.render(); + await Promise.resolve(); + await Promise.resolve(); + }); + + mountedRoots.push({ container, root }); + return container; +} + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) { + break; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } +}); + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +describe("McpToolsBrowser", () => { + it("已配置但未启动服务器时,应给出进入运行状态的引导,而不是笼统提示无工具", async () => { + const onOpenRuntimeTab = vi.fn(); + const container = await renderBrowser({ + serverCount: 2, + runningServerCount: 0, + onOpenRuntimeTab, + }); + + expect(container.textContent).toContain( + "已配置服务器,但当前没有运行中的 MCP 服务器", + ); + expect(container.textContent).toContain("去启动服务器"); + + const button = Array.from(container.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.includes("去启动服务器"), + ); + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(onOpenRuntimeTab).toHaveBeenCalledTimes(1); + }); + + it("未配置服务器时,应引导去配置管理", async () => { + const onOpenConfigTab = vi.fn(); + const container = await renderBrowser({ + serverCount: 0, + runningServerCount: 0, + onOpenConfigTab, + }); + + expect(container.textContent).toContain("还没有配置 MCP 服务器"); + expect(container.textContent).toContain("去配置管理"); + + const button = Array.from(container.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.includes("去配置管理"), + ); + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(onOpenConfigTab).toHaveBeenCalledTimes(1); + }); + + it("重复工具定义只应渲染一份,并默认展开服务器分组", async () => { + const container = await renderBrowser({ + serverCount: 1, + runningServerCount: 1, + tools: [ + createTool(), + createTool(), + createTool({ + name: "mcp__demo__read_docs", + description: "读取文档", + }), + ], + }); + + expect(container.textContent).toContain("(2)"); + expect(container.textContent).toContain("(2 个工具)"); + expect( + container.querySelectorAll('[title="mcp__demo__search_docs"]').length, + ).toBe(1); + expect( + container.querySelectorAll('[title="mcp__demo__read_docs"]').length, + ).toBe(1); + }); +}); diff --git a/src/components/mcp/McpToolsBrowser.tsx b/src/components/mcp/McpToolsBrowser.tsx index e1b303c5a..55d306a38 100644 --- a/src/components/mcp/McpToolsBrowser.tsx +++ b/src/components/mcp/McpToolsBrowser.tsx @@ -6,7 +6,7 @@ * @module components/mcp/McpToolsBrowser */ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Wrench, ChevronDown, @@ -16,12 +16,16 @@ import { Code, } from "lucide-react"; import { cn } from "@/lib/utils"; -import { McpToolDefinition } from "@/lib/api/mcp"; +import { getMcpInnerToolName, McpToolDefinition } from "@/lib/api/mcp"; interface McpToolsBrowserProps { tools: McpToolDefinition[]; loading: boolean; onRefresh: () => Promise; + serverCount?: number; + runningServerCount?: number; + onOpenRuntimeTab?: () => void; + onOpenConfigTab?: () => void; onCallTool?: ( toolName: string, args: Record, @@ -32,6 +36,10 @@ export function McpToolsBrowser({ tools, loading, onRefresh, + serverCount = 0, + runningServerCount = 0, + onOpenRuntimeTab, + onOpenConfigTab, onCallTool, }: McpToolsBrowserProps) { const [searchQuery, setSearchQuery] = useState(""); @@ -40,34 +48,116 @@ export function McpToolsBrowser({ ); const [expandedTools, setExpandedTools] = useState>(new Set()); - // 按服务器分组工具 - const toolsByServer = tools.reduce( - (acc, tool) => { - if (!acc[tool.server_name]) { - acc[tool.server_name] = []; + const dedupedTools = useMemo(() => { + const seen = new Set(); + return tools.filter((tool) => { + const key = `${tool.server_name}::${tool.name}`; + if (seen.has(key)) { + return false; } - acc[tool.server_name].push(tool); - return acc; - }, - {} as Record, + seen.add(key); + return true; + }); + }, [tools]); + + const toolsByServer = useMemo( + () => + dedupedTools.reduce( + (acc, tool) => { + if (!acc[tool.server_name]) { + acc[tool.server_name] = []; + } + acc[tool.server_name].push(tool); + return acc; + }, + {} as Record, + ), + [dedupedTools], ); - // 过滤工具 - const filteredToolsByServer = Object.entries(toolsByServer).reduce( - (acc, [serverName, serverTools]) => { - const filtered = serverTools.filter( - (tool) => - tool.name.toLowerCase().includes(searchQuery.toLowerCase()) || - tool.description.toLowerCase().includes(searchQuery.toLowerCase()), - ); - if (filtered.length > 0) { - acc[serverName] = filtered; - } - return acc; - }, - {} as Record, + const filteredToolsByServer = useMemo( + () => + Object.entries(toolsByServer).reduce( + (acc, [serverName, serverTools]) => { + const filtered = serverTools + .filter((tool) => { + const displayName = getMcpInnerToolName(tool.name, tool.server_name); + const normalizedQuery = searchQuery.toLowerCase(); + return ( + displayName.toLowerCase().includes(normalizedQuery) || + tool.name.toLowerCase().includes(normalizedQuery) || + tool.description.toLowerCase().includes(normalizedQuery) + ); + }) + .sort((left, right) => { + const leftName = getMcpInnerToolName(left.name, left.server_name); + const rightName = getMcpInnerToolName(right.name, right.server_name); + return leftName.localeCompare(rightName); + }); + if (filtered.length > 0) { + acc[serverName] = filtered; + } + return acc; + }, + {} as Record, + ), + [searchQuery, toolsByServer], ); + useEffect(() => { + const serverNames = Object.keys(filteredToolsByServer); + setExpandedServers((prev) => { + const next = new Set( + [...prev].filter((serverName) => serverNames.includes(serverName)), + ); + if (next.size > 0 || serverNames.length === 0) { + return next; + } + return new Set(serverNames); + }); + }, [filteredToolsByServer]); + + const emptyState = useMemo(() => { + if (searchQuery) { + return { + title: "未找到匹配的工具", + description: "可以尝试改用服务器名、工具名或描述关键词重新检索。", + }; + } + + if (serverCount === 0) { + return { + title: "还没有配置 MCP 服务器", + description: "先添加服务器配置,再回来浏览和调用工具。", + actionLabel: "去配置管理", + action: onOpenConfigTab, + }; + } + + if (runningServerCount === 0) { + return { + title: "已配置服务器,但当前没有运行中的 MCP 服务器", + description: "先在“运行状态”里启动服务器,工具目录才会加载出来。", + actionLabel: "去启动服务器", + action: onOpenRuntimeTab, + }; + } + + return { + title: "运行中的服务器暂未暴露工具", + description: "可以先刷新一次;如果仍为空,请检查服务器能力声明或连接日志。", + actionLabel: "刷新工具列表", + action: () => void onRefresh(), + }; + }, [ + onOpenConfigTab, + onOpenRuntimeTab, + onRefresh, + runningServerCount, + searchQuery, + serverCount, + ]); + const toggleServer = (serverName: string) => { const newExpanded = new Set(expandedServers); if (newExpanded.has(serverName)) { @@ -88,139 +178,150 @@ export function McpToolsBrowser({ setExpandedTools(newExpanded); }; - // 格式化 JSON Schema const formatSchema = (schema: Record) => { return JSON.stringify(schema, null, 2); }; return ( -
- {/* 标题栏 */} -
+
+
可用工具 - ({tools.length}) + ({dedupedTools.length})
- {/* 搜索框 */} -
+
- + setSearchQuery(e.target.value)} placeholder="搜索工具..." - className="w-full pl-8 pr-3 py-1.5 rounded border bg-background text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none" + className="w-full rounded border bg-background py-1.5 pl-8 pr-3 text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" />
- {/* 工具列表 */}
- {loading && tools.length === 0 ? ( + {loading && dedupedTools.length === 0 ? (
) : Object.keys(filteredToolsByServer).length === 0 ? ( -
- {searchQuery ? ( -

未找到匹配的工具

- ) : ( -

暂无可用工具,请先启动 MCP 服务器

- )} +
+
+ +
+

{emptyState.title}

+

{emptyState.description}

+ {emptyState.actionLabel && emptyState.action ? ( + + ) : null}
) : ( -
+
{Object.entries(filteredToolsByServer).map( ([serverName, serverTools]) => ( -
- {/* 服务器标题 */} +
- {/* 工具列表 */} {expandedServers.has(serverName) && (
- {serverTools.map((tool) => ( -
- {/* 工具标题 */} - + {serverTools.map((tool) => { + const displayName = getMcpInnerToolName( + tool.name, + tool.server_name, + ); - {/* 工具详情 */} - {expandedTools.has(tool.name) && ( -
-
-
- - 输入参数 Schema + return ( +
+ - )}
-
-                                  {formatSchema(tool.input_schema)}
-                                
+ {tool.description && ( +

+ {tool.description} +

+ )}
-
- )} -
- ))} + + + {expandedTools.has(tool.name) && ( +
+
+
+ + 输入参数 Schema + + {onCallTool && ( + + )} +
+
+                                    {formatSchema(tool.input_schema)}
+                                  
+
+
+ )} +
+ ); + })}
)}
diff --git a/src/components/onboarding/constants.test.ts b/src/components/onboarding/constants.test.ts new file mode 100644 index 000000000..41ee038a0 --- /dev/null +++ b/src/components/onboarding/constants.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { onboardingPlugins, userProfiles } from "./constants"; + +describe("onboarding constants", () => { + it("开发者引导与配置管理描述应使用 current 品牌表述", () => { + const developerProfile = userProfiles.find((item) => item.id === "developer"); + const configSwitchPlugin = onboardingPlugins.find( + (item) => item.id === "config-switch", + ); + + expect(developerProfile?.description).toContain("Claude、Codex、Gemini"); + expect(developerProfile?.description).not.toContain("Claude Code"); + expect(configSwitchPlugin?.description).toContain("Claude、Codex、Gemini"); + expect(configSwitchPlugin?.description).not.toContain("Claude Code"); + }); +}); diff --git a/src/components/onboarding/constants.ts b/src/components/onboarding/constants.ts index c8f0fc581..21da7e526 100644 --- a/src/components/onboarding/constants.ts +++ b/src/components/onboarding/constants.ts @@ -39,7 +39,7 @@ export const userProfiles: UserProfileConfig[] = [ { id: "developer", name: "程序员", - description: "使用 Claude Code、Codex、Gemini 等 AI 编程工具", + description: "使用 Claude、Codex、Gemini 等 AI 编程工具", icon: Code, defaultPlugins: ["config-switch"], }, @@ -59,7 +59,7 @@ export const onboardingPlugins: OnboardingPlugin[] = [ { id: "config-switch", name: "配置管理", - description: "一键切换 API 配置,支持 Claude Code、Codex、Gemini 等客户端", + description: "一键切换 API 配置,支持 Claude、Codex、Gemini 等客户端", icon: FileCode, downloadUrl: "https://github.com/aiclientproxy/config-switch/releases/latest/download/config-switch-plugin.zip", diff --git a/src/components/provider-pool/api-key/AddCustomProviderModal.tsx b/src/components/provider-pool/api-key/AddCustomProviderModal.tsx index 3b8a355db..26a3f5daf 100644 --- a/src/components/provider-pool/api-key/AddCustomProviderModal.tsx +++ b/src/components/provider-pool/api-key/AddCustomProviderModal.tsx @@ -209,7 +209,7 @@ const SPECIAL_PROTOCOL_PROVIDER_SEEDS = [ }, { id: "claude-code", - name: "Claude Code", + name: "Claude", type: "anthropic", apiHost: "https://api.anthropic.com", keywords: ["claude", "anthropic", "claude-code", "claude code"], diff --git a/src/components/provider-pool/api-key/AddCustomProviderModal.ui.test.tsx b/src/components/provider-pool/api-key/AddCustomProviderModal.ui.test.tsx index 17ca4b825..ca6e345d2 100644 --- a/src/components/provider-pool/api-key/AddCustomProviderModal.ui.test.tsx +++ b/src/components/provider-pool/api-key/AddCustomProviderModal.ui.test.tsx @@ -172,7 +172,7 @@ describe("AddCustomProviderModal", () => { expect(hostInput.value).toBe("https://open.bigmodel.cn/api/paas/v4/"); }); - it("应注入 Codex CLI、Gemini CLI 与 Claude Code 特例模板", async () => { + it("应注入 Codex CLI、Gemini CLI 与 Claude 特例模板", async () => { renderModal(); await settleModal(); diff --git a/src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.test.tsx b/src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.test.tsx new file mode 100644 index 000000000..3e202c89e --- /dev/null +++ b/src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.test.tsx @@ -0,0 +1,205 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockGetConfig, + mockSaveConfig, + mockGetProviders, + mockSubscribeProviderDataChanged, +} = vi.hoisted(() => ({ + mockGetConfig: vi.fn(), + mockSaveConfig: vi.fn(), + mockGetProviders: vi.fn(), + mockSubscribeProviderDataChanged: vi.fn(), +})); + +vi.mock("@/lib/api/appConfig", () => ({ + getConfig: (...args: unknown[]) => mockGetConfig(...args), + saveConfig: (...args: unknown[]) => mockSaveConfig(...args), +})); + +vi.mock("@/lib/api/apiKeyProvider", () => ({ + apiKeyProviderApi: { + getProviders: (...args: unknown[]) => mockGetProviders(...args), + }, +})); + +vi.mock("@/lib/providerDataEvents", () => ({ + subscribeProviderDataChanged: (...args: unknown[]) => + mockSubscribeProviderDataChanged(...args), +})); + +import { CompanionCapabilityPreferencesCard } from "./CompanionCapabilityPreferencesCard"; + +interface MountedCard { + container: HTMLDivElement; + root: Root; +} + +const mounted: MountedCard[] = []; + +function renderCard() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + mounted.push({ container, root }); + return container; +} + +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function findButton(container: HTMLElement, text: string) { + const button = Array.from(container.querySelectorAll("button")).find((item) => + item.textContent?.includes(text), + ); + + if (!button) { + throw new Error(`未找到按钮: ${text}`); + } + + return button as HTMLButtonElement; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + vi.useFakeTimers(); + vi.clearAllMocks(); + + mockGetConfig.mockResolvedValue({ + workspace_preferences: { + companion_defaults: { + general: { + preferredProviderId: "deepseek", + preferredModelId: "deepseek-chat", + allowFallback: false, + }, + tts: { + preferredProviderId: "openai-tts", + preferredModelId: "gpt-4o-mini-tts", + allowFallback: true, + }, + }, + }, + }); + mockSaveConfig.mockResolvedValue(undefined); + mockGetProviders.mockResolvedValue([ + { + id: "deepseek", + name: "DeepSeek", + type: "openai", + api_host: "https://api.deepseek.com/v1", + is_system: false, + group: "cloud", + enabled: true, + sort_order: 1, + custom_models: ["deepseek-chat", "deepseek-reasoner"], + api_key_count: 1, + api_keys: [ + { + id: "key-deepseek-1", + provider_id: "deepseek", + api_key_masked: "sk-***1234", + enabled: true, + usage_count: 0, + error_count: 0, + created_at: "2026-04-02T00:00:00Z", + }, + ], + created_at: "2026-04-02T00:00:00Z", + updated_at: "2026-04-02T00:00:00Z", + }, + { + id: "openai-tts", + name: "OpenAI TTS", + type: "openai", + api_host: "https://api.openai.com/v1", + is_system: false, + group: "cloud", + enabled: true, + sort_order: 2, + custom_models: ["gpt-4o-mini-tts"], + api_key_count: 1, + api_keys: [ + { + id: "key-openai-tts-1", + provider_id: "openai-tts", + api_key_masked: "sk-***5678", + enabled: true, + usage_count: 0, + error_count: 0, + created_at: "2026-04-02T00:00:00Z", + }, + ], + created_at: "2026-04-02T00:00:00Z", + updated_at: "2026-04-02T00:00:00Z", + }, + ]); + mockSubscribeProviderDataChanged.mockReturnValue(vi.fn()); +}); + +afterEach(() => { + while (mounted.length > 0) { + const current = mounted.pop(); + if (!current) { + break; + } + + act(() => { + current.root.unmount(); + }); + current.container.remove(); + } + + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe("CompanionCapabilityPreferencesCard", () => { + it("应展示桌宠通用模型与语音播报偏好说明", async () => { + const container = renderCard(); + await flushEffects(); + + expect(container.textContent).toContain("桌宠能力偏好"); + expect(container.textContent).toContain("桌宠通用模型"); + expect(container.textContent).toContain("桌宠语音播报"); + expect(container.textContent).toContain("最近当前 provider/model"); + }); + + it("恢复通用默认时应清空 companion_defaults.general", async () => { + const container = renderCard(); + await flushEffects(); + + await act(async () => { + findButton(container, "恢复通用默认").click(); + await flushEffects(); + }); + + expect(mockSaveConfig).toHaveBeenCalledTimes(1); + const savedConfig = mockSaveConfig.mock.calls[0][0]; + expect( + savedConfig.workspace_preferences.companion_defaults.general, + ).toBeUndefined(); + expect(savedConfig.workspace_preferences.companion_defaults.tts).toEqual({ + preferredProviderId: "openai-tts", + preferredModelId: "gpt-4o-mini-tts", + allowFallback: true, + }); + expect(container.textContent).toContain("桌宠能力偏好已保存"); + }); +}); diff --git a/src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.tsx b/src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.tsx new file mode 100644 index 000000000..3e9924d12 --- /dev/null +++ b/src/components/settings-v2/agent/providers/CompanionCapabilityPreferencesCard.tsx @@ -0,0 +1,514 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { AlertCircle, Bot, CheckCircle2, Volume2 } from "lucide-react"; +import { + apiKeyProviderApi, + type ProviderWithKeysDisplay, +} from "@/lib/api/apiKeyProvider"; +import { + getConfig, + saveConfig, + type CompanionDefaultsConfig, + type Config, +} from "@/lib/api/appConfig"; +import { + canUseCompanionQuickActionProvider, + getCompanionDefaultsFromConfig, +} from "@/lib/companion/preferences"; +import { + buildPersistedMediaGenerationPreference, + getTtsModelsForProvider, + hasMediaGenerationPreferenceOverride, + isTtsProvider, + type MediaGenerationPreference, +} from "@/lib/mediaGeneration"; +import { subscribeProviderDataChanged } from "@/lib/providerDataEvents"; +import { cn } from "@/lib/utils"; +import { MediaPreferenceSection } from "../shared/MediaPreferenceSection"; + +const CARD_CLASS_NAME = + "rounded-[24px] border border-slate-200/80 bg-white p-5 shadow-sm shadow-slate-950/5"; +const DEFAULT_MEDIA_PREFERENCE: MediaGenerationPreference = { + allowFallback: true, +}; +const AUTO_VALUE = "__auto__"; + +type CompanionPreferenceKind = keyof CompanionDefaultsConfig; + +function PreferenceMessage(props: { + tone: "success" | "error"; + message: string; +}) { + return ( +
+ {props.tone === "success" ? ( + + ) : ( + + )} + {props.message} +
+ ); +} + +function buildUpdatedCompanionDefaults( + currentConfig: Config, + kind: CompanionPreferenceKind, + nextPreference: MediaGenerationPreference, +) { + const persistedPreference = + buildPersistedMediaGenerationPreference(nextPreference); + const nextCompanionDefaults: CompanionDefaultsConfig = { + ...getCompanionDefaultsFromConfig(currentConfig), + [kind]: persistedPreference, + }; + + if (!nextCompanionDefaults.general) { + delete nextCompanionDefaults.general; + } + if (!nextCompanionDefaults.tts) { + delete nextCompanionDefaults.tts; + } + + return { + ...currentConfig, + workspace_preferences: { + ...currentConfig.workspace_preferences, + companion_defaults: nextCompanionDefaults, + }, + }; +} + +function findProviderById( + providers: ProviderWithKeysDisplay[], + providerId?: string, +): ProviderWithKeysDisplay | null { + const normalizedProviderId = providerId?.trim().toLowerCase(); + if (!normalizedProviderId) { + return null; + } + + return ( + providers.find( + (provider) => provider.id.trim().toLowerCase() === normalizedProviderId, + ) ?? null + ); +} + +export function CompanionCapabilityPreferencesCard() { + const [config, setConfig] = useState(null); + const [providers, setProviders] = useState([]); + const [configLoading, setConfigLoading] = useState(true); + const [providersLoading, setProvidersLoading] = useState(true); + const [generalPreference, setGeneralPreference] = + useState(DEFAULT_MEDIA_PREFERENCE); + const [ttsPreference, setTtsPreference] = useState( + DEFAULT_MEDIA_PREFERENCE, + ); + const [savingKind, setSavingKind] = useState( + null, + ); + const [message, setMessage] = useState<{ + tone: "success" | "error"; + text: string; + } | null>(null); + const messageTimerRef = useRef(null); + + useEffect(() => { + return () => { + if (messageTimerRef.current !== null) { + window.clearTimeout(messageTimerRef.current); + } + }; + }, []); + + useEffect(() => { + let cancelled = false; + + const showMessage = (tone: "success" | "error", text: string) => { + if (messageTimerRef.current !== null) { + window.clearTimeout(messageTimerRef.current); + } + setMessage({ tone, text }); + messageTimerRef.current = window.setTimeout(() => { + setMessage(null); + messageTimerRef.current = null; + }, 3200); + }; + + const loadConfigState = async () => { + setConfigLoading(true); + try { + const nextConfig = await getConfig(); + if (cancelled) { + return; + } + const defaults = getCompanionDefaultsFromConfig(nextConfig); + setConfig(nextConfig); + setGeneralPreference(defaults.general ?? DEFAULT_MEDIA_PREFERENCE); + setTtsPreference(defaults.tts ?? DEFAULT_MEDIA_PREFERENCE); + } catch (error) { + if (!cancelled) { + showMessage( + "error", + `读取桌宠偏好失败:${error instanceof Error ? error.message : "未知错误"}`, + ); + } + } finally { + if (!cancelled) { + setConfigLoading(false); + } + } + }; + + const loadProvidersState = async (forceRefresh = false) => { + if (!cancelled) { + setProvidersLoading(true); + } + try { + const nextProviders = await apiKeyProviderApi.getProviders( + forceRefresh ? { forceRefresh: true } : undefined, + ); + if (!cancelled) { + setProviders(nextProviders); + } + } catch (error) { + if (!cancelled) { + showMessage( + "error", + `读取桌宠可用服务失败:${error instanceof Error ? error.message : "未知错误"}`, + ); + } + } finally { + if (!cancelled) { + setProvidersLoading(false); + } + } + }; + + void loadConfigState(); + void loadProvidersState(); + + const unsubscribeProviderData = subscribeProviderDataChanged(() => { + void loadProvidersState(true); + }); + + return () => { + cancelled = true; + unsubscribeProviderData(); + }; + }, []); + + const showMessage = (tone: "success" | "error", text: string) => { + if (messageTimerRef.current !== null) { + window.clearTimeout(messageTimerRef.current); + } + setMessage({ tone, text }); + messageTimerRef.current = window.setTimeout(() => { + setMessage(null); + messageTimerRef.current = null; + }, 3200); + }; + + const savePreference = async ( + kind: CompanionPreferenceKind, + nextPreference: MediaGenerationPreference, + ) => { + if (!config) { + return; + } + + setSavingKind(kind); + try { + const nextConfig = buildUpdatedCompanionDefaults( + config, + kind, + nextPreference, + ); + await saveConfig(nextConfig); + setConfig(nextConfig); + + if (kind === "general") { + setGeneralPreference(nextPreference); + } else { + setTtsPreference(nextPreference); + } + + showMessage("success", "桌宠能力偏好已保存"); + } catch (error) { + showMessage( + "error", + `保存桌宠偏好失败:${error instanceof Error ? error.message : "未知错误"}`, + ); + } finally { + setSavingKind(null); + } + }; + + const generalProviders = useMemo( + () => + providers.filter((provider) => + canUseCompanionQuickActionProvider(provider), + ), + [providers], + ); + const ttsProviders = useMemo( + () => + providers.filter( + (provider) => + provider.enabled && + provider.api_key_count > 0 && + isTtsProvider(provider.id, provider.type), + ), + [providers], + ); + + const selectedGeneralProvider = useMemo( + () => + findProviderById(generalProviders, generalPreference.preferredProviderId), + [generalPreference.preferredProviderId, generalProviders], + ); + const selectedTtsProvider = useMemo( + () => findProviderById(ttsProviders, ttsPreference.preferredProviderId), + [ttsPreference.preferredProviderId, ttsProviders], + ); + + const availableGeneralModels = useMemo( + () => selectedGeneralProvider?.custom_models ?? [], + [selectedGeneralProvider], + ); + const availableTtsModels = useMemo( + () => getTtsModelsForProvider(selectedTtsProvider?.custom_models), + [selectedTtsProvider], + ); + + const generalProviderUnavailableLabel = + generalPreference.preferredProviderId && !selectedGeneralProvider + ? `当前配置不可用:${generalPreference.preferredProviderId}` + : undefined; + const generalModelUnavailableLabel = + generalPreference.preferredModelId && + !availableGeneralModels.includes(generalPreference.preferredModelId) + ? `当前配置不可用:${generalPreference.preferredModelId}` + : undefined; + + const ttsProviderUnavailableLabel = + ttsPreference.preferredProviderId && !selectedTtsProvider + ? `当前配置不可用:${ttsPreference.preferredProviderId}` + : undefined; + const ttsModelUnavailableLabel = + ttsPreference.preferredModelId && + !availableTtsModels.includes(ttsPreference.preferredModelId) + ? `当前配置不可用:${ttsPreference.preferredModelId}` + : undefined; + + const handleGeneralProviderChange = (value: string) => { + const preferredProviderId = value === AUTO_VALUE ? undefined : value; + const nextProvider = findProviderById( + generalProviders, + preferredProviderId, + ); + const nextModels = nextProvider?.custom_models ?? []; + const preferredModelId = preferredProviderId + ? nextModels.includes(generalPreference.preferredModelId || "") + ? generalPreference.preferredModelId + : nextModels[0] + : undefined; + + void savePreference("general", { + preferredProviderId, + preferredModelId, + allowFallback: generalPreference.allowFallback ?? true, + }); + }; + + const handleTtsProviderChange = (value: string) => { + const preferredProviderId = value === AUTO_VALUE ? undefined : value; + const nextProvider = findProviderById(ttsProviders, preferredProviderId); + const nextModels = getTtsModelsForProvider(nextProvider?.custom_models); + const preferredModelId = preferredProviderId + ? nextModels.includes(ttsPreference.preferredModelId || "") + ? ttsPreference.preferredModelId + : nextModels[0] + : undefined; + + void savePreference("tts", { + preferredProviderId, + preferredModelId, + allowFallback: ttsPreference.allowFallback ?? true, + }); + }; + + return ( +
+
+
+
+
+ +
+
+

+ 桌宠能力偏好 +

+

+ 为 Lime 青柠精灵单独指定通用模型与 TTS + 服务。未设置桌宠专用通用模型时,双击鼓励、三击下一步建议会先回退最近当前 + provider/model,再回退自动可用服务商。 +

+
+
+
+
+

当前范围

+

通用模型已接入桌宠 quick action。

+

TTS 先落配置底座,后续用于桌宠朗读与语音播报。

+
+
+ + {message ? ( +
+ +
+ ) : null} + +
+ ({ + value: provider.id, + label: provider.name, + }))} + providerUnavailableLabel={generalProviderUnavailableLabel} + modelLabel="桌宠通用模型" + modelValue={generalPreference.preferredModelId ?? AUTO_VALUE} + modelAutoLabel="跟随当前模型" + onModelChange={(value) => + void savePreference("general", { + ...generalPreference, + preferredModelId: value === AUTO_VALUE ? undefined : value, + allowFallback: generalPreference.allowFallback ?? true, + }) + } + models={availableGeneralModels.map((model) => ({ + value: model, + label: model, + }))} + modelUnavailableLabel={generalModelUnavailableLabel} + modelHint="如果当前 Provider 没有维护支持的模型列表,可以先留空,让桌宠沿用当前会话模型。" + allowFallback={generalPreference.allowFallback ?? true} + onAllowFallbackChange={(value) => + void savePreference("general", { + ...generalPreference, + allowFallback: value, + }) + } + fallbackTitle="桌宠通用模型不可用时自动回退" + fallbackDescription="关闭后,若桌宠专用 Provider 缺失、被禁用或没有可用 Key,将直接提示错误,不再回退当前 provider 或自动可用服务。" + emptyHint={ + providersLoading + ? "正在加载桌宠可聊天服务..." + : generalProviders.length === 0 + ? "暂无可聊天 Provider,请先到服务商设置里配置至少一个可用聊天服务。" + : "留空时会先跟随当前对话的 provider/model,再回退自动选择可用服务。" + } + disabled={!config || configLoading || savingKind === "general"} + modelDisabled={ + providersLoading || + !generalPreference.preferredProviderId || + availableGeneralModels.length === 0 + } + onReset={() => + void savePreference("general", DEFAULT_MEDIA_PREFERENCE) + } + resetLabel="恢复通用默认" + resetDisabled={ + !hasMediaGenerationPreferenceOverride(generalPreference) + } + /> + + ({ + value: provider.id, + label: provider.name, + }))} + providerUnavailableLabel={ttsProviderUnavailableLabel} + modelLabel="桌宠 TTS 模型" + modelValue={ttsPreference.preferredModelId ?? AUTO_VALUE} + modelAutoLabel="自动选择" + onModelChange={(value) => + void savePreference("tts", { + ...ttsPreference, + preferredModelId: value === AUTO_VALUE ? undefined : value, + allowFallback: ttsPreference.allowFallback ?? true, + }) + } + models={availableTtsModels.map((model) => ({ + value: model, + label: model, + }))} + modelUnavailableLabel={ttsModelUnavailableLabel} + modelHint="桌宠后续做语音播报时会优先用这里的 Provider / 模型;未指定时自动匹配可用 TTS 服务。" + allowFallback={ttsPreference.allowFallback ?? true} + onAllowFallbackChange={(value) => + void savePreference("tts", { + ...ttsPreference, + allowFallback: value, + }) + } + fallbackTitle="桌宠 TTS 不可用时自动回退" + fallbackDescription="关闭后,若桌宠专用 TTS Provider 缺失或不可用,将直接提示错误,不再尝试其他语音服务。" + emptyHint={ + providersLoading + ? "正在加载桌宠语音服务..." + : ttsProviders.length === 0 + ? "暂无可用 TTS Provider,请先到服务商设置里配置语音 / TTS 服务。" + : "未指定时,桌宠会自动选择可用的 TTS Provider。" + } + disabled={!config || configLoading || savingKind === "tts"} + modelDisabled={providersLoading || !ttsPreference.preferredProviderId} + onReset={() => void savePreference("tts", DEFAULT_MEDIA_PREFERENCE)} + resetLabel="恢复 TTS 默认" + resetDisabled={!hasMediaGenerationPreferenceOverride(ttsPreference)} + /> +
+ +
+
+ + 回退说明 +
+

+ 通用模型优先级:桌宠专用配置 > 最近当前 provider/model > + 自动可用 Provider。 +

+

+ TTS 优先级:桌宠专用配置 > 自动可用 TTS + Provider。这样能先把桌宠专属能力和 Lime 主聊天链路拆开管理。 +

+
+
+ ); +} + +export default CompanionCapabilityPreferencesCard; diff --git a/src/components/settings-v2/agent/providers/index.test.tsx b/src/components/settings-v2/agent/providers/index.test.tsx index 5b978ec15..4ab3b7a54 100644 --- a/src/components/settings-v2/agent/providers/index.test.tsx +++ b/src/components/settings-v2/agent/providers/index.test.tsx @@ -9,8 +9,11 @@ const { mockLaunchCompanionPet, mockListenCompanionPetStatus, mockSendCompanionPetCommand, + mockApiKeyProviderGetProviders, mockProviderPoolGetOverview, mockSubscribeProviderDataChanged, + mockGetConfig, + mockSaveConfig, } = vi.hoisted(() => ({ mockUseOemCloudAccess: vi.fn(), mockFormatOemCloudDateTime: vi.fn((value?: string) => `fmt:${value ?? ""}`), @@ -18,12 +21,17 @@ const { mockLaunchCompanionPet: vi.fn(), mockListenCompanionPetStatus: vi.fn(), mockSendCompanionPetCommand: vi.fn(), + mockApiKeyProviderGetProviders: vi.fn(), mockProviderPoolGetOverview: vi.fn(), mockSubscribeProviderDataChanged: vi.fn(), + mockGetConfig: vi.fn(), + mockSaveConfig: vi.fn(), })); vi.mock("@/components/provider-pool", () => ({ - ProviderPoolPage: () =>
凭证池占位
, + ProviderPoolPage: () => ( +
凭证池占位
+ ), })); vi.mock("@/lib/api/companion", () => ({ @@ -41,6 +49,18 @@ vi.mock("@/lib/api/providerPool", () => ({ }, })); +vi.mock("@/lib/api/apiKeyProvider", () => ({ + apiKeyProviderApi: { + getProviders: (...args: unknown[]) => + mockApiKeyProviderGetProviders(...args), + }, +})); + +vi.mock("@/lib/api/appConfig", () => ({ + getConfig: (...args: unknown[]) => mockGetConfig(...args), + saveConfig: (...args: unknown[]) => mockSaveConfig(...args), +})); + vi.mock("@/lib/providerDataEvents", () => ({ subscribeProviderDataChanged: (...args: unknown[]) => mockSubscribeProviderDataChanged(...args), @@ -48,8 +68,7 @@ vi.mock("@/lib/providerDataEvents", () => ({ vi.mock("@/hooks/useOemCloudAccess", () => ({ useOemCloudAccess: () => mockUseOemCloudAccess(), - formatOemCloudDateTime: (value?: string) => - mockFormatOemCloudDateTime(value), + formatOemCloudDateTime: (value?: string) => mockFormatOemCloudDateTime(value), formatOemCloudAccessModeLabel: (value?: string) => value || "未知", formatOemCloudConfigModeLabel: (value?: string) => value || "未知", formatOemCloudModelsSourceLabel: (value?: string) => value || "未知", @@ -186,6 +205,42 @@ function createProviderOverview() { ]; } +function createApiKeyProviders() { + return [ + { + id: "deepseek", + name: "DeepSeek", + type: "openai", + api_host: "https://api.deepseek.com/v1", + is_system: false, + group: "cloud", + enabled: true, + sort_order: 5, + api_version: undefined, + project: undefined, + location: undefined, + region: undefined, + custom_models: [], + api_key_count: 1, + api_keys: [ + { + id: "key-deepseek-1", + provider_id: "deepseek", + api_key_masked: "sk-***1234", + alias: "主 Key", + enabled: true, + usage_count: 0, + error_count: 0, + last_used_at: undefined, + created_at: "2026-04-01T00:00:00Z", + }, + ], + created_at: "2026-04-01T00:00:00Z", + updated_at: "2026-04-01T00:00:00Z", + }, + ]; +} + async function renderPage(props: { onOpenProfile?: () => void } = {}) { const container = document.createElement("div"); document.body.appendChild(container); @@ -233,8 +288,26 @@ beforeEach(() => { delivered: true, connected: true, }); + mockApiKeyProviderGetProviders.mockResolvedValue(createApiKeyProviders()); mockProviderPoolGetOverview.mockResolvedValue(createProviderOverview()); mockSubscribeProviderDataChanged.mockReturnValue(vi.fn()); + mockGetConfig.mockResolvedValue({ + workspace_preferences: { + companion_defaults: { + general: { + preferredProviderId: "deepseek", + preferredModelId: "deepseek-chat", + allowFallback: false, + }, + tts: { + preferredProviderId: "openai-tts", + preferredModelId: "gpt-4o-mini-tts", + allowFallback: true, + }, + }, + }, + }); + mockSaveConfig.mockResolvedValue(undefined); }); afterEach(() => { @@ -270,6 +343,9 @@ describe("CloudProviderSettings", () => { const cloudTab = container.querySelector( '[data-testid="provider-workspace-tab-cloud"]', ); + const companionTab = container.querySelector( + '[data-testid="provider-workspace-tab-companion"]', + ); expect( container.querySelector('[data-testid="provider-workspace-switcher"]'), @@ -279,11 +355,13 @@ describe("CloudProviderSettings", () => { .length, ).toBe(1); expect(text).toContain("凭证池占位"); + expect(text).not.toContain("Lime Pet Companion"); expect(text).not.toContain("把本地 Provider 配置和 OEM 云端服务拆开管理"); expect(text).not.toContain("默认先进入“服务商设置”处理 Provider"); expect(text).not.toContain("public/oem-runtime-config.js"); expect(settingsTab?.getAttribute("data-state")).toBe("active"); expect(cloudTab?.getAttribute("data-state")).toBe("inactive"); + expect(companionTab?.getAttribute("data-state")).toBe("inactive"); await act(async () => { findButton(container, "云端服务").dispatchEvent( @@ -292,9 +370,12 @@ describe("CloudProviderSettings", () => { }); expect(container.textContent ?? "").toContain("先配置 OEM 云端运行时"); - expect(container.textContent ?? "").toContain("public/oem-runtime-config.js"); + expect(container.textContent ?? "").toContain( + "public/oem-runtime-config.js", + ); expect(settingsTab?.getAttribute("data-state")).toBe("inactive"); expect(cloudTab?.getAttribute("data-state")).toBe("active"); + expect(companionTab?.getAttribute("data-state")).toBe("inactive"); }); it("未登录时应提示前往个人中心登录", async () => { @@ -514,7 +595,7 @@ describe("CloudProviderSettings", () => { }); }); - it("服务商设置页应展示桌宠桥接卡片和脱敏边界说明", async () => { + it("桌宠管理页应展示桌宠桥接卡片和脱敏边界说明", async () => { mockGetCompanionPetStatus.mockResolvedValue( createPetStatus({ connected: true, @@ -527,23 +608,40 @@ describe("CloudProviderSettings", () => { ); const { container } = await renderPage(); + + expect( + container.querySelector('[data-testid="companion-provider-card"]'), + ).toBeNull(); + + await act(async () => { + findButton(container, "桌宠管理").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + const text = container.textContent ?? ""; expect( container.querySelector('[data-testid="companion-provider-card"]'), ).not.toBeNull(); expect(text).toContain("Lime Pet Companion"); - expect(text).toContain("桌宠通过本地 Companion 通道复用 Lime 的 AI 服务商状态"); + expect(text).toContain( + "桌宠通过本地 Companion 通道复用 Lime 的 AI 服务商状态", + ); expect(text).toContain("不会直接读取 API Key、OAuth 凭证或本地凭证文件"); expect(text).toContain("桌宠已连接"); expect(text).toContain("Provider 概览"); expect(text).toContain("桌宠视角预览"); + expect(text).toContain("DeepSeek"); expect(text).toContain("OpenAI"); expect(text).toContain("Codex"); - expect(text).toContain("可用 2"); + expect(text).toContain("可用 3"); expect(text).toContain("需关注 1"); expect(text).toContain("接入检查"); expect(text).toContain("当前链路已就绪,可以直接点击“立即同步到桌宠”。"); + expect(text).toContain("桌宠能力偏好"); + expect(text).toContain("桌宠通用模型"); + expect(text).toContain("桌宠语音播报"); }); it("点击启动桌宠后应调用 launch 接口并展示启动反馈", async () => { @@ -559,6 +657,12 @@ describe("CloudProviderSettings", () => { const { container } = await renderPage(); + await act(async () => { + findButton(container, "桌宠管理").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + await act(async () => { findButton(container, "启动 Lime Pet").dispatchEvent( new MouseEvent("click", { bubbles: true }), @@ -582,6 +686,12 @@ describe("CloudProviderSettings", () => { const { container } = await renderPage(); + await act(async () => { + findButton(container, "桌宠管理").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + await act(async () => { findButton(container, "立即同步到桌宠").dispatchEvent( new MouseEvent("click", { bubbles: true }), @@ -602,6 +712,14 @@ describe("CloudProviderSettings", () => { available: true, needs_attention: false, }, + { + provider_type: "deepseek", + display_name: "DeepSeek", + total_count: 1, + healthy_count: 1, + available: true, + needs_attention: false, + }, { provider_type: "openai", display_name: "OpenAI", @@ -611,12 +729,14 @@ describe("CloudProviderSettings", () => { needs_attention: true, }, ], - total_provider_count: 2, - available_provider_count: 2, + total_provider_count: 3, + available_provider_count: 3, needs_attention_provider_count: 1, }, }); - expect(container.textContent ?? "").toContain("已同步 2 个服务商摘要到桌宠"); + expect(container.textContent ?? "").toContain( + "已同步 3 个服务商摘要到桌宠", + ); expect(container.textContent ?? "").toContain( "当前链路已就绪,可以直接点击“立即同步到桌宠”。", ); @@ -634,6 +754,13 @@ describe("CloudProviderSettings", () => { ); const { container } = await renderPage(); + + await act(async () => { + findButton(container, "桌宠管理").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + const syncButton = container.querySelector( '[data-testid="companion-sync-preview"]', ) as HTMLButtonElement | null; diff --git a/src/components/settings-v2/agent/providers/index.tsx b/src/components/settings-v2/agent/providers/index.tsx index 0b570e428..e0d340f60 100644 --- a/src/components/settings-v2/agent/providers/index.tsx +++ b/src/components/settings-v2/agent/providers/index.tsx @@ -34,13 +34,13 @@ import { sendCompanionPetCommand, type CompanionPetStatus, } from "@/lib/api/companion"; -import { providerPoolApi } from "@/lib/api/providerPool"; import { subscribeProviderDataChanged } from "@/lib/providerDataEvents"; import { - buildCompanionProviderOverview, + loadCompanionProviderOverview, type CompanionProviderOverviewPayload, } from "@/lib/provider/companionProviderOverview"; import { cn } from "@/lib/utils"; +import { CompanionCapabilityPreferencesCard } from "./CompanionCapabilityPreferencesCard"; const SURFACE_CLASS_NAME = "rounded-[24px] border border-slate-200/80 bg-white p-5 shadow-sm shadow-slate-950/5"; @@ -55,7 +55,9 @@ function SessionValueCard(props: { return (
- {props.icon ? {props.icon} : null} + {props.icon ? ( + {props.icon} + ) : null} {props.label}

@@ -66,10 +68,7 @@ function SessionValueCard(props: { ); } -function NoticeBar(props: { - tone: "error" | "success"; - message: string; -}) { +function NoticeBar(props: { tone: "error" | "success"; message: string }) { return (

{ - setProviderOverviewPreview(buildCompanionProviderOverview(overview)); + loadCompanionProviderOverview({ forceRefresh: true }) + .then((payload) => { + setProviderOverviewPreview(payload); setPreviewError(null); }) .catch((error) => { @@ -348,7 +352,8 @@ function CompanionProviderBridgeCard() { if (result.launched) { setActionFeedback({ tone: "success", - message: result.message || "已请求启动 Lime Pet,请等待桌宠建立连接。", + message: + result.message || "已请求启动 Lime Pet,请等待桌宠建立连接。", }); } else { setActionFeedback({ @@ -434,7 +439,9 @@ function CompanionProviderBridgeCard() { status?.capabilities.includes("provider-overview"), ); const endpoint = status?.endpoint || DEFAULT_COMPANION_ENDPOINT; - const lastState = formatCompanionVisualStateLabel(status?.last_state || "idle"); + const lastState = formatCompanionVisualStateLabel( + status?.last_state || "idle", + ); const capabilityText = status && status.capabilities.length > 0 ? status.capabilities.map(formatCompanionCapabilityLabel).join(" / ") @@ -445,7 +452,7 @@ function CompanionProviderBridgeCard() { if (previewLoading) { return { label: "整理摘要中", - hint: "正在从本地 Provider 真相源整理桌宠预览。", + hint: "正在从当前服务商配置与凭证池整理桌宠预览。", }; } if (previewError) { @@ -523,7 +530,7 @@ function CompanionProviderBridgeCard() { previewProviders.length >= 0, pending: previewLoading, detail: previewLoading - ? "正在从本地 Provider 真相源整理脱敏摘要。" + ? "正在从当前服务商配置与凭证池整理脱敏摘要。" : previewError ? previewError : `当前已准备 ${providerOverviewPreview?.total_provider_count ?? 0} 个服务商摘要。`, @@ -549,7 +556,10 @@ function CompanionProviderBridgeCard() { })(); return ( -
+
@@ -569,7 +579,9 @@ function CompanionProviderBridgeCard() { ? "等待桌宠连接" : "本地 Companion 未监听" } - tone={connected ? "emerald" : serverListening ? "amber" : "slate"} + tone={ + connected ? "emerald" : serverListening ? "amber" : "slate" + } />

- 桌宠通过本地 Companion 通道复用 Lime 的 AI 服务商状态,只接收脱敏后的可用性摘要,不会直接读取 API Key、OAuth 凭证或本地凭证文件。 + 桌宠通过本地 Companion 通道复用 Lime 的 AI + 服务商状态,只接收脱敏后的可用性摘要,不会直接读取 API + Key、OAuth 凭证或本地凭证文件。

@@ -590,18 +604,18 @@ function CompanionProviderBridgeCard() {
- 当前建议:{nextAction} + 当前建议: + {nextAction}
@@ -662,8 +677,12 @@ function CompanionProviderBridgeCard() { )}
-

{item.label}

-

{item.detail}

+

+ {item.label} +

+

+ {item.detail} +

@@ -678,7 +697,8 @@ function CompanionProviderBridgeCard() { 桌宠视角预览

- 这里展示 Lime 准备发给桌宠的 Provider 脱敏摘要,便于确认 Companion 边界没有把原始凭证带出去。 + 这里展示 Lime 准备发给桌宠的服务商脱敏摘要,会合并当前 AI + 服务商配置与凭证池状态,但不会带出原始凭证。

@@ -693,7 +713,9 @@ function CompanionProviderBridgeCard() { label={`需关注 ${providerOverviewPreview?.needs_attention_provider_count ?? 0}`} tone="amber" /> - +
- Companion 地址:{endpoint} + Companion 地址: + {endpoint}
{previewLoading ? ( @@ -764,7 +787,7 @@ function CompanionProviderBridgeCard() {
) : (
- 当前还没有可供桌宠消费的 Provider 摘要。配置任一服务商后,这里会显示脱敏后的可用性信息。 + 当前还没有可供桌宠消费的服务商摘要。配置任一服务商后,这里会显示脱敏后的可用性信息。
)}
@@ -836,7 +859,9 @@ function resolveOfferTone(state: string): "slate" | "emerald" | "amber" { } } -type DisplayableOffer = OemCloudProviderOfferSummary | OemCloudProviderOfferDetail; +type DisplayableOffer = + | OemCloudProviderOfferSummary + | OemCloudProviderOfferDetail; function resolveDisplayOfferState( session: OemCloudCurrentSession | null, @@ -884,7 +909,7 @@ function resolveDisplayOfferState( return "available_subscribe_required"; } -type ProviderWorkspaceView = "settings" | "cloud"; +type ProviderWorkspaceView = "settings" | "cloud" | "companion"; const PROVIDER_WORKSPACE_VIEW_META: Array<{ value: ProviderWorkspaceView; @@ -904,6 +929,12 @@ const PROVIDER_WORKSPACE_VIEW_META: Array<{ summary: "Offer / 目录 / 会话", icon: Cloud, }, + { + value: "companion", + label: "桌宠管理", + summary: "Companion / 同步 / 诊断", + icon: Bot, + }, ]; function isLimeBrandedHub(hubProviderName: string | null | undefined): boolean { @@ -967,13 +998,18 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { orderedViews.push("cloud"); } + if (!orderedViews.includes("companion")) { + orderedViews.push("companion"); + } + return orderedViews.map( (view) => PROVIDER_WORKSPACE_VIEW_META.find((item) => item.value === view)!, ); }, [isLimeBrand, isOemRuntime, showProviderSettingsEntry]); const defaultView = workspaceViews[0]?.value ?? "cloud"; - const [activeView, setActiveView] = useState(defaultView); + const [activeView, setActiveView] = + useState(defaultView); useEffect(() => { if (!workspaceViews.some((item) => item.value === activeView)) { @@ -1059,7 +1095,9 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) {
@@ -1098,10 +1138,13 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { {session.user.displayName || session.user.email || "已登录"}

- {session.user.email || session.user.username || session.user.id} + {session.user.email || + session.user.username || + session.user.id}

- 当前云端会话将驱动 OEM Offer、模型目录和默认来源。商业化套餐、可用模型和是否开放开发者入口都以服务端治理结果为准。 + 当前云端会话将驱动 OEM + Offer、模型目录和默认来源。商业化套餐、可用模型和是否开放开发者入口都以服务端治理结果为准。

@@ -1165,7 +1208,8 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { 当前云端摘要

- 这里只展示 OEM 云端的最终接入态,方便快速确认默认来源、模型目录和开发者入口是否符合当前租户策略。 + 这里只展示 OEM + 云端的最终接入态,方便快速确认默认来源、模型目录和开发者入口是否符合当前租户策略。

@@ -1202,7 +1246,8 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) {

云端服务目录

- 卡片以服务端返回为主,并结合当前桌面会话做状态归一。是否允许 API Key 模式、当前模型来源、租户覆盖是否生效,都以服务端治理结果为准。 + 卡片以服务端返回为主,并结合当前桌面会话做状态归一。是否允许 API Key + 模式、当前模型来源、租户覆盖是否生效,都以服务端治理结果为准。

@@ -1473,10 +1518,11 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { ); - const localProviderContent = ( -
+ const localProviderContent = ; + const companionContent = ( +
- +
); @@ -1487,16 +1533,21 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { - setActiveView(value as ProviderWorkspaceView) - } + onValueChange={(value) => setActiveView(value as ProviderWorkspaceView)} className="space-y-4" > {workspaceViews.length > 1 ? ( @@ -1532,6 +1583,10 @@ export function CloudProviderSettings(props: CloudProviderSettingsProps) { {cloudDirectoryContent} + + + {companionContent} +
); diff --git a/src/components/switch/AppTabs.test.tsx b/src/components/switch/AppTabs.test.tsx new file mode 100644 index 000000000..8a1a0fa8a --- /dev/null +++ b/src/components/switch/AppTabs.test.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { AppTabs } from "./AppTabs"; + +describe("switch/AppTabs", () => { + it("应展示 current 的 Claude 标签,不再回流旧品牌名称", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Claude"); + expect(html).toContain("Claude 配置"); + expect(html).not.toContain("Claude Code"); + }); +}); diff --git a/src/components/switch/AppTabs.tsx b/src/components/switch/AppTabs.tsx index afced7bfa..cf31b9126 100644 --- a/src/components/switch/AppTabs.tsx +++ b/src/components/switch/AppTabs.tsx @@ -15,8 +15,8 @@ const apps: { }[] = [ { id: "claude", - label: "Claude Code", - description: "Claude CLI 配置", + label: "Claude", + description: "Claude 配置", iconType: "claude", }, { diff --git a/src/components/terminal/termwrap.ts b/src/components/terminal/termwrap.ts index b658c464f..44bd53689 100644 --- a/src/components/terminal/termwrap.ts +++ b/src/components/terminal/termwrap.ts @@ -346,7 +346,7 @@ export class TermWrap { /** * 将数据加入写入队列(批量写入减少闪烁) * - * Claude Code 等应用会发送大量小数据包(如 ESC[2K + ESC[1A 组合), + * Claude 等应用会发送大量小数据包(如 ESC[2K + ESC[1A 组合), * 如果每个包都立即写入会导致闪烁。通过批量写入,将短时间内的多个 * 数据包合并后一次性写入,减少渲染次数。 */ diff --git a/src/components/workspace/panels/WorkbenchCreateEntryHome.test.tsx b/src/components/workspace/panels/WorkbenchCreateEntryHome.test.tsx deleted file mode 100644 index 3c8a9f748..000000000 --- a/src/components/workspace/panels/WorkbenchCreateEntryHome.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { WorkbenchCreateEntryHome } from "./WorkbenchCreateEntryHome"; -import { - cleanupMountedRoots, - clickButtonByText, - fillTextInput, - findButtonByText, - findInputByPlaceholder, - flushEffects, - mountHarness, - setupReactActEnvironment, - type MountedRoot, -} from "../hooks/testUtils"; -import { - buildCreateConfirmationA2UI, - type PendingCreateConfirmation, -} from "@/components/workspace/utils/createConfirmationPolicy"; - -setupReactActEnvironment(); - -describe("WorkbenchCreateEntryHome", () => { - const mountedRoots: MountedRoot[] = []; - - const pendingConfirmation: PendingCreateConfirmation = { - projectId: "project-1", - source: "workspace_prompt", - creationMode: "guided", - initialUserPrompt: "请帮我生成一篇关于 AI Agent 行业趋势的文章", - createdAt: 1_700_000_000_000, - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - cleanupMountedRoots(mountedRoots); - }); - - it("无待确认任务时展示工作态首页", () => { - const { container } = mountHarness( - WorkbenchCreateEntryHome, - { - projectName: "社媒项目A", - pendingCreateConfirmation: undefined, - createConfirmationResponse: null, - onOpenCreateContentDialog: vi.fn(), - onSubmitCreateConfirmation: vi.fn(), - onCancelCreateConfirmation: vi.fn(), - }, - mountedRoots, - ); - - expect(container.querySelector("[data-testid='workspace-create-entry-home']")).not.toBeNull(); - expect(container.querySelector("[data-testid='workspace-create-confirmation-card']")).toBeNull(); - expect(container.textContent).toContain("当前没有待处理任务"); - expect(container.textContent).toContain("社媒项目A"); - expect(container.textContent).toContain("创作首页"); - }); - - it("待确认任务时展示创建确认卡片与原始需求", async () => { - const { container } = mountHarness( - WorkbenchCreateEntryHome, - { - projectName: "社媒项目A", - pendingCreateConfirmation: pendingConfirmation, - createConfirmationResponse: buildCreateConfirmationA2UI( - pendingConfirmation, - ), - onOpenCreateContentDialog: vi.fn(), - onSubmitCreateConfirmation: vi.fn(), - onCancelCreateConfirmation: vi.fn(), - }, - mountedRoots, - ); - - expect(container.querySelector("[data-testid='workspace-create-confirmation-card']")).not.toBeNull(); - expect(container.textContent).toContain("确认创作方式"); - expect(container.textContent).toContain("原始需求"); - expect(container.textContent).toContain( - "请帮我生成一篇关于 AI Agent 行业趋势的文章", - ); - expect(findButtonByText(container, "开始处理")).toBeDefined(); - }); - - it("A2UI 按需显示补充说明,并在提交时回传用户选择", async () => { - const submitSpy = vi.fn(); - const { container } = mountHarness( - WorkbenchCreateEntryHome, - { - projectName: "社媒项目A", - pendingCreateConfirmation: pendingConfirmation, - createConfirmationResponse: buildCreateConfirmationA2UI( - pendingConfirmation, - ), - onOpenCreateContentDialog: vi.fn(), - onSubmitCreateConfirmation: submitSpy, - onCancelCreateConfirmation: vi.fn(), - }, - mountedRoots, - ); - - expect(container.textContent).not.toContain("补充说明(可选)"); - const submitButton = findButtonByText(container, "开始处理"); - expect(submitButton?.disabled).toBe(true); - - clickButtonByText(container, "其他方式"); - await flushEffects(); - - expect(container.textContent).toContain("补充说明(可选)"); - fillTextInput( - findInputByPlaceholder( - container, - "如果你有明确主题、素材、目标读者或限制条件,可以补充在这里", - ), - "按我的素材继续扩写", - ); - await flushEffects(); - - clickButtonByText(container, "开始处理"); - await flushEffects(); - - expect(submitSpy).toHaveBeenCalledWith( - expect.objectContaining({ - create_confirmation_option: ["other"], - }), - ); - }); -}); diff --git a/src/components/workspace/panels/WorkbenchRightRail.test.tsx b/src/components/workspace/panels/WorkbenchRightRail.test.tsx deleted file mode 100644 index 6d585ef46..000000000 --- a/src/components/workspace/panels/WorkbenchRightRail.test.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { act } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useWorkbenchStore } from "@/stores/useWorkbenchStore"; -import { WorkbenchRightRail } from "./WorkbenchRightRail"; -import { - cleanupMountedRoots, - clickElement, - flushEffects, - mountHarness, - setupReactActEnvironment, - type MountedRoot, -} from "../hooks/testUtils"; - -setupReactActEnvironment(); - -describe("WorkbenchRightRail", () => { - const mountedRoots: MountedRoot[] = []; - - beforeEach(() => { - act(() => { - useWorkbenchStore.setState({ - leftSidebarCollapsed: true, - contentReviewRailState: null, - }); - }); - }); - - afterEach(() => { - cleanupMountedRoots(mountedRoots); - act(() => { - useWorkbenchStore.getState().clearContentReviewRailState(); - }); - }); - - it("创作视图默认显示能力面板", async () => { - const { container } = mountHarness( - WorkbenchRightRail, - { - shouldRender: true, - isCreateWorkspaceView: true, - projectId: "project-1", - onBackToCreateView: vi.fn(), - onCreateContentFromPrompt: vi.fn(), - }, - mountedRoots, - ); - - await flushEffects(); - - expect(container.querySelector("[data-testid='workbench-right-rail-expanded']")).not.toBeNull(); - }); - - it("创作右侧栏不再显示项目风格策略入口", async () => { - const { container } = mountHarness( - WorkbenchRightRail, - { - shouldRender: true, - isCreateWorkspaceView: true, - projectId: "project-1", - onBackToCreateView: vi.fn(), - onCreateContentFromPrompt: vi.fn(), - }, - mountedRoots, - ); - - await flushEffects(); - - expect(container.textContent).not.toContain("风格策略"); - expect(container.textContent).not.toContain("编辑项目风格"); - }); - - it("存在评审状态时应切换为评审专家团面板,关闭后恢复能力面板", async () => { - const closeSpy = vi.fn(); - - act(() => { - useWorkbenchStore.getState().setContentReviewRailState({ - experts: [ - { - id: "reviewer-1", - name: "林岑·叙事总编", - title: "结构整饬 · 主线聚焦", - description: "用于测试的评审专家。", - tags: ["结构整饬", "主线聚焦"], - badgeText: "+1", - avatarLabel: "林岑", - avatarColor: "linear-gradient(135deg, #4f8cff 0%, #56d8ff 100%)", - }, - ], - selectedExpertIds: ["reviewer-1"], - onToggleExpert: vi.fn(), - onClose: () => { - closeSpy(); - useWorkbenchStore.getState().clearContentReviewRailState(); - }, - onCreateExpert: vi.fn(), - onStartReview: vi.fn(), - reviewRunning: false, - reviewResult: "", - reviewError: "", - }); - }); - - const { container } = mountHarness( - WorkbenchRightRail, - { - shouldRender: true, - isCreateWorkspaceView: true, - projectId: "project-1", - onBackToCreateView: vi.fn(), - onCreateContentFromPrompt: vi.fn(), - }, - mountedRoots, - ); - - await flushEffects(); - - expect(container.textContent).toContain("评审专家团"); - expect(container.textContent).toContain("林岑·叙事总编"); - expect(container.querySelector("[data-testid='workbench-right-rail-expanded']")).toBeNull(); - - clickElement( - container.querySelector("button[aria-label='关闭评审专家团']"), - ); - - await flushEffects(); - - expect(closeSpy).toHaveBeenCalledTimes(1); - expect(container.querySelector("[data-testid='workbench-right-rail-expanded']")).not.toBeNull(); - }); -}); diff --git a/src/lib/api/agent.test.ts b/src/lib/api/agent.test.ts index 5a7c9d516..c396c017a 100644 --- a/src/lib/api/agent.test.ts +++ b/src/lib/api/agent.test.ts @@ -545,7 +545,7 @@ describe("Agent API 治理护栏", () => { model: "gpt-5.4-mini", role_hint: "image_editor", task_summary: "处理封面图优化", - origin_tool: "spawn_agent", + origin_tool: "Agent", runtime_status: "completed", }, ], @@ -554,7 +554,7 @@ describe("Agent API 治理护栏", () => { parent_session_name: "主线程会话", role_hint: "image_editor", task_summary: "处理封面图优化", - origin_tool: "spawn_agent", + origin_tool: "Agent", created_from_turn_id: "turn-2", sibling_subagent_sessions: [ { @@ -640,7 +640,7 @@ describe("Agent API 治理护栏", () => { model: "gpt-5.4-mini", role_hint: "image_editor", task_summary: "处理封面图优化", - origin_tool: "spawn_agent", + origin_tool: "Agent", runtime_status: "completed", }, ], @@ -649,7 +649,7 @@ describe("Agent API 治理护栏", () => { parent_session_name: "主线程会话", role_hint: "image_editor", task_summary: "处理封面图优化", - origin_tool: "spawn_agent", + origin_tool: "Agent", created_from_turn_id: "turn-2", sibling_subagent_sessions: [ { diff --git a/src/lib/api/agentRuntime.ts b/src/lib/api/agentRuntime.ts index 8eff51eae..ad012228d 100644 --- a/src/lib/api/agentRuntime.ts +++ b/src/lib/api/agentRuntime.ts @@ -1094,6 +1094,96 @@ export interface AgentRuntimeCloseSubagentResponse { changed_session_ids: string[]; } +export interface CreateImageGenerationTaskArtifactRequest { + projectRootPath: string; + prompt: string; + title?: string; + mode?: "generate" | "edit" | "variation"; + rawText?: string; + size?: string; + aspectRatio?: string; + count?: number; + usage?: string; + style?: string; + providerId?: string; + model?: string; + sessionId?: string; + projectId?: string; + contentId?: string; + entrySource?: string; + requestedTarget?: "generate" | "cover"; + targetOutputId?: string; + targetOutputRefId?: string; + referenceImages?: string[]; +} + +export interface MediaTaskArtifactRecord { + task_id: string; + task_type: string; + task_family: string; + title?: string | null; + summary?: string | null; + payload: Record; + status: string; + normalized_status: string; + created_at: string; + updated_at?: string | null; + current_attempt_id?: string | null; + idempotency_key?: string | null; + retry_count?: number; + result?: unknown; + last_error?: Record | null; + attempts?: Array>; + progress?: Record; + ui_hints?: Record; +} + +export interface MediaTaskArtifactOutput { + success: boolean; + task_id: string; + task_type: string; + task_family: string; + status: string; + normalized_status: string; + current_attempt_id?: string | null; + path: string; + absolute_path: string; + artifact_path: string; + absolute_artifact_path: string; + reused_existing: boolean; + idempotency_key?: string | null; + record: MediaTaskArtifactRecord; +} + +export interface MediaTaskLookupRequest { + projectRootPath: string; + taskRef: string; +} + +export interface ListMediaTaskArtifactsRequest { + projectRootPath: string; + status?: string; + taskFamily?: string; + taskType?: string; + limit?: number; +} + +export interface MediaTaskListFilters { + status?: string | null; + task_family?: string | null; + task_type?: string | null; + limit?: number | null; +} + +export interface ListMediaTaskArtifactsOutput { + success: boolean; + workspace_root: string; + artifact_root: string; + filters: MediaTaskListFilters; + total: number; + tasks: MediaTaskArtifactOutput[]; +} + export type AgentToolSurfaceProfile = "core" | "workbench" | "browser_assist"; export type AgentToolCapability = @@ -1543,6 +1633,36 @@ export async function deleteAgentRuntimeSession( return await safeInvoke("agent_runtime_delete_session", { sessionId }); } +export async function createImageGenerationTaskArtifact( + request: CreateImageGenerationTaskArtifactRequest, +): Promise { + return await safeInvoke("create_image_generation_task_artifact", { request }); +} + +export async function getMediaTaskArtifact( + request: MediaTaskLookupRequest, +): Promise { + return await safeInvoke("get_media_task_artifact", { request }); +} + +export async function listMediaTaskArtifacts( + request: ListMediaTaskArtifactsRequest, +): Promise { + return await safeInvoke("list_media_task_artifacts", { request }); +} + +export async function retryMediaTaskArtifact( + request: MediaTaskLookupRequest, +): Promise { + return await safeInvoke("retry_media_task_artifact", { request }); +} + +export async function cancelMediaTaskArtifact( + request: MediaTaskLookupRequest, +): Promise { + return await safeInvoke("cancel_media_task_artifact", { request }); +} + export async function siteListAdapters(): Promise { return await safeInvoke("site_list_adapters"); } diff --git a/src/lib/api/agentTextNormalization.test.ts b/src/lib/api/agentTextNormalization.test.ts new file mode 100644 index 000000000..911de81bd --- /dev/null +++ b/src/lib/api/agentTextNormalization.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeLegacyToolSurfaceName } from "./agentTextNormalization"; + +const REFERENCE_JS_TOOL_SURFACE_MAPPINGS = [ + ["AgentTool", "Agent"], + ["AskUserQuestionTool", "AskUserQuestion"], + ["BashTool", "Bash"], + ["BriefTool", "SendUserMessage"], + ["ConfigTool", "Config"], + ["EnterPlanModeTool", "EnterPlanMode"], + ["EnterWorktreeTool", "EnterWorktree"], + ["ExitPlanModeTool", "ExitPlanMode"], + ["ExitWorktreeTool", "ExitWorktree"], + ["FileEditTool", "Edit"], + ["FileReadTool", "Read"], + ["FileWriteTool", "Write"], + ["GlobTool", "Glob"], + ["GrepTool", "Grep"], + ["LSPTool", "LSP"], + ["ListMcpResourcesTool", "ListMcpResourcesTool"], + ["NotebookEditTool", "NotebookEdit"], + ["PowerShellTool", "PowerShell"], + ["ReadMcpResourceTool", "ReadMcpResourceTool"], + ["RemoteTriggerTool", "RemoteTrigger"], + ["ScheduleCronTool", "CronCreate"], + ["SendMessageTool", "SendMessage"], + ["SkillTool", "Skill"], + ["SleepTool", "Sleep"], + ["SyntheticOutputTool", "StructuredOutput"], + ["TaskCreateTool", "TaskCreate"], + ["TaskGetTool", "TaskGet"], + ["TaskListTool", "TaskList"], + ["TaskOutputTool", "TaskOutput"], + ["TaskStopTool", "TaskStop"], + ["TaskUpdateTool", "TaskUpdate"], + ["TeamCreateTool", "TeamCreate"], + ["TeamDeleteTool", "TeamDelete"], + ["ToolSearchTool", "ToolSearch"], + ["WebFetchTool", "WebFetch"], + ["WebSearchTool", "WebSearch"], +] as const; + +describe("agentTextNormalization", () => { + it("应把参考 JS 工具目录名归一化为现役工具面", () => { + for (const [toolName, expected] of REFERENCE_JS_TOOL_SURFACE_MAPPINGS) { + expect(normalizeLegacyToolSurfaceName(toolName)).toBe(expected); + } + + expect(normalizeLegacyToolSurfaceName("RequestUserInputTool")).toBe( + "AskUserQuestion", + ); + expect(normalizeLegacyToolSurfaceName("SyntheticOutputTool")).toBe( + "StructuredOutput", + ); + expect(normalizeLegacyToolSurfaceName("AgentOutputTool")).toBe("TaskOutput"); + expect(normalizeLegacyToolSurfaceName("BashOutputTool")).toBe("TaskOutput"); + }); + + it("对当前无对应现役工具的参考例外保持原样", () => { + expect(normalizeLegacyToolSurfaceName("MCPTool")).toBe("MCPTool"); + expect(normalizeLegacyToolSurfaceName("McpAuthTool")).toBe("McpAuthTool"); + expect(normalizeLegacyToolSurfaceName("REPLTool")).toBe("REPLTool"); + }); +}); diff --git a/src/lib/api/agentTextNormalization.ts b/src/lib/api/agentTextNormalization.ts index 05811b69e..008bf6d68 100644 --- a/src/lib/api/agentTextNormalization.ts +++ b/src/lib/api/agentTextNormalization.ts @@ -1,4 +1,59 @@ const LEGACY_DECISION_PREFIX_RE = /^已决定[::]\s*/; +const LEGACY_TOOL_SURFACE_ALIASES: Record = { + ask: "AskUserQuestion", + requestuserinput: "AskUserQuestion", + requestuserinputtool: "AskUserQuestion", + askuserquestiontool: "AskUserQuestion", + brief: "SendUserMessage", + brieftool: "SendUserMessage", + sendusermessage: "SendUserMessage", + sendusermessagetool: "SendUserMessage", + spawnagent: "Agent", + subagenttask: "Agent", + agenttool: "Agent", + sendinput: "SendMessage", + sendmessagetool: "SendMessage", + bashtool: "Bash", + configtool: "Config", + enterplanmodetool: "EnterPlanMode", + exitplanmodetool: "ExitPlanMode", + enterworktreetool: "EnterWorktree", + exitworktreetool: "ExitWorktree", + filereadtool: "Read", + readfiletool: "Read", + filewritetool: "Write", + writefiletool: "Write", + createfiletool: "Write", + fileedittool: "Edit", + globtool: "Glob", + greptool: "Grep", + lsptool: "LSP", + listmcpresourcestool: "ListMcpResourcesTool", + readmcpresourcetool: "ReadMcpResourceTool", + notebookedittool: "NotebookEdit", + powershelltool: "PowerShell", + remotetriggertool: "RemoteTrigger", + schedulecrontool: "CronCreate", + croncreatetool: "CronCreate", + cronlisttool: "CronList", + crondeletetool: "CronDelete", + skilltool: "Skill", + sleeptool: "Sleep", + syntheticoutputtool: "StructuredOutput", + taskcreatetool: "TaskCreate", + taskgettool: "TaskGet", + tasklisttool: "TaskList", + taskoutputtool: "TaskOutput", + agentoutputtool: "TaskOutput", + bashoutputtool: "TaskOutput", + taskstoptool: "TaskStop", + taskupdatetool: "TaskUpdate", + teamcreatetool: "TeamCreate", + teamdeletetool: "TeamDelete", + toolsearchtool: "ToolSearch", + webfetchtool: "WebFetch", + websearchtool: "WebSearch", +}; export function normalizeLegacyRuntimeStatusTitle(title: string): string { return title.replace(LEGACY_DECISION_PREFIX_RE, "").trim(); @@ -20,6 +75,18 @@ export function normalizeLegacyTurnSummaryText(text: string): string { return [normalizedFirstLine, ...rest].filter((line, index) => index > 0 || line).join("\n"); } +export function normalizeLegacyToolSurfaceName( + value?: string | null, +): string | undefined { + const normalized = value?.trim(); + if (!normalized) { + return undefined; + } + + const key = normalized.replace(/[\s_-]+/g, "").toLowerCase(); + return LEGACY_TOOL_SURFACE_ALIASES[key] || normalized; +} + export function normalizeLegacyThreadItem( item: T, ): T { diff --git a/src/lib/api/appConfig.test.ts b/src/lib/api/appConfig.test.ts index 88b18ca78..e8fff5ed9 100644 --- a/src/lib/api/appConfig.test.ts +++ b/src/lib/api/appConfig.test.ts @@ -61,6 +61,12 @@ describe("appConfig API", () => { preferredProviderId: "fal", }, }, + companion_defaults: { + general: { + preferredProviderId: "deepseek", + preferredModelId: "deepseek-chat", + }, + }, }, }); @@ -73,6 +79,12 @@ describe("appConfig API", () => { preferredProviderId: "fal", }), }), + companion_defaults: expect.objectContaining({ + general: expect.objectContaining({ + preferredProviderId: "deepseek", + preferredModelId: "deepseek-chat", + }), + }), }), }), ); @@ -84,6 +96,12 @@ describe("appConfig API", () => { preferredProviderId: "fal", }), }), + companion_defaults: expect.objectContaining({ + general: expect.objectContaining({ + preferredProviderId: "deepseek", + preferredModelId: "deepseek-chat", + }), + }), }), ); }); @@ -97,6 +115,9 @@ describe("appConfig API", () => { media_defaults: { voice: { preferredProviderId: "openai" }, }, + companion_defaults: { + general: { preferredProviderId: "deepseek" }, + }, }, } as never; diff --git a/src/lib/api/appConfig.ts b/src/lib/api/appConfig.ts index 9481d5524..50e0b8bd9 100644 --- a/src/lib/api/appConfig.ts +++ b/src/lib/api/appConfig.ts @@ -9,6 +9,7 @@ let configLoadingPromise: Promise | null = null; let configCacheStamp: string | null = null; export type { + CompanionDefaultsConfig, Config, CrashReportingConfig, ChatAppearanceConfig, diff --git a/src/lib/api/appConfigTypes.ts b/src/lib/api/appConfigTypes.ts index 5e2e64840..93356befa 100644 --- a/src/lib/api/appConfigTypes.ts +++ b/src/lib/api/appConfigTypes.ts @@ -60,9 +60,15 @@ export interface MediaGenerationDefaultsConfig { voice?: MediaGenerationPreferenceConfig; } +export interface CompanionDefaultsConfig { + general?: MediaGenerationPreferenceConfig; + tts?: MediaGenerationPreferenceConfig; +} + export interface WorkspacePreferencesConfig { schema_version?: number; media_defaults?: MediaGenerationDefaultsConfig; + companion_defaults?: CompanionDefaultsConfig; } export interface NavigationConfig { diff --git a/src/lib/api/asrProvider.test.ts b/src/lib/api/asrProvider.test.ts index a5b4ab5bc..b5c57d492 100644 --- a/src/lib/api/asrProvider.test.ts +++ b/src/lib/api/asrProvider.test.ts @@ -143,7 +143,11 @@ describe("asrProvider API", () => { await expect(polishVoiceText("你好")).resolves.toEqual( expect.objectContaining({ instruction_name: "润色" }), ); - await expect(openVoiceWindow()).resolves.toBeUndefined(); + await expect( + openVoiceWindow({ + target: "companion-pet", + }), + ).resolves.toBeUndefined(); await expect(closeVoiceWindow()).resolves.toBeUndefined(); await expect(outputVoiceText("hello", "type")).resolves.toBeUndefined(); await expect(startRecording("default")).resolves.toBeUndefined(); @@ -161,6 +165,9 @@ describe("asrProvider API", () => { sampleRate: 16000, credentialId: "cred-1", }); + expect(safeInvoke).toHaveBeenNthCalledWith(3, "open_voice_window", { + target: "companion-pet", + }); expect(safeInvoke).toHaveBeenNthCalledWith(6, "start_recording", { deviceId: "default", }); diff --git a/src/lib/api/clawSolutions.test.ts b/src/lib/api/clawSolutions.test.ts deleted file mode 100644 index ab0ef68ba..000000000 --- a/src/lib/api/clawSolutions.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -const { mockSafeInvoke } = vi.hoisted(() => ({ - mockSafeInvoke: vi.fn(), -})); - -vi.mock("@/lib/dev-bridge", () => ({ - safeInvoke: mockSafeInvoke, -})); - -import { - checkClawSolutionReadiness, - getClawSolutionDetail, - listClawSolutions, - prepareClawSolution, -} from "./clawSolutions"; - -afterEach(() => { - vi.clearAllMocks(); -}); - -describe("clawSolutions api", () => { - it("应调用 claw_solution_list", async () => { - mockSafeInvoke.mockResolvedValueOnce([]); - - await listClawSolutions(); - - expect(mockSafeInvoke).toHaveBeenCalledWith("claw_solution_list"); - }); - - it("应调用 claw_solution_detail", async () => { - mockSafeInvoke.mockResolvedValueOnce(null); - - await getClawSolutionDetail("social-post-starter"); - - expect(mockSafeInvoke).toHaveBeenCalledWith("claw_solution_detail", { - solutionId: "social-post-starter", - }); - }); - - it("应调用 claw_solution_check_readiness", async () => { - mockSafeInvoke.mockResolvedValueOnce(null); - - await checkClawSolutionReadiness("browser-assist-task", { - userInput: "帮我登录后台", - }); - - expect(mockSafeInvoke).toHaveBeenCalledWith( - "claw_solution_check_readiness", - { - solutionId: "browser-assist-task", - context: { - userInput: "帮我登录后台", - }, - }, - ); - }); - - it("应调用 claw_solution_prepare", async () => { - mockSafeInvoke.mockResolvedValueOnce(null); - - await prepareClawSolution("team-breakdown", { - projectId: "project-1", - userInput: "拆解季度增长方案", - }); - - expect(mockSafeInvoke).toHaveBeenCalledWith("claw_solution_prepare", { - solutionId: "team-breakdown", - context: { - projectId: "project-1", - userInput: "拆解季度增长方案", - }, - }); - }); -}); diff --git a/src/lib/api/clawSolutions.ts b/src/lib/api/clawSolutions.ts deleted file mode 100644 index 1b647c8f6..000000000 --- a/src/lib/api/clawSolutions.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { safeInvoke } from "@/lib/dev-bridge"; - -export type ClawSolutionReadiness = - | "ready" - | "needs_setup" - | "needs_capability"; - -export type ClawSolutionActionType = - | "fill_input" - | "navigate_theme" - | "launch_browser_assist" - | "enable_team_mode"; - -export type ClawSolutionReasonCode = - | "missing_model" - | "missing_browser_capability" - | "missing_skill_dependency" - | "team_recommended"; - -export interface ClawSolutionSummary { - id: string; - title: string; - summary: string; - outputHint: string; - recommendedCapabilities: string[]; - readiness: ClawSolutionReadiness; - readinessMessage: string; - reasonCode?: ClawSolutionReasonCode; -} - -export interface ClawSolutionDetail extends ClawSolutionSummary { - starterPrompt: string; - themeTarget?: string; - followupMode?: string; - capabilityTags: string[]; -} - -export interface ClawSolutionContext { - projectId?: string; - userInput?: string; -} - -export interface ClawSolutionReadinessResult { - solutionId: string; - readiness: ClawSolutionReadiness; - readinessMessage: string; - reasonCode?: ClawSolutionReasonCode; -} - -export interface ClawSolutionPreparation { - solutionId: string; - actionType: ClawSolutionActionType; - prompt: string; - themeTarget?: string; - shouldLaunchBrowserAssist: boolean; - shouldEnableTeamMode: boolean; - readiness: ClawSolutionReadiness; - readinessMessage: string; - reasonCode?: ClawSolutionReasonCode; -} - -export async function listClawSolutions(): Promise { - return safeInvoke("claw_solution_list"); -} - -export async function getClawSolutionDetail( - solutionId: string, -): Promise { - return safeInvoke("claw_solution_detail", { solutionId }); -} - -export async function checkClawSolutionReadiness( - solutionId: string, - context?: ClawSolutionContext, -): Promise { - return safeInvoke("claw_solution_check_readiness", { solutionId, context }); -} - -export async function prepareClawSolution( - solutionId: string, - context?: ClawSolutionContext, -): Promise { - return safeInvoke("claw_solution_prepare", { solutionId, context }); -} diff --git a/src/lib/api/companion.ts b/src/lib/api/companion.ts index feaa30135..e4e480105 100644 --- a/src/lib/api/companion.ts +++ b/src/lib/api/companion.ts @@ -4,6 +4,18 @@ import type { UnlistenFn } from "@tauri-apps/api/event"; export const COMPANION_PET_STATUS_EVENT = "companion-pet-status"; export const COMPANION_OPEN_PROVIDER_SETTINGS_EVENT = "companion-open-provider-settings"; +export const COMPANION_REQUEST_PROVIDER_SYNC_EVENT = + "companion-request-provider-sync"; +export const COMPANION_REQUEST_PET_CHEER_EVENT = "companion-request-pet-cheer"; +export const COMPANION_REQUEST_PET_NEXT_STEP_EVENT = + "companion-request-pet-next-step"; +export const COMPANION_REQUEST_PET_CHAT_EVENT = "companion-request-pet-chat"; +export const COMPANION_REQUEST_PET_CHAT_RESET_EVENT = + "companion-request-pet-chat-reset"; +export const COMPANION_REQUEST_PET_VOICE_CHAT_EVENT = + "companion-request-pet-voice-chat"; +export const COMPANION_PET_VOICE_TRANSCRIPT_EVENT = + "companion-pet-voice-transcript"; export const COMPANION_PROVIDER_OVERVIEW_CAPABILITY = "provider-overview"; export type CompanionPetVisualState = @@ -51,6 +63,18 @@ export interface CompanionPetSendResult { connected: boolean; } +export interface CompanionPetChatRequestPayload { + text: string; + source?: string | null; +} + +export interface CompanionPetLive2DActionPayload { + expressions?: Array; + emotion_tags?: string[]; + motion_group?: string | null; + motion_index?: number | null; +} + export async function getCompanionPetStatus(): Promise { return safeInvoke("companion_get_pet_status"); } diff --git a/src/lib/api/mcp.test.ts b/src/lib/api/mcp.test.ts new file mode 100644 index 000000000..37374569e --- /dev/null +++ b/src/lib/api/mcp.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { getMcpInnerToolName } from "./mcp"; + +describe("mcp", () => { + it("应在已知 server 名下提取 inner tool 名", () => { + expect(getMcpInnerToolName("mcp__docs__search_docs", "docs")).toBe( + "search_docs", + ); + }); + + it("应保留 inner tool 名中的双下划线片段", () => { + expect( + getMcpInnerToolName("mcp__docs__admin__search_docs", "docs"), + ).toBe("admin__search_docs"); + }); + + it("对非 MCP 工具名保持原样", () => { + expect(getMcpInnerToolName("WebSearch", "docs")).toBe("WebSearch"); + }); +}); diff --git a/src/lib/api/mcp.ts b/src/lib/api/mcp.ts index 22b8c22de..c25a6fffe 100644 --- a/src/lib/api/mcp.ts +++ b/src/lib/api/mcp.ts @@ -49,7 +49,7 @@ export interface McpServerInfo { // 工具类型 // ============================================================================ -/** MCP 工具定义 */ +/** MCP 工具定义。`name` 当前格式固定为 `mcp____`。 */ export interface McpToolDefinition { name: string; description: string; @@ -74,6 +74,28 @@ export interface McpToolResult { is_error: boolean; } +/** 从 runtime 名 `mcp____` 提取 inner tool 名。 */ +export function getMcpInnerToolName( + toolName: string, + serverName?: string, +): string { + if (!toolName) return toolName; + + if (serverName) { + const prefixedName = `mcp__${serverName}__`; + if (toolName.startsWith(prefixedName)) { + return toolName.slice(prefixedName.length); + } + } + + if (!toolName.startsWith("mcp__")) { + return toolName; + } + + const parts = toolName.split("__"); + return parts.length >= 3 ? parts.slice(2).join("__") : toolName; +} + // ============================================================================ // 提示词类型 // ============================================================================ @@ -179,7 +201,7 @@ export const mcpApi = { // 工具管理 API // -------------------------------------------------------------------------- - /** 获取所有可用工具 */ + /** 获取所有可用工具,返回名格式为 `mcp____`。 */ listTools: (): Promise => safeInvoke("mcp_list_tools"), /** 按调用上下文获取可见工具(支持 deferred_loading) */ @@ -189,7 +211,7 @@ export const mcpApi = { ): Promise => safeInvoke("mcp_list_tools_for_context", { caller, includeDeferred }), - /** 工具搜索(Tool Search) */ + /** 工具搜索(Tool Search),返回名格式为 `mcp____`。 */ searchTools: ( query: string, caller?: string, @@ -197,14 +219,14 @@ export const mcpApi = { ): Promise => safeInvoke("mcp_search_tools", { query, caller, limit }), - /** 调用工具 */ + /** 调用工具,`toolName` 当前格式为 `mcp____`。 */ callTool: ( toolName: string, args: Record, ): Promise => safeInvoke("mcp_call_tool", { toolName, arguments: args }), - /** 带 caller 校验调用工具 */ + /** 带 caller 校验调用工具,`toolName` 当前格式为 `mcp____`。 */ callToolWithCaller: ( toolName: string, args: Record, diff --git a/src/lib/api/mediaTasks.test.ts b/src/lib/api/mediaTasks.test.ts new file mode 100644 index 000000000..f73727ed2 --- /dev/null +++ b/src/lib/api/mediaTasks.test.ts @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { safeInvoke } from "@/lib/dev-bridge"; +import { + cancelMediaTaskArtifact, + createImageGenerationTaskArtifact, + getMediaTaskArtifact, + listMediaTaskArtifacts, + retryMediaTaskArtifact, +} from "./mediaTasks"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), +})); + +describe("mediaTasks API", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("应通过统一网关创建图片任务 artifact", async () => { + vi.mocked(safeInvoke).mockResolvedValueOnce({ + success: true, + task_id: "task-image-1", + task_type: "image_generate", + task_family: "image", + status: "pending_submit", + normalized_status: "pending", + path: ".lime/tasks/image_generate/task-image-1.json", + absolute_path: "/workspace/.lime/tasks/image_generate/task-image-1.json", + artifact_path: ".lime/tasks/image_generate/task-image-1.json", + absolute_artifact_path: + "/workspace/.lime/tasks/image_generate/task-image-1.json", + reused_existing: false, + record: { + task_id: "task-image-1", + task_type: "image_generate", + task_family: "image", + payload: { + prompt: "未来感青柠实验室", + }, + status: "pending_submit", + normalized_status: "pending", + created_at: "2026-04-04T12:00:00Z", + }, + }); + + await expect( + createImageGenerationTaskArtifact({ + projectRootPath: "/workspace", + prompt: "未来感青柠实验室", + mode: "generate", + count: 1, + }), + ).resolves.toEqual( + expect.objectContaining({ + task_id: "task-image-1", + task_type: "image_generate", + }), + ); + + expect(vi.mocked(safeInvoke)).toHaveBeenCalledWith( + "create_image_generation_task_artifact", + { + request: { + projectRootPath: "/workspace", + prompt: "未来感青柠实验室", + mode: "generate", + count: 1, + }, + }, + ); + }); + + it("应通过统一网关读取、列出、重试和取消媒体任务 artifact", async () => { + vi.mocked(safeInvoke) + .mockResolvedValueOnce({ + success: true, + task_id: "task-image-2", + task_type: "image_generate", + task_family: "image", + status: "pending_submit", + normalized_status: "pending", + path: ".lime/tasks/image_generate/task-image-2.json", + absolute_path: "/workspace/.lime/tasks/image_generate/task-image-2.json", + artifact_path: ".lime/tasks/image_generate/task-image-2.json", + absolute_artifact_path: + "/workspace/.lime/tasks/image_generate/task-image-2.json", + reused_existing: false, + record: { + task_id: "task-image-2", + task_type: "image_generate", + task_family: "image", + payload: { + prompt: "读取任务", + }, + status: "pending_submit", + normalized_status: "pending", + created_at: "2026-04-04T12:10:00Z", + }, + }) + .mockResolvedValueOnce({ + success: true, + workspace_root: "/workspace", + artifact_root: "/workspace/.lime/tasks", + filters: { + status: "pending", + task_family: "image", + task_type: "image_generate", + limit: 10, + }, + total: 1, + tasks: [ + { + success: true, + task_id: "task-image-2", + task_type: "image_generate", + task_family: "image", + status: "pending_submit", + normalized_status: "pending", + path: ".lime/tasks/image_generate/task-image-2.json", + absolute_path: + "/workspace/.lime/tasks/image_generate/task-image-2.json", + artifact_path: ".lime/tasks/image_generate/task-image-2.json", + absolute_artifact_path: + "/workspace/.lime/tasks/image_generate/task-image-2.json", + reused_existing: false, + record: { + task_id: "task-image-2", + task_type: "image_generate", + task_family: "image", + payload: { + prompt: "读取任务", + }, + status: "pending_submit", + normalized_status: "pending", + created_at: "2026-04-04T12:10:00Z", + }, + }, + ], + }) + .mockResolvedValueOnce({ + success: true, + task_id: "task-image-2", + task_type: "image_generate", + task_family: "image", + status: "pending_submit", + normalized_status: "pending", + current_attempt_id: "attempt-2", + path: ".lime/tasks/image_generate/task-image-2.json", + absolute_path: "/workspace/.lime/tasks/image_generate/task-image-2.json", + artifact_path: ".lime/tasks/image_generate/task-image-2.json", + absolute_artifact_path: + "/workspace/.lime/tasks/image_generate/task-image-2.json", + reused_existing: false, + record: { + task_id: "task-image-2", + task_type: "image_generate", + task_family: "image", + payload: { + prompt: "读取任务", + }, + status: "pending_submit", + normalized_status: "pending", + created_at: "2026-04-04T12:10:00Z", + retry_count: 1, + }, + }) + .mockResolvedValueOnce({ + success: true, + task_id: "task-image-2", + task_type: "image_generate", + task_family: "image", + status: "cancelled", + normalized_status: "cancelled", + path: ".lime/tasks/image_generate/task-image-2.json", + absolute_path: "/workspace/.lime/tasks/image_generate/task-image-2.json", + artifact_path: ".lime/tasks/image_generate/task-image-2.json", + absolute_artifact_path: + "/workspace/.lime/tasks/image_generate/task-image-2.json", + reused_existing: false, + record: { + task_id: "task-image-2", + task_type: "image_generate", + task_family: "image", + payload: { + prompt: "读取任务", + }, + status: "cancelled", + normalized_status: "cancelled", + created_at: "2026-04-04T12:10:00Z", + }, + }); + + await expect( + getMediaTaskArtifact({ + projectRootPath: "/workspace", + taskRef: "task-image-2", + }), + ).resolves.toEqual(expect.objectContaining({ task_id: "task-image-2" })); + + await expect( + listMediaTaskArtifacts({ + projectRootPath: "/workspace", + status: "pending", + taskFamily: "image", + taskType: "image_generate", + limit: 10, + }), + ).resolves.toEqual(expect.objectContaining({ total: 1 })); + + await expect( + retryMediaTaskArtifact({ + projectRootPath: "/workspace", + taskRef: "task-image-2", + }), + ).resolves.toEqual( + expect.objectContaining({ current_attempt_id: "attempt-2" }), + ); + + await expect( + cancelMediaTaskArtifact({ + projectRootPath: "/workspace", + taskRef: "task-image-2", + }), + ).resolves.toEqual( + expect.objectContaining({ normalized_status: "cancelled" }), + ); + + expect(vi.mocked(safeInvoke)).toHaveBeenNthCalledWith( + 1, + "get_media_task_artifact", + { + request: { + projectRootPath: "/workspace", + taskRef: "task-image-2", + }, + }, + ); + expect(vi.mocked(safeInvoke)).toHaveBeenNthCalledWith( + 2, + "list_media_task_artifacts", + { + request: { + projectRootPath: "/workspace", + status: "pending", + taskFamily: "image", + taskType: "image_generate", + limit: 10, + }, + }, + ); + expect(vi.mocked(safeInvoke)).toHaveBeenNthCalledWith( + 3, + "retry_media_task_artifact", + { + request: { + projectRootPath: "/workspace", + taskRef: "task-image-2", + }, + }, + ); + expect(vi.mocked(safeInvoke)).toHaveBeenNthCalledWith( + 4, + "cancel_media_task_artifact", + { + request: { + projectRootPath: "/workspace", + taskRef: "task-image-2", + }, + }, + ); + }); +}); diff --git a/src/lib/api/mediaTasks.ts b/src/lib/api/mediaTasks.ts new file mode 100644 index 000000000..79e12f58b --- /dev/null +++ b/src/lib/api/mediaTasks.ts @@ -0,0 +1,17 @@ +export type { + CreateImageGenerationTaskArtifactRequest, + ListMediaTaskArtifactsOutput, + ListMediaTaskArtifactsRequest, + MediaTaskArtifactOutput, + MediaTaskArtifactRecord, + MediaTaskListFilters, + MediaTaskLookupRequest, +} from "./agentRuntime"; + +export { + cancelMediaTaskArtifact, + createImageGenerationTaskArtifact, + getMediaTaskArtifact, + listMediaTaskArtifacts, + retryMediaTaskArtifact, +} from "./agentRuntime"; diff --git a/src/lib/companion/petQuickActions.test.ts b/src/lib/companion/petQuickActions.test.ts new file mode 100644 index 000000000..fa727c773 --- /dev/null +++ b/src/lib/companion/petQuickActions.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { apiKeyProviderApi } from "@/lib/api/apiKeyProvider"; +import { getConfig } from "@/lib/api/appConfig"; +import { + getAgentRuntimeSession, + listAgentRuntimeSessions, +} from "@/lib/api/agentRuntime"; +import { + resetCompanionPetConversationHistory, + runCompanionPetConversation, + runCompanionPetQuickAction, + selectCompanionQuickActionProvider, +} from "./petQuickActions"; + +vi.mock("@/lib/api/apiKeyProvider", () => ({ + apiKeyProviderApi: { + getProviders: vi.fn(), + testChat: vi.fn(), + }, +})); + +vi.mock("@/lib/api/appConfig", () => ({ + getConfig: vi.fn(), +})); + +vi.mock("@/lib/api/agentRuntime", () => ({ + listAgentRuntimeSessions: vi.fn(), + getAgentRuntimeSession: vi.fn(), +})); + +function createProvider(overrides: Record = {}) { + return { + id: "openai", + name: "OpenAI", + type: "openai", + api_host: "https://api.openai.com/v1", + is_system: true, + group: "cloud", + enabled: true, + sort_order: 0, + custom_models: [], + api_key_count: 1, + api_keys: [ + { + id: "key-1", + provider_id: "openai", + api_key_masked: "sk-***1234", + enabled: true, + usage_count: 0, + error_count: 0, + created_at: "2026-04-02T00:00:00Z", + }, + ], + created_at: "2026-04-02T00:00:00Z", + updated_at: "2026-04-02T00:00:00Z", + ...overrides, + }; +} + +describe("petQuickActions", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + resetCompanionPetConversationHistory(); + vi.mocked(getConfig).mockResolvedValue({ + workspace_preferences: {}, + } as never); + vi.mocked(listAgentRuntimeSessions).mockResolvedValue([]); + vi.mocked(getAgentRuntimeSession).mockResolvedValue({ + execution_runtime: null, + } as never); + }); + + it("应优先选择启用且有可用 key 的服务商", () => { + const selected = selectCompanionQuickActionProvider([ + createProvider({ + id: "disabled-openai", + enabled: false, + }), + createProvider({ + id: "deepseek", + name: "DeepSeek", + sort_order: 1, + }), + createProvider({ + id: "openai", + sort_order: 10, + }), + ]); + + expect(selected?.id).toBe("deepseek"); + }); + + it("执行鼓励动作时,应调用 chat 测试接口并输出短气泡", async () => { + vi.mocked(apiKeyProviderApi.getProviders).mockResolvedValue([ + createProvider({ + id: "deepseek", + name: "DeepSeek", + }), + ]); + vi.mocked(apiKeyProviderApi.testChat).mockResolvedValue({ + success: true, + content: " 继续向前,青柠陪你一起做完它 ", + latency_ms: 88, + }); + + await expect(runCompanionPetQuickAction("cheer")).resolves.toEqual({ + bubbleText: "继续向前,青柠陪你一起做完它", + providerId: "deepseek", + latencyMs: 88, + }); + expect(apiKeyProviderApi.testChat).toHaveBeenCalledWith( + "deepseek", + undefined, + expect.stringContaining("Lime 青柠精灵"), + ); + }); + + it("执行桌宠对话时,应复用宿主侧 provider 并生成短回复", async () => { + vi.mocked(apiKeyProviderApi.getProviders).mockResolvedValue([ + createProvider({ + id: "deepseek", + name: "DeepSeek", + }), + ]); + vi.mocked(apiKeyProviderApi.testChat).mockResolvedValue({ + success: true, + content: " [joy] 今天先把节奏放慢一点,我陪你一起往前走。 ", + latency_ms: 96, + }); + + await expect( + runCompanionPetConversation("今天有点累,你在吗"), + ).resolves.toEqual({ + bubbleText: "今天先把节奏放慢一点,我陪你一起往前走。", + providerId: "deepseek", + latencyMs: 96, + live2dAction: { + emotion_tags: ["joy"], + }, + }); + + expect(apiKeyProviderApi.testChat).toHaveBeenCalledWith( + "deepseek", + undefined, + expect.stringContaining("今天有点累,你在吗"), + ); + expect(apiKeyProviderApi.testChat).toHaveBeenCalledWith( + "deepseek", + undefined, + expect.stringContaining("[joy]"), + ); + }); + + it("桌宠连续对话时,应把最近几轮上下文带进下一次请求", async () => { + vi.mocked(apiKeyProviderApi.getProviders).mockResolvedValue([ + createProvider({ + id: "deepseek", + name: "DeepSeek", + }), + ]); + vi.mocked(apiKeyProviderApi.testChat) + .mockResolvedValueOnce({ + success: true, + content: "[joy]当然在,我陪你慢慢来", + latency_ms: 90, + }) + .mockResolvedValueOnce({ + success: true, + content: "那我们先把最小的一步做完", + latency_ms: 92, + }); + + await runCompanionPetConversation("今天有点累"); + await runCompanionPetConversation("那我现在先做什么"); + + expect(apiKeyProviderApi.testChat).toHaveBeenNthCalledWith( + 2, + "deepseek", + undefined, + expect.stringContaining("用户:今天有点累"), + ); + expect(apiKeyProviderApi.testChat).toHaveBeenNthCalledWith( + 2, + "deepseek", + undefined, + expect.stringContaining("青柠:当然在,我陪你慢慢来"), + ); + }); + + it("已设置桌宠通用模型时,应优先使用桌宠专用 provider/model", async () => { + vi.mocked(getConfig).mockResolvedValue({ + workspace_preferences: { + companion_defaults: { + general: { + preferredProviderId: "openai", + preferredModelId: "gpt-4.1", + allowFallback: false, + }, + }, + }, + } as never); + vi.mocked(apiKeyProviderApi.getProviders).mockResolvedValue([ + createProvider({ + id: "openai", + name: "OpenAI", + custom_models: ["gpt-4.1"], + }), + createProvider({ + id: "deepseek", + name: "DeepSeek", + sort_order: 1, + }), + ]); + vi.mocked(apiKeyProviderApi.testChat).mockResolvedValue({ + success: true, + content: "青柠就按你的专属模型来啦", + latency_ms: 120, + }); + + await runCompanionPetQuickAction("cheer"); + + expect(apiKeyProviderApi.testChat).toHaveBeenCalledWith( + "openai", + "gpt-4.1", + expect.any(String), + ); + }); + + it("未设置桌宠模型时,应优先回退最近当前 provider/model", async () => { + vi.mocked(apiKeyProviderApi.getProviders).mockResolvedValue([ + createProvider({ + id: "openai", + name: "OpenAI", + sort_order: 10, + }), + createProvider({ + id: "deepseek", + name: "DeepSeek", + sort_order: 20, + }), + ]); + vi.mocked(listAgentRuntimeSessions).mockResolvedValue([ + { + id: "session-1", + created_at: 1, + updated_at: 20, + workspace_id: "workspace-a", + }, + ] as never); + vi.mocked(getAgentRuntimeSession).mockResolvedValue({ + execution_runtime: { + provider_selector: "deepseek", + provider_name: "DeepSeek", + model_name: "deepseek-chat", + }, + } as never); + vi.mocked(apiKeyProviderApi.testChat).mockResolvedValue({ + success: true, + content: "先把最小的一步做掉", + latency_ms: 66, + }); + + await runCompanionPetQuickAction("next-step"); + + expect(apiKeyProviderApi.testChat).toHaveBeenCalledWith( + "deepseek", + "deepseek-chat", + expect.any(String), + ); + }); + + it("当前 provider 不可用时,应自动回退到可聊天服务商", async () => { + vi.mocked(apiKeyProviderApi.getProviders).mockResolvedValue([ + createProvider({ + id: "openai", + name: "OpenAI", + sort_order: 1, + }), + ]); + vi.mocked(listAgentRuntimeSessions).mockResolvedValue([ + { + id: "session-2", + created_at: 1, + updated_at: 30, + }, + ] as never); + vi.mocked(getAgentRuntimeSession).mockResolvedValue({ + execution_runtime: { + provider_selector: "claude", + provider_name: "Claude", + model_name: "claude-sonnet-4-5", + }, + } as never); + vi.mocked(apiKeyProviderApi.testChat).mockResolvedValue({ + success: true, + content: "青柠帮你切到当前可用服务啦", + latency_ms: 75, + }); + + await runCompanionPetQuickAction("cheer"); + + expect(apiKeyProviderApi.testChat).toHaveBeenCalledWith( + "openai", + undefined, + expect.any(String), + ); + }); + + it("没有可聊天服务商时,应给出明确错误", async () => { + vi.mocked(apiKeyProviderApi.getProviders).mockResolvedValue([]); + + await expect(runCompanionPetQuickAction("next-step")).rejects.toThrow( + "还没找到可聊天的 AI 服务商", + ); + }); +}); diff --git a/src/lib/companion/petQuickActions.ts b/src/lib/companion/petQuickActions.ts new file mode 100644 index 000000000..959087199 --- /dev/null +++ b/src/lib/companion/petQuickActions.ts @@ -0,0 +1,289 @@ +import { apiKeyProviderApi } from "@/lib/api/apiKeyProvider"; +import type { CompanionPetLive2DActionPayload } from "@/lib/api/companion"; +import { getConfig } from "@/lib/api/appConfig"; +import { + getCompanionDefaultsFromConfig, + resolveCompanionQuickActionTarget, +} from "./preferences"; + +export { selectCompanionQuickActionProvider } from "./preferences"; + +export type CompanionPetQuickAction = "cheer" | "next-step"; + +export interface CompanionPetQuickActionResult { + bubbleText: string; + providerId: string; + latencyMs?: number; +} + +export interface CompanionPetConversationResult { + bubbleText: string; + providerId: string; + latencyMs?: number; + live2dAction?: CompanionPetLive2DActionPayload; +} + +interface CompanionPetConversationTurn { + role: "user" | "assistant"; + content: string; +} + +const SUPPORTED_LIVE2D_EMOTION_TAGS = [ + "neutral", + "joy", + "sadness", + "surprise", + "anger", + "fear", + "disgust", + "smirk", +] as const; + +const supportedLive2DEmotionTagSet = new Set( + SUPPORTED_LIVE2D_EMOTION_TAGS, +); +const MAX_PET_CONVERSATION_TURNS = 6; +const petConversationHistory: CompanionPetConversationTurn[] = []; + +function normalizeText(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function buildPrompt(action: CompanionPetQuickAction): string { + switch (action) { + case "next-step": + return [ + "你是“Lime 青柠精灵”桌宠。", + "请只输出一句中文下一步行动建议。", + "要求具体、轻量、可立刻执行,不超过26个汉字。", + "不要使用表情、引号、换行、编号,也不要解释原因。", + ].join(""); + case "cheer": + default: + return [ + "你是“Lime 青柠精灵”桌宠。", + "请只输出一句中文陪伴或鼓励短句。", + "语气温柔机灵,不超过24个汉字。", + "不要使用表情、引号、换行、编号,也不要自我介绍。", + ].join(""); + } +} + +function buildConversationPrompt(userInput: string): string { + const historyBlock = + petConversationHistory.length > 0 + ? [ + "最近几轮对话如下,请自然延续语气和上下文。", + ...petConversationHistory.map((turn) => + turn.role === "user" + ? `用户:${turn.content}` + : `青柠:${turn.content}`, + ), + ].join("") + : ""; + + return [ + "你是“Lime 青柠精灵”桌宠。", + "用户正在直接和你说话。", + historyBlock, + "请直接用中文回复用户,最多两句,总长度不超过48个汉字。", + `为了驱动 Live2D,你可以插入 0 到 2 个情绪标签:${SUPPORTED_LIVE2D_EMOTION_TAGS.map( + (tag) => `[${tag}]`, + ).join(" ")}。`, + "标签可放在句首或句中,但除了这些标签以外,不要输出任何方括号内容。", + "语气温柔、机灵、自然,像桌边陪伴,不要使用表情、引号、编号、标题或换行。", + `用户输入:${userInput}`, + ].join(""); +} + +function fallbackBubbleText(action: CompanionPetQuickAction): string { + return action === "next-step" ? "先把眼前最小的一步做掉" : "青柠会一直陪着你"; +} + +function fallbackConversationBubbleText(): string { + return "我在呢,我们慢慢说"; +} + +function sanitizeBubbleCandidate(value: string | undefined): string { + return normalizeText(value || "") + .replace(/^["“”'`]+/, "") + .replace(/["“”'`]+$/, "") + .replace(/^[\d*.\s、-]+/, ""); +} + +function normalizeBubbleText( + action: CompanionPetQuickAction, + content: string | undefined, +): string { + const compact = sanitizeBubbleCandidate(content); + + if (!compact) { + return fallbackBubbleText(action); + } + + const chars = Array.from(compact); + if (chars.length <= 30) { + return compact; + } + + return `${chars.slice(0, 30).join("")}…`; +} + +interface NormalizedConversationBubble { + bubbleText: string; + emotionTags: string[]; +} + +function normalizeConversationBubble( + content: string | undefined, +): NormalizedConversationBubble { + const emotionTags: string[] = []; + const contentWithoutTags = (content || "").replace( + /\[([a-z0-9_-]+)\]/gi, + (match, rawTag: string) => { + const tag = rawTag.toLowerCase(); + if (!supportedLive2DEmotionTagSet.has(tag)) { + return match; + } + if (!emotionTags.includes(tag)) { + emotionTags.push(tag); + } + return " "; + }, + ); + const compact = sanitizeBubbleCandidate(contentWithoutTags); + + if (!compact) { + return { + bubbleText: fallbackConversationBubbleText(), + emotionTags, + }; + } + + const chars = Array.from(compact); + if (chars.length <= 56) { + return { + bubbleText: compact, + emotionTags, + }; + } + + return { + bubbleText: `${chars.slice(0, 56).join("")}…`, + emotionTags, + }; +} + +function missingProviderMessage(): string { + return "还没找到可聊天的 AI 服务商,先去 Lime 里配置一个吧"; +} + +function appendPetConversationTurn( + role: CompanionPetConversationTurn["role"], + content: string, +): void { + const normalizedContent = normalizeText(content); + if (!normalizedContent) { + return; + } + + petConversationHistory.push({ + role, + content: normalizedContent, + }); + + if (petConversationHistory.length > MAX_PET_CONVERSATION_TURNS) { + petConversationHistory.splice( + 0, + petConversationHistory.length - MAX_PET_CONVERSATION_TURNS, + ); + } +} + +export function resetCompanionPetConversationHistory(): void { + petConversationHistory.length = 0; +} + +async function resolveGeneralChatTarget() { + const [config, providers] = await Promise.all([ + getConfig(), + apiKeyProviderApi.getProviders({ + forceRefresh: true, + }), + ]); + const companionDefaults = getCompanionDefaultsFromConfig(config); + return resolveCompanionQuickActionTarget( + providers, + companionDefaults.general, + ); +} + +export async function runCompanionPetQuickAction( + action: CompanionPetQuickAction, +): Promise { + const target = await resolveGeneralChatTarget(); + + if (!target) { + throw new Error(missingProviderMessage()); + } + + const result = await apiKeyProviderApi.testChat( + target.provider.id, + target.modelName, + buildPrompt(action), + ); + + if (!result.success) { + throw new Error( + normalizeText(result.error || "") || "青柠这次暂时没有连上可用模型", + ); + } + + return { + bubbleText: normalizeBubbleText(action, result.content), + providerId: target.provider.id, + latencyMs: result.latency_ms, + }; +} + +export async function runCompanionPetConversation( + input: string, +): Promise { + const normalizedInput = normalizeText(input); + if (!normalizedInput) { + throw new Error("你先跟我说一句话吧"); + } + + const target = await resolveGeneralChatTarget(); + if (!target) { + throw new Error(missingProviderMessage()); + } + + const result = await apiKeyProviderApi.testChat( + target.provider.id, + target.modelName, + buildConversationPrompt(normalizedInput), + ); + + if (!result.success) { + throw new Error( + normalizeText(result.error || "") || "青柠这次暂时没想好怎么回你", + ); + } + + const normalizedConversation = normalizeConversationBubble(result.content); + appendPetConversationTurn("user", normalizedInput); + appendPetConversationTurn("assistant", normalizedConversation.bubbleText); + + return { + bubbleText: normalizedConversation.bubbleText, + providerId: target.provider.id, + latencyMs: result.latency_ms, + live2dAction: + normalizedConversation.emotionTags.length > 0 + ? { + emotion_tags: normalizedConversation.emotionTags, + } + : undefined, + }; +} diff --git a/src/lib/companion/preferences.ts b/src/lib/companion/preferences.ts new file mode 100644 index 000000000..ccbb50884 --- /dev/null +++ b/src/lib/companion/preferences.ts @@ -0,0 +1,192 @@ +import type { SessionModelPreference } from "@/components/agent/chat/hooks/agentChatShared"; +import { + GLOBAL_MODEL_PREF_KEY, + GLOBAL_PROVIDER_PREF_KEY, + getAgentPreferenceKeys, + loadPersistedString, + resolveWorkspaceAgentPreferences, +} from "@/components/agent/chat/hooks/agentChatStorage"; +import { createSessionModelPreferenceFromExecutionRuntime } from "@/components/agent/chat/utils/sessionExecutionRuntime"; +import { + getAgentRuntimeSession, + listAgentRuntimeSessions, +} from "@/lib/api/agentRuntime"; +import type { ProviderWithKeysDisplay } from "@/lib/api/apiKeyProvider"; +import type { CompanionDefaultsConfig, Config } from "@/lib/api/appConfigTypes"; +import { + normalizeMediaGenerationPreference, + type MediaGenerationPreference, +} from "@/lib/mediaGeneration"; + +export interface ResolvedCompanionQuickActionTarget { + provider: ProviderWithKeysDisplay; + modelName?: string; + source: "companion-config" | "current-agent" | "auto"; +} + +function normalizeProviderId(value?: string | null): string { + return value?.trim().toLowerCase() || ""; +} + +function hasEnabledApiKey(provider: ProviderWithKeysDisplay): boolean { + return provider.enabled && provider.api_keys.some((item) => item.enabled); +} + +function isKeylessChatProvider(provider: ProviderWithKeysDisplay): boolean { + return ( + provider.enabled && + normalizeProviderId(provider.type) === "ollama" && + provider.api_host.trim().length > 0 + ); +} + +function findUsableProvider( + providers: ProviderWithKeysDisplay[], + providerId?: string | null, +): ProviderWithKeysDisplay | null { + const normalizedProviderId = normalizeProviderId(providerId); + if (!normalizedProviderId) { + return null; + } + + const provider = + providers.find( + (item) => normalizeProviderId(item.id) === normalizedProviderId, + ) ?? null; + + if (!provider || !canUseCompanionQuickActionProvider(provider)) { + return null; + } + + return provider; +} + +function loadPersistedCurrentAgentPreference(): SessionModelPreference | null { + const { providerKey, modelKey } = getAgentPreferenceKeys(undefined); + const rawProvider = + loadPersistedString(providerKey) || + loadPersistedString("agent_pref_provider") || + loadPersistedString(GLOBAL_PROVIDER_PREF_KEY); + const rawModel = + loadPersistedString(modelKey) || + loadPersistedString("agent_pref_model") || + loadPersistedString(GLOBAL_MODEL_PREF_KEY); + + if (!rawProvider || !rawModel) { + return null; + } + + return resolveWorkspaceAgentPreferences(undefined); +} + +async function loadRuntimeCurrentAgentPreference(): Promise { + try { + const latestSession = [...(await listAgentRuntimeSessions())].sort( + (left, right) => right.updated_at - left.updated_at, + )[0]; + + if (!latestSession) { + return null; + } + + const detail = await getAgentRuntimeSession(latestSession.id); + return createSessionModelPreferenceFromExecutionRuntime( + detail.execution_runtime, + ); + } catch (error) { + console.warn( + "[Companion] 读取最近当前 provider/model 失败,改用本地与自动回退:", + error, + ); + return null; + } +} + +export function getCompanionDefaultsFromConfig( + config?: Pick | null, +): CompanionDefaultsConfig { + return config?.workspace_preferences?.companion_defaults ?? {}; +} + +export function canUseCompanionQuickActionProvider( + provider: ProviderWithKeysDisplay, +): boolean { + return hasEnabledApiKey(provider) || isKeylessChatProvider(provider); +} + +export function selectCompanionQuickActionProvider( + providers: ProviderWithKeysDisplay[], +): ProviderWithKeysDisplay | null { + return ( + [...providers] + .filter((provider) => canUseCompanionQuickActionProvider(provider)) + .sort((left, right) => { + if (left.sort_order !== right.sort_order) { + return left.sort_order - right.sort_order; + } + return left.name.localeCompare(right.name, "zh-CN"); + })[0] ?? null + ); +} + +export async function loadCurrentCompanionAgentPreference(): Promise { + const runtimePreference = await loadRuntimeCurrentAgentPreference(); + if (runtimePreference) { + return runtimePreference; + } + + return loadPersistedCurrentAgentPreference(); +} + +export async function resolveCompanionQuickActionTarget( + providers: ProviderWithKeysDisplay[], + preference?: MediaGenerationPreference | null, +): Promise { + const normalizedPreference = normalizeMediaGenerationPreference(preference); + + if (normalizedPreference.preferredProviderId) { + const configuredProvider = findUsableProvider( + providers, + normalizedPreference.preferredProviderId, + ); + + if (configuredProvider) { + return { + provider: configuredProvider, + modelName: normalizedPreference.preferredModelId, + source: "companion-config", + }; + } + + if (normalizedPreference.allowFallback === false) { + throw new Error( + `桌宠通用模型 Provider 不可用:${normalizedPreference.preferredProviderId}`, + ); + } + } + + const currentPreference = await loadCurrentCompanionAgentPreference(); + if (currentPreference) { + const currentProvider = findUsableProvider( + providers, + currentPreference.providerType, + ); + if (currentProvider) { + return { + provider: currentProvider, + modelName: currentPreference.model, + source: "current-agent", + }; + } + } + + const autoProvider = selectCompanionQuickActionProvider(providers); + if (!autoProvider) { + return null; + } + + return { + provider: autoProvider, + source: "auto", + }; +} diff --git a/src/lib/governance/agentCommandCatalog.json b/src/lib/governance/agentCommandCatalog.json index 65bc5e69b..e88c31f8e 100644 --- a/src/lib/governance/agentCommandCatalog.json +++ b/src/lib/governance/agentCommandCatalog.json @@ -42,7 +42,12 @@ "site_import_adapter_yaml_bundle", "site_run_adapter", "site_debug_run_adapter", - "site_save_adapter_result" + "site_save_adapter_result", + "create_image_generation_task_artifact", + "get_media_task_artifact", + "list_media_task_artifacts", + "retry_media_task_artifact", + "cancel_media_task_artifact" ], "desktopCompanionCommands": [ "companion_get_pet_status", diff --git a/src/lib/governance/legacySurfaceCatalog.json b/src/lib/governance/legacySurfaceCatalog.json index e12a8bea5..e118e3154 100644 --- a/src/lib/governance/legacySurfaceCatalog.json +++ b/src/lib/governance/legacySurfaceCatalog.json @@ -56,6 +56,64 @@ "targets": ["src/components/agent/chat/hooks/useAgentChat.ts"], "allowedPaths": [] }, + { + "id": "claw-home-shell-entry", + "classification": "dead-candidate", + "description": "已删除的旧 Claw 首页壳入口", + "targets": ["src/components/agent/chat/AgentChatHomeShell.tsx"], + "allowedPaths": [] + }, + { + "id": "claw-home-shell-preference-hook", + "classification": "dead-candidate", + "description": "已删除的旧 Claw 首页壳偏好恢复 Hook", + "targets": ["src/components/agent/chat/hooks/useHomeShellAgentPreferences.ts"], + "allowedPaths": [] + }, + { + "id": "claw-home-shell-project-memory-hook", + "classification": "dead-candidate", + "description": "已删除的旧 Claw 首页项目记忆 Hook", + "targets": ["src/components/agent/chat/hooks/useHomeShellProjectMemory.ts"], + "allowedPaths": [] + }, + { + "id": "claw-home-shell-skills-hook", + "classification": "dead-candidate", + "description": "已删除的旧 Claw 首页技能加载 Hook", + "targets": ["src/components/agent/chat/hooks/useHomeShellSkills.ts"], + "allowedPaths": [] + }, + { + "id": "claw-home-shell-solution-surface", + "classification": "dead-candidate", + "description": "已删除的旧 Claw 首页推荐方案组件与辅助模块", + "targets": [ + "src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.tsx", + "src/components/agent/chat/claw-solutions/useClawSolutions.ts", + "src/components/agent/chat/claw-solutions/actionDispatcher.ts", + "src/components/agent/chat/claw-solutions/storage.ts", + "src/components/agent/chat/claw-solutions/types.ts" + ], + "allowedPaths": [] + }, + { + "id": "claw-solution-legacy-api-gateway", + "classification": "dead-candidate", + "description": "已删除的旧 Claw 推荐方案前端 API 网关", + "targets": ["src/lib/api/clawSolutions.ts"], + "allowedPaths": [] + }, + { + "id": "claw-solution-legacy-rust-modules", + "classification": "dead-candidate", + "description": "已删除的旧 Claw 推荐方案 Rust 命令与服务模块", + "targets": [ + "src-tauri/src/commands/claw_solution_cmd.rs", + "src-tauri/src/services/claw_solution_service.rs" + ], + "allowedPaths": [] + }, { "id": "heartbeat-api-gateway", "classification": "dead-candidate", @@ -733,6 +791,18 @@ ], "allowedPaths": [] }, + { + "id": "claw-solution-legacy-commands", + "classification": "dead-candidate", + "description": "已删除的旧 Claw 首页推荐方案命令边界", + "commands": [ + "claw_solution_list", + "claw_solution_detail", + "claw_solution_check_readiness", + "claw_solution_prepare" + ], + "allowedPaths": [] + }, { "id": "agent-create-session-compat-command", "classification": "dead-candidate", diff --git a/src/lib/provider/companionProviderOverview.test.ts b/src/lib/provider/companionProviderOverview.test.ts index 50333d535..c5d2b7459 100644 --- a/src/lib/provider/companionProviderOverview.test.ts +++ b/src/lib/provider/companionProviderOverview.test.ts @@ -157,4 +157,79 @@ describe("buildCompanionProviderOverview", () => { expect(serialized).not.toContain("base_url"); expect(serialized).not.toContain("display_credential"); }); + + it("应把 API Key Provider 与凭证池摘要合并给桌宠", () => { + const result = buildCompanionProviderOverview( + [ + { + provider_type: "openai", + stats: { + total: 1, + healthy: 1, + unhealthy: 0, + disabled: 0, + total_usage: 2, + total_errors: 0, + }, + credentials: [], + }, + ], + [ + { + id: "deepseek", + name: "DeepSeek", + type: "openai", + api_host: "https://api.deepseek.com/v1", + is_system: false, + group: "cloud", + enabled: true, + sort_order: 10, + custom_models: [], + api_key_count: 1, + api_keys: [ + { + id: "deepseek-key-1", + provider_id: "deepseek", + api_key_masked: "sk-***1234", + enabled: true, + usage_count: 0, + error_count: 0, + created_at: "2026-04-01T00:00:00Z", + }, + ], + created_at: "2026-04-01T00:00:00Z", + updated_at: "2026-04-01T00:00:00Z", + }, + ], + ); + + expect(result).toEqual({ + providers: [ + { + provider_type: "deepseek", + display_name: "DeepSeek", + total_count: 1, + healthy_count: 1, + available: true, + needs_attention: false, + }, + { + provider_type: "openai", + display_name: "OpenAI", + total_count: 1, + healthy_count: 1, + available: true, + needs_attention: false, + }, + ], + total_provider_count: 2, + available_provider_count: 2, + needs_attention_provider_count: 0, + }); + + const serialized = JSON.stringify(result); + expect(serialized).not.toContain("sk-***1234"); + expect(serialized).not.toContain("deepseek-key-1"); + expect(serialized).not.toContain("api_host"); + }); }); diff --git a/src/lib/provider/companionProviderOverview.ts b/src/lib/provider/companionProviderOverview.ts index 4a43cb400..fb34c40d1 100644 --- a/src/lib/provider/companionProviderOverview.ts +++ b/src/lib/provider/companionProviderOverview.ts @@ -1,4 +1,11 @@ -import type { ProviderPoolOverview } from "@/lib/api/providerPool"; +import { + apiKeyProviderApi, + type ProviderWithKeysDisplay, +} from "@/lib/api/apiKeyProvider"; +import { + providerPoolApi, + type ProviderPoolOverview, +} from "@/lib/api/providerPool"; import { getProviderLabel } from "@/lib/constants/providerMappings"; export interface CompanionProviderSummary { @@ -17,6 +24,10 @@ export interface CompanionProviderOverviewPayload { needs_attention_provider_count: number; } +interface LoadCompanionProviderOverviewOptions { + forceRefresh?: boolean; +} + function buildProviderSummary( pool: ProviderPoolOverview, ): CompanionProviderSummary { @@ -37,13 +48,100 @@ function buildProviderSummary( }; } +function normalizeProviderKey(value?: string | null): string { + return (value || "").trim().toLowerCase(); +} + +function hasConfiguredKeylessAccess( + provider: ProviderWithKeysDisplay, +): boolean { + return ( + normalizeProviderKey(provider.type) === "ollama" && + provider.api_host.trim().length > 0 + ); +} + +function buildApiKeyProviderSummary( + provider: ProviderWithKeysDisplay, +): CompanionProviderSummary | null { + const keylessAccessConfigured = hasConfiguredKeylessAccess(provider); + const totalCount = + provider.api_keys.length > 0 ? provider.api_keys.length : keylessAccessConfigured ? 1 : 0; + + if (totalCount === 0) { + return null; + } + + const enabledApiKeyCount = provider.enabled + ? provider.api_keys.filter((item) => item.enabled).length + : 0; + const healthyCount = + enabledApiKeyCount > 0 + ? enabledApiKeyCount + : provider.enabled && keylessAccessConfigured + ? 1 + : 0; + const displayName = provider.name.trim() || getProviderLabel(provider.id); + + return { + provider_type: provider.id, + display_name: displayName, + total_count: totalCount, + healthy_count: healthyCount, + available: healthyCount > 0, + needs_attention: totalCount > healthyCount, + }; +} + +function mergeProviderSummary( + current: CompanionProviderSummary | undefined, + incoming: CompanionProviderSummary, +): CompanionProviderSummary { + if (!current) { + return incoming; + } + + const totalCount = current.total_count + incoming.total_count; + const healthyCount = current.healthy_count + incoming.healthy_count; + + return { + provider_type: current.provider_type, + display_name: current.display_name || incoming.display_name, + total_count: totalCount, + healthy_count: healthyCount, + available: healthyCount > 0, + needs_attention: + current.needs_attention || + incoming.needs_attention || + totalCount > healthyCount, + }; +} + export function buildCompanionProviderOverview( overview: ProviderPoolOverview[], + apiKeyProviders: ProviderWithKeysDisplay[] = [], ): CompanionProviderOverviewPayload { - const providers = overview + const providerMap = new Map(); + + overview .filter((pool) => pool.stats.total > 0) - .sort((left, right) => left.provider_type.localeCompare(right.provider_type)) - .map(buildProviderSummary); + .map(buildProviderSummary) + .forEach((summary) => { + const key = normalizeProviderKey(summary.provider_type); + providerMap.set(key, mergeProviderSummary(providerMap.get(key), summary)); + }); + + apiKeyProviders + .map(buildApiKeyProviderSummary) + .filter((summary): summary is CompanionProviderSummary => summary !== null) + .forEach((summary) => { + const key = normalizeProviderKey(summary.provider_type); + providerMap.set(key, mergeProviderSummary(providerMap.get(key), summary)); + }); + + const providers = Array.from(providerMap.values()).sort((left, right) => + left.display_name.localeCompare(right.display_name, "zh-CN"), + ); return { providers, @@ -54,3 +152,15 @@ export function buildCompanionProviderOverview( ).length, }; } + +export async function loadCompanionProviderOverview( + options: LoadCompanionProviderOverviewOptions = {}, +): Promise { + const sourceOptions = options.forceRefresh ? { forceRefresh: true } : undefined; + const [overview, apiKeyProviders] = await Promise.all([ + providerPoolApi.getOverview(sourceOptions), + apiKeyProviderApi.getProviders(sourceOptions), + ]); + + return buildCompanionProviderOverview(overview, apiKeyProviders); +} diff --git a/src/lib/tauri-mock/core.test.ts b/src/lib/tauri-mock/core.test.ts index 60802a06d..235156c9f 100644 --- a/src/lib/tauri-mock/core.test.ts +++ b/src/lib/tauri-mock/core.test.ts @@ -78,8 +78,20 @@ describe("tauri-mock/core invoke", () => { }), default_allowed_tools: expect.arrayContaining([ "ToolSearch", + "ListMcpResourcesTool", + "ReadMcpResourceTool", + "Bash", "WebSearch", - "ask", + "WebFetch", + "AskUserQuestion", + "SendUserMessage", + "SendMessage", + "TeamCreate", + "TeamDelete", + "ListPeers", + "RemoteTrigger", + "TaskCreate", + "Workflow", ]), counts: expect.objectContaining({ catalog_total: expect.any(Number), @@ -87,10 +99,26 @@ describe("tauri-mock/core invoke", () => { }), catalog_tools: expect.arrayContaining([ expect.objectContaining({ name: "ToolSearch" }), + expect.objectContaining({ name: "ListMcpResourcesTool" }), + expect.objectContaining({ name: "Bash" }), expect.objectContaining({ name: "WebSearch" }), + expect.objectContaining({ name: "WebFetch" }), + expect.objectContaining({ name: "SendUserMessage" }), + expect.objectContaining({ name: "RemoteTrigger" }), + expect.objectContaining({ name: "CronCreate" }), ]), registry_tools: expect.arrayContaining([ - expect.objectContaining({ name: "ask" }), + expect.objectContaining({ name: "AskUserQuestion" }), + expect.objectContaining({ name: "SendUserMessage" }), + expect.objectContaining({ name: "ReadMcpResourceTool" }), + expect.objectContaining({ name: "EnterPlanMode" }), + expect.objectContaining({ name: "SendMessage" }), + expect.objectContaining({ name: "TeamCreate" }), + expect.objectContaining({ name: "TeamDelete" }), + expect.objectContaining({ name: "ListPeers" }), + expect.objectContaining({ name: "CronList" }), + expect.objectContaining({ name: "TaskOutput" }), + expect.objectContaining({ name: "ExitWorktree" }), ]), mcp_tools: expect.arrayContaining([ expect.objectContaining({ name: "mcp__lime-browser__navigate" }), @@ -117,6 +145,37 @@ describe("tauri-mock/core invoke", () => { } }); + it("媒体任务命令在 bridge 失败时应回退统一 task file mock 协议", async () => { + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => {}); + mocks.invokeViaHttp.mockRejectedValueOnce(new Error("Failed to fetch")); + + try { + await expect( + invoke("list_media_task_artifacts", { + request: { + projectRootPath: "/mock/workspace", + taskFamily: "image", + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + success: true, + total: 1, + tasks: expect.arrayContaining([ + expect.objectContaining({ + task_type: "image_generate", + task_family: "image", + }), + ]), + }), + ); + } finally { + consoleWarnSpy.mockRestore(); + } + }); + it("OpenClaw 环境状态命令在 bridge 失败时回退默认 mock", async () => { const consoleWarnSpy = vi .spyOn(console, "warn") diff --git a/src/lib/tauri-mock/core.ts b/src/lib/tauri-mock/core.ts index 588a9f804..f8c64a2a7 100644 --- a/src/lib/tauri-mock/core.ts +++ b/src/lib/tauri-mock/core.ts @@ -44,6 +44,95 @@ const deprecatedAgentCommandMocks = Object.fromEntries( ), ) as Record never>; +function normalizeMockMediaTaskId(taskRef?: string): string { + const normalized = (taskRef || "task-image-mock-1") + .trim() + .replace(/[^a-zA-Z0-9_-]+/g, "-"); + return normalized || "task-image-mock-1"; +} + +function buildMockMediaTaskOutput( + args: any, + overrides?: Partial>, +) { + const request = args?.request ?? args ?? {}; + const taskId = normalizeMockMediaTaskId( + typeof request.taskRef === "string" ? request.taskRef : undefined, + ); + const projectRootPath = + typeof request.projectRootPath === "string" && request.projectRootPath.trim() + ? request.projectRootPath.trim() + : "/mock/workspace"; + const prompt = + typeof request.prompt === "string" && request.prompt.trim() + ? request.prompt.trim() + : "mock image task"; + const status = + typeof overrides?.status === "string" + ? overrides.status + : "pending_submit"; + const normalizedStatus = + typeof overrides?.normalized_status === "string" + ? overrides.normalized_status + : status === "cancelled" + ? "cancelled" + : status === "failed" + ? "failed" + : "pending"; + const attemptCount = + typeof overrides?.attempt_count === "number" ? overrides.attempt_count : 1; + const currentAttemptId = + typeof overrides?.current_attempt_id === "string" + ? overrides.current_attempt_id + : `attempt-${attemptCount}`; + const createdAt = "2026-04-04T00:00:00.000Z"; + const path = `.lime/tasks/image_generate/${taskId}.json`; + const record = { + task_id: taskId, + task_type: "image_generate", + task_family: "image", + title: request.title ?? null, + summary: "mock media task", + payload: { + prompt, + mode: request.mode ?? "generate", + size: request.size ?? "1024x1024", + count: request.count ?? 1, + }, + status, + normalized_status: normalizedStatus, + created_at: createdAt, + current_attempt_id: currentAttemptId, + retry_count: Math.max(attemptCount - 1, 0), + attempts: Array.from({ length: attemptCount }, (_, index) => ({ + attempt_id: `attempt-${index + 1}`, + attempt_index: index + 1, + status: index === attemptCount - 1 ? status : "cancelled", + input_snapshot: { + prompt, + }, + })), + }; + + return { + success: true, + task_id: taskId, + task_type: "image_generate", + task_family: "image", + status, + normalized_status: normalizedStatus, + current_attempt_id: currentAttemptId, + attempt_count: attemptCount, + path, + absolute_path: `${projectRootPath}/${path}`, + artifact_path: path, + absolute_artifact_path: `${projectRootPath}/${path}`, + reused_existing: false, + record, + ...overrides, + }; +} + type MockBrowserProfileRecord = { id: string; profile_key: string; @@ -3427,6 +3516,37 @@ const defaultMocks: Record = { get_video_generation_task: () => null, list_video_generation_tasks: () => [], cancel_video_generation_task: () => null, + create_image_generation_task_artifact: (args: any) => + buildMockMediaTaskOutput(args), + get_media_task_artifact: (args: any) => buildMockMediaTaskOutput(args), + list_media_task_artifacts: (args: any) => { + const request = args?.request ?? args ?? {}; + return { + success: true, + workspace_root: request.projectRootPath ?? "/mock/workspace", + artifact_root: `${request.projectRootPath ?? "/mock/workspace"}/.lime/tasks`, + filters: { + status: request.status ?? null, + task_family: request.taskFamily ?? null, + task_type: request.taskType ?? null, + limit: request.limit ?? null, + }, + total: 1, + tasks: [buildMockMediaTaskOutput(args)], + }; + }, + retry_media_task_artifact: (args: any) => + buildMockMediaTaskOutput(args, { + attempt_count: 2, + current_attempt_id: "attempt-2", + status: "pending_submit", + normalized_status: "pending", + }), + cancel_media_task_artifact: (args: any) => + buildMockMediaTaskOutput(args, { + status: "cancelled", + normalized_status: "cancelled", + }), search_pixabay_images: () => ({ total: 0, total_hits: 0, diff --git a/src/pages/smart-input.tsx b/src/pages/smart-input.tsx index 60661f9cc..fd64f1ef5 100644 --- a/src/pages/smart-input.tsx +++ b/src/pages/smart-input.tsx @@ -23,6 +23,8 @@ import { onVoiceStopRecording, } from "@/lib/api/voiceShortcutEvents"; import { useVoiceSound } from "@/hooks/useVoiceSound"; +import { safeEmit, safeListen } from "@/lib/dev-bridge"; +import { COMPANION_PET_VOICE_TRANSCRIPT_EVENT } from "@/lib/api/companion"; import "./smart-input.css"; // Lime Logo组件 @@ -82,6 +84,21 @@ function getInstructionIdFromUrl(): string | null { return instruction ? decodeURIComponent(instruction) : null; } +type VoiceTarget = "companion-pet"; + +interface VoiceResetPayload { + target?: string | null; +} + +function normalizeVoiceTarget(value: string | null | undefined): VoiceTarget | null { + return value === "companion-pet" ? "companion-pet" : null; +} + +function getVoiceTargetFromUrl(): VoiceTarget | null { + const params = new URLSearchParams(window.location.search); + return normalizeVoiceTarget(params.get("target")); +} + /** 语音状态 */ type VoiceState = "idle" | "recording" | "transcribing" | "polishing"; @@ -101,6 +118,7 @@ export function SmartInputPage() { // 追踪是否已经从 URL 初始化过语音模式 const voiceModeInitializedRef = useRef(false); + const voiceTargetRef = useRef(getVoiceTargetFromUrl()); // 语音音效 const { playStartSound, playStopSound } = useVoiceSound(soundEnabled); @@ -124,6 +142,23 @@ export function SmartInputPage() { setTimeout(() => setErrorMsg(null), 3000); }, []); + const applyResolvedVoiceText = useCallback( + async (finalText: string) => { + if (voiceTargetRef.current === "companion-pet") { + await safeEmit(COMPANION_PET_VOICE_TRANSCRIPT_EVENT, { + text: finalText, + source: "voice_window", + }); + await getCurrentWindow().close(); + return; + } + + setInputValue(finalText); + inputRef.current?.focus(); + }, + [], + ); + // 开始语音模式 const startVoiceMode = useCallback(async () => { console.log("[语音输入] startVoiceMode 被调用,当前状态:", voiceState); @@ -187,6 +222,7 @@ export function SmartInputPage() { const isVoiceMode = getVoiceModeFromUrl(); const isTranslateMode = getTranslateModeFromUrl(); const instructionId = getInstructionIdFromUrl(); + voiceTargetRef.current = getVoiceTargetFromUrl(); console.log( "[语音输入] URL 参数 voice=", @@ -209,6 +245,26 @@ export function SmartInputPage() { } }, [startVoiceMode]); + useEffect(() => { + let unlisten: (() => void) | null = null; + + (async () => { + try { + unlisten = await safeListen("voice-reset", (event) => { + voiceTargetRef.current = + normalizeVoiceTarget(event.payload?.target) ?? getVoiceTargetFromUrl(); + void startVoiceMode(); + }); + } catch (err) { + console.error("[语音输入] 监听重置事件失败:", err); + } + })(); + + return () => { + if (unlisten) unlisten(); + }; + }, [startVoiceMode]); + // 监听后端发送的开始录音事件(窗口已存在时使用) useEffect(() => { let unlisten: (() => void) | null = null; @@ -388,10 +444,9 @@ export function SmartInputPage() { console.error("[语音润色] 失败:", e); } - setInputValue(finalText); setVoiceState("idle"); setVoiceMode(false); - inputRef.current?.focus(); + await applyResolvedVoiceText(finalText); } catch (err) { console.error("[语音识别] 失败:", err); showError("语音识别过程中发生错误"); @@ -399,8 +454,8 @@ export function SmartInputPage() { setVoiceMode(false); } }, - [showError], - ); // 只依赖 showError,其他通过 Ref 获取 + [applyResolvedVoiceText, showError], + ); // 依赖通过 Ref 和稳定回调控制,避免录音闭包拿到旧状态 // 监听快捷键释放事件 useEffect(() => { @@ -494,10 +549,9 @@ export function SmartInputPage() { console.error("[语音润色] 失败:", e); } - setInputValue(finalText); setVoiceState("idle"); setVoiceMode(false); - inputRef.current?.focus(); + await applyResolvedVoiceText(finalText); } catch (err) { console.error("[语音识别] 失败:", err); showError("语音识别过程中发生错误"); @@ -513,10 +567,10 @@ export function SmartInputPage() { }; const unlistenPromise = setupStopListener(); - return () => { - unlistenPromise.then((unlisten) => unlisten()); - }; - }, [voiceMode, showError]); + return () => { + unlistenPromise.then((unlisten) => unlisten()); + }; + }, [applyResolvedVoiceText, voiceMode, showError]); // 关闭窗口 const handleClose = useCallback(async () => { diff --git a/tools/lime-cli/SKILL.md b/tools/lime-cli/SKILL.md new file mode 100644 index 000000000..b299a733a --- /dev/null +++ b/tools/lime-cli/SKILL.md @@ -0,0 +1,31 @@ +--- +name: lime-cli +description: Lime CLI 平台技能,统一任务创建、状态查询、重试、队列与幂等语义。 +--- + +# Lime CLI + +## 何时使用 + +- 需要把业务能力从 GUI 或内部 API 中解耦出来时 +- 需要让 Agent 通过 `Bash` 显式执行任务时 +- 需要结构化 JSON、可测试、可排队、可重试、可幂等的任务边界时 + +## 优先命令 + +- `lime task create ...` +- `lime task status ` +- `lime task list --status failed` +- `lime task retry ` +- `lime task cancel ` +- `lime task result ` +- `lime skill list` +- `lime skill show ` +- `lime doctor` + +## 执行规则 + +- 默认输出 JSON,不要依赖自然语言解析。 +- 创建任务时,优先带 `--idempotency-key`,避免重复提交。 +- 遇到失败时先看 `retryable`、`hint`、`status` 字段。 +- 需要业务域细节时,继续看 `references/` 或各域目录下的 `SKILL.md`。 diff --git a/tools/lime-cli/domains/broadcast/SKILL.md b/tools/lime-cli/domains/broadcast/SKILL.md new file mode 100644 index 000000000..031e4bf08 --- /dev/null +++ b/tools/lime-cli/domains/broadcast/SKILL.md @@ -0,0 +1,12 @@ +--- +name: lime-cli-broadcast +description: 通过 Lime CLI 创建播客文本整理任务。 +--- + +# Broadcast + +- 优先命令:`lime task create broadcast` +- 适用场景:把文章、笔记、采访稿整理成可播报文本材料 +- 必填输入:`--content` +- 常用参数:`--title`、`--audience`、`--tone`、`--duration-hint-minutes` +- 推荐带上:`--idempotency-key` diff --git a/tools/lime-cli/domains/broadcast/references/create.md b/tools/lime-cli/domains/broadcast/references/create.md new file mode 100644 index 000000000..ce7aca8ee --- /dev/null +++ b/tools/lime-cli/domains/broadcast/references/create.md @@ -0,0 +1,20 @@ +# broadcast create + +示例: + +```bash +lime task create broadcast \ + --title "AI 周报" \ + --content "原文内容" \ + --audience "开发者" \ + --tone "理性" \ + --duration-hint-minutes 8 \ + --idempotency-key "broadcast-ai-weekly" +``` + +输出重点: + +- `task_id` +- `task_type = broadcast_generate` +- `status` +- `path` diff --git a/tools/lime-cli/domains/resource-search/SKILL.md b/tools/lime-cli/domains/resource-search/SKILL.md new file mode 100644 index 000000000..36607a985 --- /dev/null +++ b/tools/lime-cli/domains/resource-search/SKILL.md @@ -0,0 +1,12 @@ +--- +name: lime-cli-resource-search +description: 通过 Lime CLI 创建素材检索任务。 +--- + +# Resource Search + +- 优先命令:`lime task create resource-search` +- 适用场景:为当前内容补素材,例如图片、BGM、音效 +- 必填输入:`--resource-type`、`--query`、`--usage` +- 常用参数:`--count`、`--constraint` +- 推荐带上:`--idempotency-key` diff --git a/tools/lime-cli/domains/resource-search/references/create.md b/tools/lime-cli/domains/resource-search/references/create.md new file mode 100644 index 000000000..d94481757 --- /dev/null +++ b/tools/lime-cli/domains/resource-search/references/create.md @@ -0,0 +1,20 @@ +# resource-search create + +示例: + +```bash +lime task create resource-search \ + --resource-type image \ + --query "城市夜景" \ + --usage "封面" \ + --count 6 \ + --constraint "蓝色电影感" \ + --idempotency-key "resource-search-cover-1" +``` + +输出重点: + +- `task_id` +- `task_type = modal_resource_search` +- `status` +- `path` diff --git a/tools/lime-cli/domains/typesetting/SKILL.md b/tools/lime-cli/domains/typesetting/SKILL.md new file mode 100644 index 000000000..30d5e1a7f --- /dev/null +++ b/tools/lime-cli/domains/typesetting/SKILL.md @@ -0,0 +1,12 @@ +--- +name: lime-cli-typesetting +description: 通过 Lime CLI 创建文稿排版优化任务。 +--- + +# Typesetting + +- 优先命令:`lime task create typesetting` +- 适用场景:整理标题层级、段落节奏、列表格式、移动端阅读体验 +- 必填输入:`--content`、`--target-platform` +- 常用参数:`--rule` +- 推荐带上:`--idempotency-key` diff --git a/tools/lime-cli/domains/typesetting/references/create.md b/tools/lime-cli/domains/typesetting/references/create.md new file mode 100644 index 000000000..c46bcc9c3 --- /dev/null +++ b/tools/lime-cli/domains/typesetting/references/create.md @@ -0,0 +1,19 @@ +# typesetting create + +示例: + +```bash +lime task create typesetting \ + --target-platform xiaohongshu \ + --content "原文内容" \ + --rule "短段落" \ + --rule "保留原意" \ + --idempotency-key "typesetting-xhs-article-1" +``` + +输出重点: + +- `task_id` +- `task_type = typesetting` +- `status` +- `path` diff --git a/tools/lime-cli/domains/url-parse/SKILL.md b/tools/lime-cli/domains/url-parse/SKILL.md new file mode 100644 index 000000000..bc1de77b0 --- /dev/null +++ b/tools/lime-cli/domains/url-parse/SKILL.md @@ -0,0 +1,12 @@ +--- +name: lime-cli-url-parse +description: 通过 Lime CLI 创建链接解析任务。 +--- + +# URL Parse + +- 优先命令:`lime task create url-parse` +- 适用场景:把网页链接整理成摘要、要点和后续可加工文本 +- 必填输入:`--url`、`--summary` +- 常用参数:`--key-point`、`--extract-status` +- 推荐带上:`--idempotency-key` diff --git a/tools/lime-cli/domains/url-parse/references/create.md b/tools/lime-cli/domains/url-parse/references/create.md new file mode 100644 index 000000000..5799500b4 --- /dev/null +++ b/tools/lime-cli/domains/url-parse/references/create.md @@ -0,0 +1,20 @@ +# url-parse create + +示例: + +```bash +lime task create url-parse \ + --url "https://example.com/article" \ + --summary "文章摘要" \ + --key-point "观点一" \ + --key-point "观点二" \ + --extract-status ready \ + --idempotency-key "article-example" +``` + +输出重点: + +- `task_id` +- `task_type = url_parse` +- `status` +- `normalized_status` diff --git a/tools/lime-cli/references/command-model.md b/tools/lime-cli/references/command-model.md new file mode 100644 index 000000000..e43ba7c02 --- /dev/null +++ b/tools/lime-cli/references/command-model.md @@ -0,0 +1,24 @@ +# 命令模型 + +## 主命令 + +- `lime task create ...` +- `lime task status ` +- `lime task list` +- `lime task retry ` +- `lime task cancel ` +- `lime task result ` +- `lime skill list` +- `lime skill show ` +- `lime doctor` + +## 输出约定 + +- 标准输出默认 JSON +- 标准错误默认结构化错误 JSON +- 所有任务命令都应返回 `task_id`、`task_type`、`status`、`path` + +## 兼容入口 + +- 保留 `lime media image|cover|video generate` 作为兼容别名 +- 新主线统一收敛到 `lime task create ...` diff --git a/tools/lime-cli/references/error-envelope.md b/tools/lime-cli/references/error-envelope.md new file mode 100644 index 000000000..d69a6f9e2 --- /dev/null +++ b/tools/lime-cli/references/error-envelope.md @@ -0,0 +1,20 @@ +# 错误包络 + +错误输出默认 JSON,字段包括: + +- `success` +- `error_code` +- `error_message` +- `retryable` +- `hint` +- `task_id` +- `idempotency_key` + +常见错误码: + +- `invalid_params` +- `task_not_found` +- `io_error` +- `task_conflict` +- `invalid_state` +- `not_retryable` diff --git a/tools/lime-cli/references/idempotency.md b/tools/lime-cli/references/idempotency.md new file mode 100644 index 000000000..b9d6a4679 --- /dev/null +++ b/tools/lime-cli/references/idempotency.md @@ -0,0 +1,15 @@ +# 幂等规则 + +## 创建命令 + +- 所有 `lime task create ...` 命令都支持 `--idempotency-key` +- 相同业务类型 + 相同幂等键的重复提交,应返回已有任务 + +## 建议 + +- Agent 调用时优先使用稳定幂等键 +- 用户手动重复执行同一命令时,也建议带幂等键 + +## 例外 + +- `retry` 是显式新尝试,不受 `create` 的去重逻辑限制 diff --git a/tools/lime-cli/references/overview.md b/tools/lime-cli/references/overview.md new file mode 100644 index 000000000..d697df04e --- /dev/null +++ b/tools/lime-cli/references/overview.md @@ -0,0 +1,20 @@ +# Lime CLI 概览 + +Lime CLI 的目标是把任务型业务收敛为统一的命令边界,优先服务 Agent、技能和独立测试。 + +适合 CLI 化的能力: + +- 输入输出稳定 +- 结果可结构化 JSON +- 可异步提交并通过状态查询跟踪 +- 适合 `skills + bash` 透明执行 + +当前主线业务: + +- `image` +- `cover` +- `video` +- `broadcast` +- `url-parse` +- `typesetting` +- `resource-search` diff --git a/tools/lime-cli/references/retry-status-queue.md b/tools/lime-cli/references/retry-status-queue.md new file mode 100644 index 000000000..43ebff726 --- /dev/null +++ b/tools/lime-cli/references/retry-status-queue.md @@ -0,0 +1,18 @@ +# 重试、状态与队列 + +## 状态查询 + +- 单任务:`lime task status ` +- 批量查看:`lime task list` +- 按状态过滤:`lime task list --status failed` + +## 重试 + +- 只允许对 `failed` 或 `cancelled` 的任务执行 `retry` +- `retry` 会创建新的任务记录 +- 新任务会保留原输入,并记录 `source_task_id` + +## 队列观察 + +- `queued` 表示任务已进入待处理状态 +- 列表结果应作为 Agent 的事实源,不依赖额外自然语言解释 diff --git a/tools/lime-cli/references/task-lifecycle.md b/tools/lime-cli/references/task-lifecycle.md new file mode 100644 index 000000000..2e6fda008 --- /dev/null +++ b/tools/lime-cli/references/task-lifecycle.md @@ -0,0 +1,23 @@ +# 任务生命周期 + +## 规范状态 + +- `pending` +- `queued` +- `running` +- `succeeded` +- `failed` +- `cancelled` + +## 当前文件记录中的原始状态 + +- `pending_submit` 会被归一为 `pending` +- `queued` 保持不变 +- `processing` / `in_progress` 会被归一为 `running` +- `completed` / `success` 会被归一为 `succeeded` + +## 操作建议 + +- 创建后立即使用 `lime task status ` +- 查看队列时使用 `lime task list --status queued` +- 失败任务通过 `lime task retry ` 创建新尝试