diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 000000000..f611a9393 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,136 @@ +name: PR Gate + +on: + pull_request: + paths: + - ".github/workflows/**" + - "package.json" + - "package-lock.json" + - "src/**" + - "src-tauri/**" + - "scripts/**" + - "eslint.config.js" + - "tsconfig.json" + - "tsconfig.node.json" + - "tailwind.config.js" + - "postcss.config.js" + - "vite.config.ts" + - "index.html" + push: + branches: + - main + paths: + - ".github/workflows/**" + - "package.json" + - "package-lock.json" + - "src/**" + - "src-tauri/**" + - "scripts/**" + - "eslint.config.js" + - "tsconfig.json" + - "tsconfig.node.json" + - "tailwind.config.js" + - "postcss.config.js" + - "vite.config.ts" + - "index.html" + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_NET_RETRY: 10 + RUSTUP_MAX_RETRIES: 10 + CARGO_TERM_COLOR: always + +jobs: + frontend: + name: Frontend Verify + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Run Vitest + run: npm test + + bridge: + name: Bridge & Contracts Verify + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run bridge tests + run: npm run test:bridge + + - name: Run command contracts + run: npm run test:contracts + + rust: + name: Rust Verify + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Linux dependencies + run: | + sudo apt update + sudo apt install -y \ + libwebkit2gtk-4.1-dev \ + build-essential \ + curl \ + wget \ + file \ + libxdo-dev \ + libssl-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libasound2-dev + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + shared-key: pr-gate-rust-ubuntu + cache-on-failure: true + + - name: Run cargo test + run: cargo test --manifest-path "src-tauri/Cargo.toml" + + - name: Run cargo clippy + run: cargo clippy --manifest-path "src-tauri/Cargo.toml" diff --git a/.gitignore b/.gitignore index 6cd66731d..057db82a5 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ __pycache__/ .kiro/ .history docs/prd/ +!docs/prd/ +docs/prd/* +!docs/prd/tools/ +!docs/prd/tools/*.md # Internal roadmap&gongzonghao (private) docs/roadmap/ @@ -72,4 +76,4 @@ lime-claw.png lime.db -.codex-* \ No newline at end of file +.codex-* diff --git a/AGENTS.md b/AGENTS.md index fe8b6e97e..b7883ed03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ 2. **宽度按页面类型选** - 表单页保持窄阅读宽度,卡片/工作台页面使用更宽的自适应内容区,不要整仓统一 `max-width` 3. **中文排版优先** - 避免过大英文 tracking、重复标题和挤压式统计卡文案 4. **渐变只做氛围层** - 禁止用互相打架的多层渐变制造分割感,背景存在感必须弱于内容 +5. **默认禁用半透明主表面** - 弹窗、浮层、工作台容器、主卡片默认使用实体底色,避免 `bg-white/80`、`backdrop-blur` 一类设计造成层级混乱与内容遮挡错觉 ## 详细文档 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index af962eb44..b694999a4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,21 +1,22 @@ -## Lime v0.91.0 +## Lime v0.92.0 ### ✨ 主要更新 -- **Aster 运行时队列正式接入 Lime**:桌面端补齐 runtime queue service、Aster state support 与 session store 协作,Agent 会话恢复、排队执行和 runtime item 映射进一步收口 -- **Agent 聊天输入链路继续统一**:输入栏、空状态、图片附件、模型选择与会话 hooks 继续围绕 Aster 聊天主链路整理,减少旧 compat 路径分叉 -- **模型能力与视觉提示增强**:新增模型能力徽章、视觉能力提示与 provider model list 整理,模型选择和多模态提示更直接 -- **Skills / Social Post 执行链路补齐**:新增技能执行运行时与社交内容技能集成,Aster Skills 在 Lime 内的发现、执行与同步更完整 -- **数据库与治理清理继续推进**:移除旧 `unified_chat` / `tool_hooks` / `three stage workflow` 相关残留,统一到现役 Aster Agent、Memory 与 Workspace 路径 +- **Team Workspace 正式成型**:Agent 聊天页新增 Team Workspace 主工作台、建议栏、Dock 与 Home Shell,围绕多代理协作视图重组交互结构 +- **运行态与工具可视化增强**:`ToolCallDisplay`、Harness 状态面板、Runtime Strip、执行日志与子代理时间线继续增强,工具调用与运行态反馈更完整 +- **Aster Agent 运行时继续收口**:Rust 侧补齐 session store、subagent control、agent tools inventory / execution、runtime queue 及命令桥接,统一现役 Agent Runtime 路径 +- **治理与测试基建升级**:新增 `pr-gate`、本地校验脚本、命令契约检查、workspace smoke 与治理报告更新,发布前自检链路更清晰 +- **Provider / 模型兼容性继续补强**:补充 Novita 与多种 OpenAI/Claude 兼容 provider 细节,推理内容与工具调用适配继续完善 ### ⚠️ 兼容性说明 -- Aster 相关聊天、会话与时间线事实源进一步集中到新的 runtime / session store 路径,旧 compat API 不再建议继续扩展 -- 模型可见性与能力展示依赖新的 provider model 推断逻辑,历史仅按名称匹配的前端分支需要逐步淘汰 +- Agent 聊天页结构继续向 Team Workspace 与现役 Runtime API 收口,旧 compat 会话 / 子代理展示路径不再建议扩展 +- 工具面板、Harness 状态与时间线展示依赖新的事件元数据与运行时映射,历史 UI 分支需要逐步跟进 ### 🔗 依赖同步 -- `src-tauri/Cargo.toml` 中的 `aster-rust` 依赖固定到 `v0.19.0` +- `src-tauri/Cargo.toml` 中的 `aster-rust` 依赖固定到 `v0.20.0` +- 应用版本同步提升到 `v0.92.0`,覆盖 `package.json`、Tauri 配置与 Rust workspace 版本入口 ### 🧪 测试 @@ -26,7 +27,7 @@ ### 📝 文档 -- 更新 Aster 集成、治理、Skills 与发布相关文档,补充当前现役架构与发布说明 +- 更新治理、测试、工具体系与 Aster 集成相关文档,补充当前现役架构与发布说明 ### 📦 Windows 下载说明 @@ -36,4 +37,4 @@ --- -**完整变更**: v0.90.0...v0.91.0 +**完整变更**: v0.91.0...v0.92.0 diff --git a/docs/aiprompts/design-language.md b/docs/aiprompts/design-language.md index 89eeeb436..b36703eea 100644 --- a/docs/aiprompts/design-language.md +++ b/docs/aiprompts/design-language.md @@ -76,6 +76,8 @@ Lime 的整体界面应当接近以下气质: - 背景先轻,再让卡片浮出来 - 如果背景已经有气氛层,卡片本身就要更克制 - 当页面出现“背景比内容更显眼”的情况,优先减背景,不要继续加组件装饰 +- 弹窗、浮层、工作台主面板默认禁止半透明与磨砂效果;不要用 `bg-white/80`、`backdrop-blur`、半透明描边去制造“高级感” +- 需要悬浮感时,优先用实体底色、清晰边框和浅阴影,不要靠透出下层内容制造层级 ## 容器与布局 @@ -147,13 +149,21 @@ Lime 的整体界面应当接近以下气质: - 卡片底色优先白色或轻微染色白 - 边框比阴影更重要 - 阴影要浅,重点靠层级和留白,而不是重投影 +- 面板内不要连续叠三层以上卡片;能用分区、留白、分隔线解决的,不要继续套一层圆角卡 推荐方向: -- `bg-white` 或 `bg-white/90` +- `bg-white` +- `bg-slate-50` - `border-slate-200/80` - `shadow-sm shadow-slate-950/5` +避免方向: + +- 大面积 `bg-white/80`、`bg-slate-50/70` 一类半透明主表面 +- `backdrop-blur-*` 用在正文承载容器、工作台、弹窗主体 +- 在同一信息区连续嵌套多层圆角白卡,导致边界重复、滚动时视觉发花 + ### 2. 组件圆角 - 工作台容器:大圆角 @@ -211,6 +221,7 @@ Lime 的整体界面应当接近以下气质: - 氛围背景应采用连续浅渐变,不要做分段叠色 - 工作台主操作应与统计信息分栏,而不是挤成一行 - 分组标题应使用“中文主标题 + 英文辅助标签”的组合,而不是反过来 +- Team Workspace / 浮层类界面应避免半透明主体和多层套卡,否则会放大遮挡感与层级混乱 ## 关联文档 diff --git a/docs/aiprompts/governance.md b/docs/aiprompts/governance.md index c92d99bfc..6739ae892 100644 --- a/docs/aiprompts/governance.md +++ b/docs/aiprompts/governance.md @@ -120,13 +120,20 @@ compat 层禁止: ```bash npm run governance:legacy-report +npm run test:contracts ``` -它用于扫描: +- `npm run governance:legacy-report` 用于扫描: + - 已被判定为 `deprecated` / `dead-candidate` 的前端入口 + - 旧 Tauri 命令是否仍然只收口在指定 API 网关 + - 哪些兼容壳层已经零引用,可以进入删除候选 +- `npm run test:contracts` 用于检查跨层命令契约: + - 前端 `safeInvoke(...)` / `invoke(...)` 的实际命令调用 + - Rust `tauri::generate_handler!` 的实际注册表 + - `agentCommandCatalog` 中的 `deprecated` 命令与 `runtime gateway` 命令边界 + - `mockPriorityCommands` 与 `defaultMocks` 是否仍然同步 -- 已被判定为 `deprecated` / `dead-candidate` 的前端入口 -- 旧 Tauri 命令是否仍然只收口在指定 API 网关 -- 哪些兼容壳层已经零引用,可以进入删除候选 +只看其中一侧都不够。只要能力仍然依赖命令边界,至少要同时看前端调用、Rust 注册、deprecated 目录、mock 集合这四个面。 原则只有一句: @@ -195,6 +202,11 @@ npm run governance:legacy-report 至少加一条能自动失败的规则,阻止旧路径继续增长。 +如果改动涉及 Tauri 命令、前端 API 网关、bridge 或 mock,优先补: + +- `npm run test:contracts` +- `npm run governance:legacy-report` + ### 第五步:迁旁路 确认统计、记忆、搜索、报表、审计、任务系统不再依赖旧实现。 @@ -209,6 +221,7 @@ npm run governance:legacy-report - 前端唯一入口是不是 `useAgentChatUnified -> useAsterAgentChat`,还是 `useChat` / `useAgentChat` / `useUnifiedChat` 还在继续长逻辑? - Rust 唯一入口是不是 `agent_runtime_*`,还是 `chat_*` / `general_chat_*` / `agent_*` / `aster_agent_*` 还在平行演进? +- 前端 `safeInvoke(...)` / `invoke(...)`、Rust `tauri::generate_handler!`、`agentCommandCatalog`、`mockPriorityCommands` / `defaultMocks` 这四个命令边界是不是仍然一致,还是已经产生漂移? - 数据事实源是不是同一组表 / 同一套 Repository,还是还在同时写 `agent_*` 与 `general_chat_*`? - 统计、记忆等旁路是不是已经切到新路径,还是还在读旧表? diff --git a/docs/prd/tools/README.md b/docs/prd/tools/README.md new file mode 100644 index 000000000..a0895229c --- /dev/null +++ b/docs/prd/tools/README.md @@ -0,0 +1,325 @@ +# Lime 工具治理总览 + +更新时间:2026-03-20 + +## 1. 背景 + +当前 Lime 的工具体系已经不只是单一的 Aster native tools,还同时包含: + +- Aster 默认内置工具 +- Lime 注入工具 +- Creator 专属工具 +- Browser Assist 兼容工具面 +- Lime MCP runtime tools +- Aster ExtensionManager 注入后的 prefixed tools + +这套能力本身已经接近 Tool Calling 2.0,但过去存在两个核心问题: + +1. **工具事实源分裂** + - MCP schema metadata、Aster runtime registry、Lime 注入 extension、provider 转换层分别做了解析 + - 同一个字段(如 `deferred_loading` / `allowed_callers` / `input_examples`)在多处重复解释 + +2. **权限平面混杂** + - “工具是否应该进入上下文” + - “工具是否允许某个 caller 调用” + - “工具调用后是否需要 sandbox / approval” + - “参数是否受限” + 过去没有被严格分层,导致工具越多,上下文与权限越容易错乱 + +本次治理的目标,是把 Lime 的工具系统收敛到一条清晰主链路,参考 Codex 的思路: + +- **小而稳定的常驻工具面** +- **按需搜索 / 延迟加载的动态工具** +- **工具发现与权限执行分离** +- **MCP 作为独立体系接入,但统一进入 Agent runtime** + +--- + +## 2. 本次结论 + +### 2.1 不是“没有 Tool Search”,而是“已经有一半,但事实源没收口” + +Lime 实际已经具备这些能力: + +- `search_tools` +- `list_tools_for_context` +- `tool_search` bridge tool +- `deferred_loading` +- `allowed_callers` +- `input_examples` +- MCP -> Aster extension 注入 + +真正的问题不是缺能力,而是: + +- metadata 解析分散 +- native tool 目录不完整 +- MCP / extension / provider 多处重复解释 +- runtime 缺少一份可审计的“工具库存快照” + +### 2.2 现役事实源 + +本轮治理后,建议把事实源固定为: + +- **工具元数据事实源**:`src-tauri/crates/core/src/tool_calling.rs` +- **native 工具目录事实源**:`src-tauri/src/agent_tools/catalog.rs` +- **执行权限事实源**:`src-tauri/src/agent_tools/execution.rs` +- **MCP runtime 工具事实源**:`src-tauri/crates/mcp/src/manager.rs` +- **Aster 注入工具面事实源**:`src-tauri/src/commands/aster_agent_cmd.rs` +- **工具库存 / 审计快照事实源**:`src-tauri/src/agent_tools/inventory.rs` + +### 2.3 当前 / 兼容 / 待清理分类 + +| 分类 | 路径 / 对象 | 说明 | +| -------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| current | `lime_core::tool_calling` | 统一 metadata 读取与打分 | +| 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/agent_tools/inventory.rs` | runtime 工具库存快照 | +| compat | `SubAgentTask` | 兼容旧子代理工具名,仍可见但应逐步退出 | +| 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` 测试夹具加载 | + +> 注意:`dead-candidate` 本轮只做标记,不直接删除。删除属于高风险操作,需要单独确认。 + +--- + +## 3. 本次已落地实现 + +### 3.1 元数据收口 + +已统一到 `lime_core::tool_calling`: + +- `extract_tool_surface_metadata` +- `tool_visible_in_context` +- `tool_matches_caller` +- `score_tool_match` +- `normalize_tool_caller` + +以下模块都已切换到共享逻辑: + +- `src-tauri/crates/mcp/src/manager.rs` +- `src-tauri/src/commands/aster_agent_cmd.rs` +- `src-tauri/crates/providers/src/providers/openai_custom.rs` +- `src-tauri/crates/providers/src/providers/claude_custom.rs` + +### 3.2 native 工具目录补全 + +`src-tauri/src/agent_tools/catalog.rs` 已升级为完整目录,覆盖: + +- Aster built-ins +- Lime 注入工具 +- Creator 工具面 +- Browser Assist 兼容前缀 + +并明确了: + +- `ToolSourceKind` +- `ToolPermissionPlane` +- `ToolLifecycle` +- `workspace_default_allow` + +### 3.3 runtime 库存快照 + +新增: + +- `src-tauri/src/agent_tools/inventory.rs` +- `agent_runtime_get_tool_inventory` Tauri 命令 +- `src/lib/api/agentRuntime.ts` 对应 helper + +这条命令可以一次返回: + +- 当前 surface 的 catalog tools +- 默认允许工具集合 +- runtime registry tools +- extension surfaces +- searchable / loaded extension tools +- MCP servers 与 MCP tools +- 映射缺口与可见性统计 + +### 3.4 执行权限事实源 + +新增: + +- `src-tauri/src/agent_tools/execution.rs` + +负责: + +- `bash` / `Task` 的 warning gate 语义 +- workspace 参数限制模板 +- sandbox profile 归类 +- execution permission 模板生成 +- inventory execution profile 暴露 +- 默认策略 + persisted policy + runtime override 合并 + +其中策略覆盖入口已经收口为: + +- **持久化覆盖**:`src-tauri/crates/core/src/config/types.rs` -> `NativeAgentConfig.tool_execution` +- **运行时覆盖**:`request.metadata.harness.executionPolicy` / `execution_policy` +- **有效策略解析**:`src-tauri/src/agent_tools/execution.rs::resolve_tool_execution_policy` + +结果: + +- `aster_agent_cmd.rs` 不再手工拼整段 `ToolPermission` 模板 +- execution 层事实源从命令层 if/else 收回 `agent_tools` 边界 +- inventory 现在可直接审计 `execution_warning_policy` / `execution_restriction_profile` / `execution_sandbox_profile` +- `agent_runtime_get_tool_inventory` 可通过 `metadata` 观察 runtime override 后的 effective profile +- inventory 同时暴露每个 execution 字段的来源: + - `execution_warning_policy_source` + - `execution_restriction_profile_source` + - `execution_sandbox_profile_source` + +### 3.5 前端契约同步 + +已同步更新: + +- `src/lib/governance/agentCommandCatalog.json` +- `src/lib/tauri-mock/core.ts` +- `src/lib/dev-bridge/mockPriorityCommands.ts` +- `src/lib/api/agent.test.ts` +- `src/lib/api/agentRuntime.ts` + +### 3.6 轻量测试通道 + +为避免 `lime` 主包在本地因 Tauri 链接过大而降低回归效率,已补一条对齐 Codex 思路的轻量测试通道: + +- `src-tauri/crates/agent/src/lib.rs` +- `src-tauri/crates/agent/src/agent_tools/mod.rs` + +这条通道直接复用 app crate 的: + +- `src-tauri/src/agent_tools/catalog.rs` +- `src-tauri/src/agent_tools/execution.rs` +- `src-tauri/src/agent_tools/inventory.rs` + +用于承接纯逻辑单测,而不是复制第二份实现。 + +结论: + +- **runtime 事实源没有新增** +- **测试入口新增了一条更轻的执行面** +- `tool_search` 与 inventory 的 extension 状态判定也已继续收口到共享 helper,避免主包再次长出重复逻辑 + +### 3.7 旧权限表面下沉 + +本轮继续做了一刀减法: + +- `src-tauri/crates/agent/src/tool_permissions.rs` +- `src-tauri/crates/agent/src/shell_security.rs` + +现在文件仍保留在仓库中,但编译边界已经: + +- 不再通过 `lime-agent` crate 根对外 `pub mod` +- 不再通过 `lime-agent` crate 根对外 `pub use` +- 不再进入 `lime-agent` 的 `lib.rs` 编译图 +- 不再进入正常 `cargo check` / 运行时编译图 +- 只通过 `src-tauri/crates/agent/tests/legacy_permission_surfaces.rs` 测试夹具加载,并继续复用文件内自测 + +同时新增了两层守卫: + +- `scripts/report-legacy-surfaces.mjs`:防止旧权限模块重新公开、重新挂回 `lib.rs` 编译图,或被上层重新依赖 +- `src/lib/governance/legacyToolPermissionGuard.test.ts`:防止 `lime-agent` 再次把这两套旧权限逻辑挂回 `lib.rs`,并约束测试夹具边界 + +--- + +## 4. 当前确认的静态工具面 + +### 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` + +- **Lime injected core tools**:7 个 + `tool_search` / `spawn_agent` / `send_input` / `wait_agent` / `resume_agent` / `close_agent` / `SubAgentTask` + +- **Core surface catalog total**:26 个 + +### 4.2 Creator surface + +在 Core 之上额外增加 8 个: + +- `social_generate_cover_image` +- `lime_create_video_generation_task` +- `lime_create_broadcast_generation_task` +- `lime_create_cover_generation_task` +- `lime_create_modal_resource_search_task` +- `lime_create_image_generation_task` +- `lime_create_url_parse_task` +- `lime_create_typesetting_task` + +- **Creator surface catalog total**:34 个 + +### 4.3 Browser Assist surface + +目录里只保留一个前缀入口: + +- `mcp__lime-browser__*` + +但它实际映射到 Aster browser runtime 的一组 prefixed tools。 +参考 Aster 的 `chrome_mcp/tools.rs`,当前浏览器工具定义为 **17 个**。 + +- **Browser Assist surface catalog total**:27 个 +- **Creator + Browser Assist 全量 surface**:35 个 + +--- + +## 5. 为什么这套方案比现状合理 + +### 5.1 更像 Codex,而不是“把所有 schema 全塞 prompt” + +Codex 的思路是: + +- 常驻工具面尽量小 +- 动态工具按 thread 存储 +- 通过 `defer_loading` 控制是否默认进入上下文 +- 权限配置与工具发现分离 + +Lime 现在的目标状态也应该是: + +- native 常驻面稳定 +- MCP / long-tail tools 搜索后按需进入 +- `allowed_callers` 只管调用者可见性 +- sandbox / approval 只管执行权限 + +### 5.2 MCP 单独成体系,但不单独造第二套 agent 认知 + +MCP 在 Lime 里仍然是独立运行时: + +- server 启停 +- tool cache +- prompt/resource +- runtime list/search/call + +但一旦进入 Agent,会通过 Aster `ExtensionManager` 统一挂接。 +这样模型只面对一个工具宇宙,不需要理解两套完全不同的上下文协议。 + +### 5.3 权限终于能分层 + +建议永久保留三层概念: + +1. **目录层** + - 这个工具是否存在 + - 属于哪个 surface / source / lifecycle + +2. **上下文层** + - 这个工具是否默认进入上下文 + - 是否 deferred + - caller 是否匹配 + +3. **执行层** + - 参数限制 + - sandbox + - approval + - workspace allowlist + +这三层不再混写,后续就不会随着工具数增加而指数级混乱。 + +--- + +## 6. 文档索引 + +- `docs/prd/tools/architecture.md`:架构、时序、流程图、Codex 对照 +- `docs/prd/tools/inventory.md`:工具盘点、分类、库存命令说明 +- `docs/prd/tools/development-plan.md`:开发计划、验收标准、下一刀 diff --git a/docs/prd/tools/architecture.md b/docs/prd/tools/architecture.md new file mode 100644 index 000000000..7c39cd32f --- /dev/null +++ b/docs/prd/tools/architecture.md @@ -0,0 +1,342 @@ +# Lime 工具治理架构 + +## 1. 设计目标 + +本次工具治理的设计目标只有四个: + +1. **只保留一套 metadata 解释器** +2. **只保留一份 native tool catalog** +3. **MCP 独立运行,但统一注入 Agent runtime** +4. **把“工具发现”和“工具权限执行”彻底分开** + +--- + +## 2. 核心事实源 + +### 2.1 统一元数据事实源 + +路径:`src-tauri/crates/core/src/tool_calling.rs` + +负责: + +- `deferred_loading` +- `always_visible` +- `allowed_callers` +- `tags` +- `input_examples` +- tool search 打分 +- caller 归一化 + +任何地方如果还在自己读 `x-lime` / `x_lime`,都应视为治理退化。 + +### 2.2 native 目录事实源 + +路径:`src-tauri/src/agent_tools/catalog.rs` + +负责: + +- 工具目录完整性 +- source / lifecycle / capability / permission_plane +- 默认 allowlist 子集 +- Creator / Browser Assist surface 裁剪 +- MCP extension surface 聚合 + +### 2.3 执行权限事实源 + +路径:`src-tauri/src/agent_tools/execution.rs` + +负责: + +- execution 层 warning gate 收口 +- workspace 参数限制模板收口 +- sandbox profile 归类 +- permission 模板生成 +- inventory execution profile 暴露 +- 默认策略、persisted policy、runtime session override 的优先级合并 + +### 2.4 MCP runtime 事实源 + +路径:`src-tauri/crates/mcp/src/manager.rs` + +负责: + +- MCP server lifecycle +- tool cache +- `list_tools` +- `list_tools_for_context` +- `search_tools` +- runtime metadata 继承与自动 defer 策略 + +### 2.5 Agent 注入事实源 + +路径:`src-tauri/src/commands/aster_agent_cmd.rs` + +负责: + +- `tool_search` bridge tool +- MCP -> Aster extension 注入 +- workspace tool allowlist +- runtime tool inventory 命令 + +### 2.6 轻量测试载体 + +路径: + +- `src-tauri/crates/agent/src/lib.rs` +- `src-tauri/crates/agent/src/agent_tools/mod.rs` + +负责: + +- 把 `src-tauri/src/agent_tools/catalog.rs` +- 把 `src-tauri/src/agent_tools/execution.rs` +- 把 `src-tauri/src/agent_tools/inventory.rs` + +以模块方式复用到 `lime-agent` crate 内,供纯逻辑单测执行。 + +注意: + +- 它不是新的 runtime 事实源 +- 它只是测试载体,避免 `lime` 主包为 Tauri/App wiring 做超大链接 +- 运行时事实源仍然是 app crate 下的 `catalog.rs` / `execution.rs` / `inventory.rs` + +--- + +## 3. 总体架构图 + +```mermaid +graph TD + UI[前端 / agentRuntime.ts] --> CMD[aster_agent_cmd.rs] + + CMD --> CAT[agent_tools/catalog.rs] + CMD --> EXEC[agent_tools/execution.rs] + CMD --> INV[agent_tools/inventory.rs] + CMD --> META[lime_core::tool_calling.rs] + CMD --> MCP[lime_mcp::manager.rs] + CMD --> AGENT[Aster Agent] + TEST[lime-agent test carrier] -. 复用纯逻辑 .-> CAT + TEST -. 复用纯逻辑 .-> EXEC + TEST -. 复用纯逻辑 .-> INV + + MCP --> MCPTOOLS[MCP Runtime Tools] + MCP --> PROMPTS[MCP Prompts / Resources] + + AGENT --> REG[ToolRegistry] + AGENT --> EXT[ExtensionManager] + + MCPTOOLS --> EXT + META --> MCP + META --> CMD + META --> PROVIDERS[Provider Tool Conversion] + + CAT --> EXEC + CAT --> INV + EXEC --> INV + REG --> INV + EXT --> INV + MCP --> INV +``` + +--- + +## 4. 初始化时序图 + +下面是 Lime 启动 Agent 并把 MCP 工具面注入 Aster 的主链路。 + +```mermaid +sequenceDiagram + participant UI as 前端 + participant CMD as aster_agent_init + participant STATE as AsterAgentState + participant MCP as McpClientManager + participant META as lime_core::tool_calling + participant EXT as Aster ExtensionManager + + UI->>CMD: aster_agent_init() + CMD->>STATE: init_agent_with_db() + CMD->>MCP: ensure_lime_mcp_servers_running() + CMD->>MCP: list_tools() + MCP->>META: extract_tool_surface_metadata() + MCP-->>CMD: McpToolDefinition[] + CMD->>CMD: build_mcp_extension_surface() + CMD->>EXT: add_client(extension, bridge_client) + CMD->>CMD: ensure_tool_search_tool_registered() + CMD-->>UI: initialized + tool surface ready +``` + +--- + +## 5. 工具检索 / 按需加载流程图 + +这个流程对应 Anthropic Tool Search / Codex defer loading 的同类思路。 + +```mermaid +flowchart TD + A[用户任务] --> B{现有默认上下文能否完成?} + B -- 是 --> C[直接使用默认可见工具] + B -- 否 --> D[调用 tool_search] + D --> E[查询 native registry + extension searchable tools] + E --> F{目标工具是否 deferred?} + F -- 否 --> G[直接执行] + F -- 是 --> H[通过 ExtensionManager 加载目标工具] + H --> I[把工具加入可执行工具面] + I --> G + G --> J[执行时再走 sandbox / approval / 参数限制] +``` + +关键点: + +- `tool_search` 负责“找工具” +- `allowed_callers` 负责“谁能看见 / 调用” +- `deferred_loading` 负责“是否默认进上下文” +- sandbox / approval 负责“执行时能不能做” + +--- + +## 6. 权限平面拆分 + +## 6.1 目录层 + +由 `catalog.rs` 定义。 + +回答的问题: + +- 这个工具属于哪个产品 surface +- 是 current 还是 compat +- 是 Aster builtin、Lime injected 还是 Browser compatibility + +## 6.2 上下文层 + +由 `tool_calling.rs` + `mcp manager` + `tool_search` 定义。 + +回答的问题: + +- 默认可见还是 deferred +- caller 是否匹配 +- 是否需要 tool_search 后再进入 + +## 6.3 执行层 + +由 `agent_tools/execution.rs` + Aster `ToolPermissionManager` + workspace sandbox / runtime approval 定义。 + +回答的问题: + +- 参数是否受限 +- 是否要求审批 +- 是否要进入 sandbox +- 当前 workspace 是否允许 + +> 结论:**不要再让目录层和执行层共享同一套“权限”语义。** + +--- + +## 7. 与 Codex 的对照 + +## 6.4 执行策略解析时序图 + +下面是一次工具执行前,effective execution policy 的解析链路。 + +```mermaid +sequenceDiagram + participant UI as 前端 / request.metadata + participant CMD as aster_agent_cmd.rs + participant CFG as NativeAgentConfig.tool_execution + participant EXEC as agent_tools/execution.rs + participant PERM as ToolPermissionManager + + UI->>CMD: submit turn / get inventory(metadata) + CMD->>CFG: 读取 persisted policy + CMD->>EXEC: resolve_tool_execution_policy(tool, config, metadata) + EXEC->>EXEC: 合并默认策略 + EXEC->>EXEC: 叠加 persisted override + EXEC->>EXEC: 叠加 runtime override + EXEC-->>CMD: effective policy + CMD->>PERM: build_workspace_execution_permissions(...) + CMD-->>UI: inventory / runtime 权限结果 +``` + +结论: + +- `catalog.rs` 仍只定义目录层事实 +- `execution.rs` 独占执行层合并逻辑 +- `aster_agent_cmd.rs` 只负责 orchestration,不再手工散写 permission 模板 +- provenance 也在 `execution.rs` 统一生成,inventory 只消费结果,不再自行推断来源 + +--- + +## 7. 与 Codex 的对照 + +## 7.1 Codex 怎么做 + +参考: + +- `codex-rs/app-server-protocol/src/protocol/v2.rs` +- `codex-rs/state/src/runtime/threads.rs` + +Codex 的关键点: + +1. **小型常驻工具配置** + - `ToolsV2` 只保留少量稳定入口,例如 `web_search`、`view_image` + +2. **动态工具独立建模** + - `DynamicToolSpec` 单独描述动态工具 + - 包含 `defer_loading` + +3. **线程级动态工具持久化** + - `thread_dynamic_tools` + - 动态工具跟着 thread,而不是全局把所有 schema 塞进 prompt + +4. **权限与工具发现分离** + - `AppToolsConfig` 管具体 app tool 的 enable / approval mode + - sandbox / approval 是另一套系统 + +## 7.2 Lime 应该学什么 + +Lime 不需要一比一复制 Codex,但要学到这三个原则: + +1. **常驻工具面要小** +2. **长尾工具靠搜索和 deferred loading** +3. **权限执行不要和工具目录绑死** + +## 7.3 Lime 当前对应关系 + +| Codex | Lime 对应实现 | +| ------------------------------- | -------------------------------------------------------------------- | +| `DynamicToolSpec.defer_loading` | `x-lime.deferred_loading` + `McpToolDefinition.deferred_loading` | +| `thread_dynamic_tools` | Aster ExtensionManager searchable tools + MCP runtime cache | +| 小常驻工具面 | `workspace_default_allowed_tool_names(...)` | +| app tool config / approval 分离 | `catalog.rs` + `execution.rs` + workspace sandbox / Aster permission | +| persisted permissions profile | `NativeAgentConfig.tool_execution` | +| thread/request runtime override | `request.metadata.harness.executionPolicy` | + +--- + +## 8. MCP 在架构里的位置 + +MCP 不是 native tools 的附属物,也不是另起一套 Agent。 + +它在 Lime 中应被视为: + +- **独立的 runtime tool fabric** +- 但通过 **统一的 extension 注入边界** 进入 Agent + +这意味着: + +- MCP server 启停、缓存、resource/prompt 仍然独立 +- Agent 只看到统一后的 prefixed tool surface +- 工具搜索可以同时搜 native 与 extension +- 库存命令可以同时做 catalog / runtime / MCP 三视角盘点 + +--- + +## 9. dead-candidate 说明 + +以下路径当前不在主链路: + +- `src-tauri/crates/agent/src/tool_permissions.rs` +- `src-tauri/crates/agent/src/shell_security.rs` + +状态建议: + +- 当前标记为 `dead-candidate` +- 暂不删除 +- 如果后续确认完全无运行时回流,再单独发起一次“删除旧权限系统”的治理变更 diff --git a/docs/prd/tools/development-plan.md b/docs/prd/tools/development-plan.md new file mode 100644 index 000000000..6c9a11350 --- /dev/null +++ b/docs/prd/tools/development-plan.md @@ -0,0 +1,222 @@ +# Lime 工具治理开发计划 + +## 1. 目标 + +把 Lime 的工具系统从“多源并存、权限混写、上下文不可审计”收敛为: + +- 单一 metadata 事实源 +- 单一 native catalog +- MCP runtime 独立但统一注入 +- inventory 可审计 +- 权限平面分层 + +--- + +## 2. 本轮已完成 + +## Phase A:事实源收口(已完成) + +- [x] 把 tool metadata 解析统一到 `lime_core::tool_calling` +- [x] MCP manager 改为复用共享 metadata +- [x] `tool_search` bridge tool 改为复用共享 metadata +- [x] provider 转换层改为复用共享 metadata + +### 验收标准 + +- 不再存在多处独立解析 `deferred_loading` / `allowed_callers` / `input_examples` +- tool search 与 MCP list/search 的语义一致 + +--- + +## Phase B:native catalog 完整化(已完成) + +- [x] 建立完整 `ToolCatalogEntry` +- [x] 引入 `ToolSourceKind` +- [x] 引入 `ToolPermissionPlane` +- [x] 引入 `ToolLifecycle` +- [x] 补全 core / creator / browser assist tools +- [x] 形成默认 allowlist 子集 + +### 验收标准 + +- 能回答“当前到底有哪些 native tools” +- 能区分 current / compat +- 能区分 session allowlist / parameter restricted / caller filtered + +--- + +## Phase C:runtime inventory(已完成) + +- [x] 新增 `agent_tools/inventory.rs` +- [x] 新增 `agent_runtime_get_tool_inventory` +- [x] 接入 `agentRuntime.ts` +- [x] 接入 mock / governance command catalog / API test + +### 验收标准 + +- 一条命令能同时输出 catalog / registry / extension / MCP 四视角 +- 能看到 visible / deferred / caller_allowed 状态 +- 能发现 registry 未被 catalog 覆盖的漂移项 + +--- + +## Phase D:文档与治理说明(已完成) + +- [x] 输出 `docs/prd/tools/README.md` +- [x] 输出 `docs/prd/tools/architecture.md` +- [x] 输出 `docs/prd/tools/inventory.md` +- [x] 输出 `docs/prd/tools/development-plan.md` + +### 验收标准 + +- 有架构图 +- 有时序图 +- 有流程图 +- 有 current / compat / dead-candidate 分类 +- 有 Codex / Aster / Lime 对照 + +--- + +## Phase E:测试矩阵补强(本轮完成) + +- [x] 补齐 `catalog.rs` 的 surface / lifecycle / default allowlist 边界测试 +- [x] 补齐 `inventory.rs` 的 caller / extension source / deferred 状态测试 +- [x] 补齐 `mcp manager` 的冲突保序与默认 defer 阈值测试 +- [x] 补齐 `tool_search` 的 extension 前缀匹配与状态判定测试 +- [x] 补齐 provider 层对 `x-lime` / `x_lime` alias 与去重行为测试 +- [x] 补齐前端 `agentRuntime.ts` inventory helper 默认参数测试 +- [x] 新增 `lime-agent` 轻量测试载体,复用 `catalog.rs` / `inventory.rs` 纯逻辑模块,绕开主包 Tauri 大链接 +- [x] 把 `tool_permissions.rs` / `shell_security.rs` 迁出 `lime-agent` 的 `lib.rs` 编译图,改为独立 integration test 夹具加载,并补治理守卫与 Vitest 护栏 + +## Phase F:执行权限事实源收口(本轮完成) + +- [x] 新增 `src-tauri/src/agent_tools/execution.rs` +- [x] 把 workspace execution permission 模板从 `aster_agent_cmd.rs` 收回 `agent_tools` 边界 +- [x] 统一 `bash` / `Task` warning gate 语义 +- [x] 把 execution profile 暴露到 inventory / `agentRuntime.ts` +- [x] 补齐 `execution.rs` 与 inventory 的定向测试 +- [x] 新增 `NativeAgentConfig.tool_execution`,承接 persisted policy 覆盖 +- [x] 让 `request.metadata.harness.executionPolicy` 承接 runtime session override +- [x] 让 `agent_runtime_get_tool_inventory` 支持 `metadata`,返回 runtime override 后的 effective profile +- [x] 给 inventory 增加 provenance 字段,逐项标记 `default` / `persisted` / `runtime` +- [x] 补齐配置层 default / alias / roundtrip 测试 + +### 验收标准 + +- 对齐 Codex 风格的四类断言: + 1. **默认值是否稳定** + 2. **legacy / alias 字段是否兼容** + 3. **显式配置是否覆盖默认策略** + 4. **持久语义是否在多层之间保持一致** +- 新增工具治理逻辑至少要被以下矩阵之一覆盖: + - catalog 边界 + - runtime inventory 快照 + - MCP runtime 过滤 / 搜索 / defer + - provider metadata 透传 + - 前端命令 helper + +### 本轮验证策略 + +- **小包优先**:优先跑 `lime-providers`、`lime-mcp` 与前端 `agent.test.ts` +- **轻量逻辑优先**:`catalog.rs` / `inventory.rs` 的纯逻辑单测优先从 `lime-agent` 执行,测试名使用模块前缀: + - `agent_tools::catalog::tests::...` + - `agent_tools::inventory::tests::...` +- **主包降级为编译检查**:`lime` 主包测试会触发超大链接;在当前环境磁盘仅余约 `4.4Gi` 时,优先做 `cargo check` / 定向编译验证 +- **边界说明**:若后续 CI 或本地磁盘空间恢复,应追加一次 `lime` 主包的完整定向测试,把 `catalog.rs` / `inventory.rs` / `aster_agent_cmd.rs` 新增测试全部实跑 + +### 推荐命令 + +```bash +# 轻量 Rust 纯逻辑测试 +cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-agent \ + agent_tools::catalog::tests::test_tool_catalog_entries_for_surface_counts_and_lifecycle_boundaries -- --exact + +cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-agent \ + agent_tools::inventory::tests::test_build_tool_inventory_marks_extension_sources_and_statuses -- --exact + +# MCP / Provider 定向测试 +cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-mcp empty_query_prioritizes_always_visible_then_name -- --nocapture +cargo test --manifest-path "src-tauri/Cargo.toml" -p lime-providers supports_x_lime_alias -- --nocapture + +# 前端契约与治理守卫 +npm test -- "src/lib/api/agent.test.ts" +npm test -- "src/lib/governance/legacyToolPermissionGuard.test.ts" +npm run test:contracts +npm run governance:legacy-report +``` + +--- + +## 3. 下一刀建议 + +## 3.1 优先级 P1:删除旧权限系统前先做守卫 + +本轮已完成: + +- `tool_permissions.rs` / `shell_security.rs` 已明确标为 dead-candidate +- 已从 `lime-agent` crate 根移除公开表面 +- 已退出 `lime-agent` 的 `lib.rs` 编译图,仅通过 `tests/legacy_permission_surfaces.rs` 测试夹具加载 +- 已在治理脚本与 Vitest 护栏中禁止新引用回流 + +剩余原因: + +- 这两套逻辑虽然已不在主链路,但文件级删除仍属于高风险动作,需要单独确认 + +--- + +## 3.2 优先级 P1:把“策略覆盖层”接到 execution 事实源 + +本轮已完成: + +- `workspace allowlist` / `parameter restriction` / `warning gate` / `sandbox profile` 已有统一 execution 事实源 +- `aster_agent_cmd.rs` 已从手工 permission 模板拼装降级为 orchestration +- inventory 已能审计 execution profile +- `NativeAgentConfig.tool_execution` 已承接 persisted policy +- `request.metadata.harness.executionPolicy` 已承接 runtime session override +- inventory 已可查看 runtime override 后的 effective execution profile +- inventory 已可逐项查看 execution provenance,而不是只看最终值 + +剩余下一步建议: + +- 如果后续要做 UI 可视化,再把 effective policy 与来源标识直接展示在工具调试页 +- 保持 `catalog.rs` 只描述目录层,`execution.rs` 只描述执行层,避免覆盖逻辑再次散回命令层 + +--- + +## 3.3 优先级 P2:继续做减法,而不是再加抽象 + +不要再新增: + +- 第二套 tool metadata parser +- 第二套 native tool list +- 第二套 browser tool 目录 +- 第二套 MCP 注入路径 + +后续所有新工具都应满足: + +1. 在 `catalog.rs` 有记录 +2. metadata 走 `lime_core::tool_calling` +3. runtime inventory 能看见 + +--- + +## 4. 风险与对策 + +| 风险 | 说明 | 对策 | +| --------------------------- | --------------------------------------------------- | ------------------------------------------ | +| catalog 与 runtime 再次漂移 | 新增工具时只改注册不改目录 | 以 inventory + command catalog 作为守卫 | +| MCP caller 语义再次分裂 | MCP tool schema / extension allowed_caller 各自解释 | 一律先过 `tool_calling.rs` | +| 权限语义再次混写 | catalog、allowlist、sandbox、approval 又掺在一起 | 强制按目录层 / 上下文层 / 执行层汇报与设计 | +| 旧权限系统回流 | 新代码重新引用 `tool_permissions.rs` | 标记 dead-candidate,并增加仓库级扫描守卫 | + +--- + +## 5. 最终建议 + +这次治理完成后,后续迭代请遵守三条硬规则: + +1. **新增工具先入 catalog** +2. **新增 metadata 字段先入 `tool_calling.rs`** +3. **新增运行时注入能力必须能被 inventory 看见** + +只要守住这三条,Lime 的工具数继续增长,也不会再回到“上下文失控 + 权限混乱”的状态。 diff --git a/docs/prd/tools/inventory.md b/docs/prd/tools/inventory.md new file mode 100644 index 000000000..b23961940 --- /dev/null +++ b/docs/prd/tools/inventory.md @@ -0,0 +1,274 @@ +# Lime 工具库存与分类 + +## 1. 静态 catalog 盘点 + +## 1.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` + +### Lime injected(7) + +- `tool_search` +- `spawn_agent` +- `send_input` +- `wait_agent` +- `resume_agent` +- `close_agent` +- `SubAgentTask` + +### Core 总数 + +- **26 个 catalog entries** + +--- + +## 1.2 Creator surface 增量(8) + +- `social_generate_cover_image` +- `lime_create_video_generation_task` +- `lime_create_broadcast_generation_task` +- `lime_create_cover_generation_task` +- `lime_create_modal_resource_search_task` +- `lime_create_image_generation_task` +- `lime_create_url_parse_task` +- `lime_create_typesetting_task` + +### Creator 总数 + +- **34 个 catalog entries** + +--- + +## 1.3 Browser Assist + +目录层只保留一个兼容前缀: + +- `mcp__lime-browser__*` + +它不是一个单独真实工具,而是一组 browser runtime tools 的聚合入口。 +参考 Aster `chrome_mcp/tools.rs`,当前浏览器工具定义为 **17 个**。 + +### Browser Assist 总数 + +- **27 个 catalog entries** + +### Creator + Browser Assist 总数 + +- **35 个 catalog entries** + +--- + +## 2. 默认授权子集 + +Core surface 当前默认 allow 的工具为: + +- `Skill` +- `TaskOutput` +- `KillShell` +- `TodoWrite` +- `EnterPlanMode` +- `ExitPlanMode` +- `WebSearch` +- `ask` +- `tool_search` +- `spawn_agent` +- `send_input` +- `wait_agent` +- `resume_agent` +- `close_agent` +- `SubAgentTask` + +结论: + +- 默认 allowlist 是 **15 个** +- 明确排除了 `read` / `write` / `edit` / `bash` / `WebFetch` / `analyze_image` 这类需要参数约束或更强执行控制的工具 + +这符合“常驻工具面小而稳”的原则。 + +--- + +## 3. 新增库存命令 + +## 3.1 后端命令 + +- `agent_runtime_get_tool_inventory` + +实现路径: + +- `src-tauri/src/commands/aster_agent_cmd.rs` +- `src-tauri/src/agent_tools/inventory.rs` + +## 3.2 前端 helper + +- `src/lib/api/agentRuntime.ts` +- `getAgentRuntimeToolInventory(...)` + +### 调用示例 + +```ts +import { getAgentRuntimeToolInventory } from "@/lib/api/agentRuntime"; + +const snapshot = await getAgentRuntimeToolInventory({ + caller: "assistant", + creator: true, + browserAssist: true, + metadata: { + harness: { + executionPolicy: { + toolOverrides: { + bash: { + warningPolicy: "none", + }, + }, + }, + }, + }, +}); +``` + +## 3.3 返回内容 + +库存快照会同时返回: + +- 请求 caller / surface +- 当前 agent 是否初始化 +- warnings +- MCP servers +- 默认 allow 工具列表 +- catalog tools +- runtime registry tools +- extension surfaces +- extension tools +- mcp tools +- catalog / registry 对应的 effective execution profile(warning / restriction / sandbox) + - 默认策略:`execution.rs` + - 持久化覆盖:`NativeAgentConfig.tool_execution` + - 运行时覆盖:`request.metadata.harness.executionPolicy` +- catalog / registry 对应的 provenance 字段: + - `execution_warning_policy_source` + - `execution_restriction_profile_source` + - `execution_sandbox_profile_source` +- counts + +--- + +## 4. 这份库存解决了什么问题 + +过去你只能分别从这些地方猜测工具面: + +- catalog +- registry +- mcp manager +- extension manager +- tool_search 输出 + +现在一条命令就能同时回答这些问题: + +1. **静态目录里一共有多少工具?** +2. **当前 surface 下哪些是默认允许的?** +3. **Aster runtime registry 里实际注册了哪些工具?** +4. **哪些 runtime tools 没被 catalog 覆盖?** +5. **当前有哪些 extension surfaces?** +6. **哪些 extension tools 处于 deferred / loaded / visible?** +7. **MCP 真实运行了哪些 servers 和 tools?** + +--- + +## 5. 分类建议 + +## 5.1 current + +- `src-tauri/crates/core/src/tool_calling.rs` +- `src-tauri/src/agent_tools/catalog.rs` +- `src-tauri/src/agent_tools/execution.rs` +- `src-tauri/src/agent_tools/inventory.rs` +- `src-tauri/crates/mcp/src/manager.rs` +- `src-tauri/src/commands/aster_agent_cmd.rs` + +## 5.2 compat + +- `SubAgentTask` +- `workspace_allowed_tool_names(...)` + +## 5.3 deprecated + +当前目录层没有新增 deprecated 工具;建议不要提前扩充 deprecated 层。 + +## 5.4 dead-candidate + +- `src-tauri/crates/agent/src/tool_permissions.rs` +- `src-tauri/crates/agent/src/shell_security.rs` + +--- + +## 6. 建议的库存使用方式 + +### 6.1 PR / 回归检查 + +每次工具相关改动,至少回答: + +- registry tools 是否出现 catalog 未覆盖项 +- extension surface 是否混入不该存在的 caller +- MCP tools 是否无意中全部默认进入上下文 + +### 6.2 调试上下文爆炸 + +先看: + +- `default_allowed_tools` +- `registry_visible_total` +- `extension_tool_visible_total` +- `mcp_tool_visible_total` + +如果这些数字异常上升,说明“默认进入上下文”的面在膨胀。 + +### 6.3 调试权限错乱 + +先分清问题属于哪层: + +- catalog 层:目录不全 / 生命周期错 +- context 层:`deferred_loading` / `allowed_callers` 错 +- execution 层:sandbox / approval / 参数限制错 + +此时优先看: + +- `execution_warning_policy` +- `execution_restriction_profile` +- `execution_sandbox_profile` +- `execution_warning_policy_source` +- `execution_restriction_profile_source` +- `execution_sandbox_profile_source` +- `request.metadata` 是否传入了 runtime override + +--- + +## 7. 结论 + +库存命令不是为了“多一个调试页面”,而是为了把工具系统从“猜”变成“看得见”。 + +只要 inventory 这层一直存在,后续无论工具从 20 个涨到 200 个,都还能保持: + +- 工具目录可审计 +- 上下文暴露可审计 +- MCP 注入可审计 +- 权限平面可审计 diff --git a/docs/test/README.md b/docs/test/README.md index 426f7d9ea..51e129ccd 100644 --- a/docs/test/README.md +++ b/docs/test/README.md @@ -1,14 +1,21 @@ # Lime 测试体系 -> 基于 Anthropic AI Agent 评估指南与 Orchids Bridge 项目实践 +> 面向 Lime 当前桌面端产品形态的测试入口与索引 ## 概述 -Lime 作为 AI API 代理和 Agent 集成平台,需要一套完整的测试体系来确保: -- API 代理的正确性和稳定性 -- 凭证池管理的可靠性 -- Aster Agent 集成的功能完整性 -- 协议转换的准确性 +Lime 当前是一个本地优先的 Tauri 桌面应用,而不是单一前端项目或单一 API 服务。 + +测试体系需要同时覆盖: + +- 前端界面与工作台交互 +- Tauri 命令边界 +- Rust 服务层与业务逻辑 +- 数据库、文件系统与工作区状态 +- Provider、协议转换与本地 HTTP Server +- 浏览器运行时、终端、OpenClaw 等桌面能力 +- Agent Runtime 与真实模型行为 +- macOS / Windows 平台差异 ## 测试分层 @@ -36,9 +43,10 @@ Lime 作为 AI API 代理和 Agent 集成平台,需要一套完整的测试体 ``` docs/test/ ├── README.md # 本文件 - 测试体系概览 +├── testing-strategy-2026.md # 当前 Lime 主测试策略 ├── unit-tests.md # 单元测试指南 ├── integration-tests.md # 集成测试指南 -├── e2e-tests.md # 端到端测试指南 +├── e2e-tests.md # 浏览器续测与 E2E 总览 ├── agent-evaluation.md # Agent 评估指南(核心文档) └── test-cases/ # 测试用例模板 ├── converter-tests.md # 协议转换器测试用例 @@ -48,15 +56,17 @@ docs/test/ ## 文档索引 -| 文档 | 说明 | 适用场景 | -|------|------|----------| -| [unit-tests.md](unit-tests.md) | 单元测试指南 | 独立模块测试 | -| [integration-tests.md](integration-tests.md) | 集成测试指南 | 模块间协作测试 | -| [e2e-tests.md](e2e-tests.md) | E2E 测试指南 | 完整用户流程测试 | -| [agent-evaluation.md](agent-evaluation.md) | Agent 评估指南 | AI Agent 行为评估 | -| [test-cases/converter-tests.md](test-cases/converter-tests.md) | 转换器测试用例 | OpenAI ↔ Claude 转换 | -| [test-cases/provider-tests.md](test-cases/provider-tests.md) | Provider 测试用例 | OAuth 和 API 调用 | -| [test-cases/agent-tests.md](test-cases/agent-tests.md) | Agent 测试用例 | Aster Agent 集成 | +| 文档 | 说明 | 适用场景 | +| ---------------------------------------------------------------- | -------------------------- | ------------------------------------- | +| [testing-strategy-2026.md](testing-strategy-2026.md) | 当前 Lime 测试体系建设建议 | 建立分层门禁、规划演进 | +| [unit-tests.md](unit-tests.md) | 单元测试指南 | 独立模块测试 | +| [integration-tests.md](integration-tests.md) | 集成测试指南 | 模块间协作测试 | +| [e2e-tests.md](e2e-tests.md) | 当前浏览器续测与 E2E 入口 | Playwright MCP / DevBridge 主路径验证 | +| [../aiprompts/playwright-e2e.md](../aiprompts/playwright-e2e.md) | 浏览器续测详细事实源 | 继续测试、复现、控制台与 Bridge 排障 | +| [agent-evaluation.md](agent-evaluation.md) | Agent 评估指南 | AI Agent 行为评估 | +| [test-cases/converter-tests.md](test-cases/converter-tests.md) | 转换器测试用例 | OpenAI ↔ Claude 转换 | +| [test-cases/provider-tests.md](test-cases/provider-tests.md) | Provider 测试用例 | OAuth 和 API 调用 | +| [test-cases/agent-tests.md](test-cases/agent-tests.md) | Agent 测试用例 | Aster Agent 集成 | ## 快速开始 @@ -72,6 +82,37 @@ cd src-tauri && cargo test npm test ``` +### 运行本地智能校验 + +```bash +npm run verify:local +``` + +### 运行本地全量校验 + +```bash +npm run verify:local:full +``` + +### 浏览器模式桥接检查 + +```bash +npm run bridge:health -- --timeout-ms 120000 +``` + +### 运行首条自包含 smoke + +```bash +npm run smoke:workspace-ready +``` + +### 当前浏览器续测入口 + +当前仓库的浏览器模式 E2E / 续测文档分两层: + +- `docs/test/e2e-tests.md`:总览、命令矩阵、适用边界 +- `docs/aiprompts/playwright-e2e.md`:详细操作流程与 Playwright MCP 续测事实源 + ### 运行代码检查 ```bash @@ -84,12 +125,12 @@ npm run lint ## 核心测试模块 -| 模块 | 测试重点 | 文档 | -|------|----------|------| -| 协议转换 | OpenAI ↔ Claude 转换正确性 | [converter-tests.md](test-cases/converter-tests.md) | -| Provider 系统 | OAuth 刷新、API 调用 | [provider-tests.md](test-cases/provider-tests.md) | -| 凭证池 | 轮询、健康检查、负载均衡 | [integration-tests.md](integration-tests.md) | -| Aster Agent | 流式响应、工具调用 | [agent-tests.md](test-cases/agent-tests.md) | +| 模块 | 测试重点 | 文档 | +| ------------- | -------------------------- | --------------------------------------------------- | +| 协议转换 | OpenAI ↔ Claude 转换正确性 | [converter-tests.md](test-cases/converter-tests.md) | +| Provider 系统 | OAuth 刷新、API 调用 | [provider-tests.md](test-cases/provider-tests.md) | +| 凭证池 | 轮询、健康检查、负载均衡 | [integration-tests.md](integration-tests.md) | +| Aster Agent | 流式响应、工具调用 | [agent-tests.md](test-cases/agent-tests.md) | ## 测试原则 @@ -104,11 +145,11 @@ npm run lint ## 评分器类型 -| 类型 | 适用场景 | 优点 | 缺点 | -|------|----------|------|------| -| **代码评分器** | 确定性验证 | 快速、可复现 | 对有效变体脆弱 | -| **模型评分器** | 语义评估 | 灵活、可扩展 | 非确定性、需校准 | -| **人工评分器** | 复杂判断 | 金标准质量 | 昂贵、慢 | +| 类型 | 适用场景 | 优点 | 缺点 | +| -------------- | ---------- | ------------ | ---------------- | +| **代码评分器** | 确定性验证 | 快速、可复现 | 对有效变体脆弱 | +| **模型评分器** | 语义评估 | 灵活、可扩展 | 非确定性、需校准 | +| **人工评分器** | 复杂判断 | 金标准质量 | 昂贵、慢 | ## 评估指标 diff --git a/docs/test/e2e-tests.md b/docs/test/e2e-tests.md index 1272f594a..f57529e66 100644 --- a/docs/test/e2e-tests.md +++ b/docs/test/e2e-tests.md @@ -1,276 +1,126 @@ -# Lime E2E 测试指南 +# Lime 浏览器续测与 E2E 指南 -> 端到端测试验证完整用户流程 +> 本文只保留 Lime 当前仍有效的浏览器端 E2E 入口;详细操作与续测步骤以 `docs/aiprompts/playwright-e2e.md` 为准。 -## 概述 +## 1. 当前事实源 -E2E 测试模拟真实用户操作,验证从前端到后端的完整流程。Lime 使用 Tauri 框架,E2E 测试需要覆盖: +### current -- 桌面应用启动和初始化 -- 用户界面交互 -- API 代理完整流程 -- 凭证管理流程 +- `docs/aiprompts/playwright-e2e.md`:浏览器续测、Playwright MCP 交互、DevBridge 排障的唯一详细事实源 +- `npm run tauri:dev:headless`:当前浏览器模式启动入口 +- `npm run bridge:health -- --timeout-ms 120000`:当前 DevBridge 就绪检查入口 +- `npm run test:bridge`:当前浏览器桥接最小自动校验入口 +- `npm run smoke:workspace-ready`:当前首条自包含 smoke,覆盖 DevBridge 就绪与默认 workspace 基础链路 -## 测试框架 +### supplement -### Tauri E2E 测试 +- `npm run bridge:e2e`:偏排障性质的脚本,不是仓库统一 E2E 标准 +- `npm run smoke:social-workbench`:现有专项 smoke,但仍依赖人工前置状态,暂不等于“自包含主链路冒烟” -使用 `tauri-driver` 进行自动化测试: +### deprecated + +- `tauri-driver`:不再是当前仓库推荐的 E2E 方案 +- `npm run test:e2e`:当前仓库已不存在,不应继续作为执行入口 + +## 2. 何时使用 E2E / 续测 + +以下场景优先走当前浏览器续测流程: + +- 用户明确要求“继续测试”“继续复现”“继续用 Playwright MCP 验证” +- 需要复用已有页面状态或浏览器标签页 +- 需要确认页面真实交互、控制台报错、DevBridge / mock fallback 行为 +- 修改涉及前端页面主路径,而不是单一工具函数或纯后端逻辑 + +以下场景不要强行拉起整条 E2E: + +- 只是模块级逻辑修改,可用单测或定向集成测试覆盖 +- 只是 `safeInvoke`、mock、bridge 边界修改,且 `npm run test:bridge` 足以验证 +- 只是命令注册 / 命令漂移问题,优先跑 `npm run test:contracts` + +## 3. 当前标准流程 + +### 第 1 步:启动浏览器模式 ```bash -# 安装依赖 -cargo install tauri-driver - -# 运行 E2E 测试 -npm run test:e2e +npm run tauri:dev:headless ``` -### 测试配置 +用途: -```javascript -// playwright.config.ts -import { defineConfig } from "@playwright/test"; +- 启动前端 dev server +- 启动 Tauri headless 环境 +- 启动 DevBridge +- 让 Playwright MCP 可访问 `http://127.0.0.1:1420/` -export default defineConfig({ - testDir: "./tests/e2e", - timeout: 30000, - use: { - baseURL: "tauri://localhost", - }, -}); -``` - -## 测试场景 - -### 1. 应用启动流程 - -```typescript -import { test, expect } from "@playwright/test"; - -test.describe("应用启动", () => { - test("应用正常启动并显示主界面", async ({ page }) => { - // 等待应用加载 - await page.waitForSelector('[data-testid="main-layout"]'); - - // 验证核心组件存在 - await expect(page.locator('[data-testid="sidebar"]')).toBeVisible(); - await expect(page.locator('[data-testid="content-area"]')).toBeVisible(); - }); - - test("首次启动显示欢迎引导", async ({ page }) => { - // 清除本地存储模拟首次启动 - await page.evaluate(() => localStorage.clear()); - await page.reload(); - - await expect(page.locator('[data-testid="welcome-modal"]')).toBeVisible(); - }); -}); -``` - -### 2. 凭证管理流程 - -```typescript -test.describe("凭证管理", () => { - test("添加 Kiro 凭证", async ({ page }) => { - // 打开凭证管理 - await page.click('[data-testid="credentials-tab"]'); - await page.click('[data-testid="add-credential-btn"]'); - - // 选择 Provider - await page.click('[data-testid="provider-kiro"]'); - - // 上传凭证文件 - const fileInput = page.locator('input[type="file"]'); - await fileInput.setInputFiles("./tests/fixtures/test-credential.json"); - - // 验证凭证添加成功 - await expect(page.locator('[data-testid="credential-item"]')).toBeVisible(); - await expect(page.locator("text=test@example.com")).toBeVisible(); - }); - - test("删除凭证", async ({ page }) => { - // 假设已有凭证 - await page.click('[data-testid="credentials-tab"]'); - - // 删除凭证 - await page.click('[data-testid="credential-menu"]'); - await page.click('[data-testid="delete-credential"]'); - await page.click('[data-testid="confirm-delete"]'); - - // 验证凭证已删除 - await expect( - page.locator('[data-testid="credential-item"]'), - ).not.toBeVisible(); - }); -}); -``` - -### 3. API 代理流程 - -```typescript -test.describe("API 代理", () => { - test("启动代理服务器", async ({ page }) => { - await page.click('[data-testid="server-tab"]'); - await page.click('[data-testid="start-server-btn"]'); - - // 等待服务器启动 - await expect(page.locator("text=服务器运行中")).toBeVisible(); - await expect(page.locator('[data-testid="server-port"]')).toContainText( - "8080", - ); - }); - - test("代理请求成功", async ({ page, request }) => { - // 启动服务器 - await page.click('[data-testid="start-server-btn"]'); - await page.waitForSelector("text=服务器运行中"); - - // 发送测试请求 - const response = await request.post( - "http://localhost:8080/v1/chat/completions", - { - headers: { - "Content-Type": "application/json", - Authorization: "Bearer test-key", - }, - data: { - model: "gpt-4", - messages: [{ role: "user", content: "Hello" }], - }, - }, - ); - - expect(response.ok()).toBeTruthy(); - }); -}); -``` - -### 4. Agent 对话流程 - -```typescript -test.describe("Agent 对话", () => { - test("发送消息并接收响应", async ({ page }) => { - await page.click('[data-testid="agent-tab"]'); - - // 输入消息 - await page.fill('[data-testid="message-input"]', "你好,请介绍一下自己"); - await page.click('[data-testid="send-btn"]'); - - // 等待响应 - await expect(page.locator('[data-testid="assistant-message"]')).toBeVisible( - { - timeout: 30000, - }, - ); - }); - - test("流式响应正确显示", async ({ page }) => { - await page.click('[data-testid="agent-tab"]'); - await page.fill('[data-testid="message-input"]', "写一首短诗"); - await page.click('[data-testid="send-btn"]'); - - // 验证流式显示(内容逐渐增加) - const messageEl = page.locator('[data-testid="assistant-message"]'); - - let prevLength = 0; - for (let i = 0; i < 5; i++) { - await page.waitForTimeout(500); - const text = await messageEl.textContent(); - expect(text?.length).toBeGreaterThan(prevLength); - prevLength = text?.length || 0; - } - }); -}); -``` - -## 测试数据管理 - -### Fixtures - -``` -tests/ -├── fixtures/ -│ ├── test-credential.json # 测试凭证 -│ ├── mock-responses/ # Mock API 响应 -│ │ ├── chat-completion.json -│ │ └── streaming-response.txt -│ └── test-config.json # 测试配置 -└── e2e/ - └── *.spec.ts -``` - -### Mock 服务 - -```typescript -// tests/mocks/api-server.ts -import { setupServer } from "msw/node"; -import { rest } from "msw"; - -export const mockServer = setupServer( - rest.post("*/v1/chat/completions", (req, res, ctx) => { - return res( - ctx.json({ - id: "test-id", - choices: [ - { - message: { role: "assistant", content: "Mock response" }, - }, - ], - }), - ); - }), -); -``` - -## 运行 E2E 测试 +### 第 2 步:等待桥接就绪 ```bash -# 构建应用 -npm run build - -# 运行 E2E 测试 -npm run test:e2e - -# 运行特定测试 -npm run test:e2e -- --grep "凭证管理" - -# 生成测试报告 -npm run test:e2e -- --reporter=html +npm run bridge:health -- --timeout-ms 120000 ``` -## CI/CD 集成 +用途: -```yaml -# .github/workflows/e2e.yml -name: E2E Tests +- 等待 `http://127.0.0.1:3030/health` 可用 +- 降低页面早于 DevBridge 就绪时的 `Failed to fetch` 噪音 -on: [push, pull_request] +### 第 3 步:使用 Playwright MCP 进入页面 -jobs: - e2e: - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 +标准入口: - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "22" +- 打开 `http://127.0.0.1:1420/` +- 等待“正在加载...”消失 +- 确认默认首页已出现 +- 检查一次 `browser_console_messages(level=error)` - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable +### 第 4 步:沿主路径做最小验证 - - name: Install dependencies - run: npm ci +当前优先验证以下路径: - - name: Build app - run: npm run build +1. 首页可加载,主导航可见 +2. 社媒内容工作流可进入 +3. 页面交互后控制台不新增关键 error - - name: Run E2E tests - run: npm run test:e2e -``` +详细点击路径、控制台检查要求、交接格式,以 `docs/aiprompts/playwright-e2e.md` 为准。 -## 下一步 +## 4. 当前命令矩阵 -- [Agent 评估指南](agent-evaluation.md) -- [测试用例:Agent](test-cases/agent-tests.md) +| 目标 | 命令 / 入口 | 角色 | 说明 | +| ---------------------- | ---------------------------------------------- | ---------- | ----------------------------------------------- | +| 启动浏览器模式 | `npm run tauri:dev:headless` | current | 当前标准启动命令 | +| 等待 Bridge 就绪 | `npm run bridge:health -- --timeout-ms 120000` | current | 当前标准健康检查 | +| 校验桥接基础能力 | `npm run test:bridge` | current | `safeInvoke` / mock / tauri-mock 最小自动校验 | +| Workspace 自包含 smoke | `npm run smoke:workspace-ready` | current | 验证 DevBridge、默认 workspace、路径回查链路 | +| 校验跨层命令契约 | `npm run test:contracts` | current | 检查前端命令、Rust 注册、catalog、mock 集合漂移 | +| 浏览器续测细则 | `docs/aiprompts/playwright-e2e.md` | current | Playwright MCP 唯一详细事实源 | +| 专项 bridge 排障 | `npm run bridge:e2e` | supplement | 适合排障,不是统一门禁 | +| 社媒内容专项 smoke | `npm run smoke:social-workbench` | supplement | 仍非自包含,不应冒充标准 E2E | +| 旧 E2E 命令 | `npm run test:e2e` | deprecated | 当前仓库不存在 | + +## 5. 当前验证标准 + +一次有效的浏览器续测 / E2E 至少满足以下之一: + +1. 主路径走通且控制台 error 归零 +2. 主路径走通,且剩余错误已明确归类为非阻塞项 +3. 已定位新的 bridge / mock / 命令注册缺口,并给出下一步最小修复点 + +## 6. 当前不做的假设 + +本文不再把以下内容当成当前标准: + +- 假设仓库已接入本地 Playwright 测试目录与统一 `test:e2e` 命令 +- 假设 `tauri-driver` 仍是推荐路径 +- 假设浏览器 E2E 已进入 CI 标准门禁 + +当前 PR 门禁以 `.github/workflows/pr-gate.yml` 为准;完整浏览器主链路 smoke 仍属于后续建设项,详见 `docs/test/testing-strategy-2026.md`。 + +## 7. 给后续 Agent 的交接要求 + +如果本轮没有完全收口,请至少留下: + +- 当前页面 URL +- 已完成的业务步骤 +- 控制台 error 数量 +- 是否走到了真实 bridge 或 mock fallback +- 最新暴露的命令缺口 +- 下一轮应先补 mock、bridge,还是命令注册 diff --git a/docs/test/testing-strategy-2026.md b/docs/test/testing-strategy-2026.md new file mode 100644 index 000000000..524e175d0 --- /dev/null +++ b/docs/test/testing-strategy-2026.md @@ -0,0 +1,89 @@ +# Lime 测试体系待办(2026) + +> 本文件只保留当前仍未解决的测试问题;已落地能力已从优先级清单移除。 + +## 1. 事实源与分类 + +### current + +以下路径已经是当前测试体系的事实源,不再作为“待建设能力”重复列入: + +- `docs/test/README.md`:当前测试入口与命令索引 +- `docs/test/e2e-tests.md`:当前浏览器续测与 E2E 总览入口 +- `docs/aiprompts/playwright-e2e.md`:当前浏览器续测 / Playwright MCP 事实源 +- `package.json`:当前统一测试命令入口 +- `scripts/local-ci.mjs`:当前本地智能校验入口 +- `scripts/report-legacy-surfaces.mjs`:当前 legacy / compat 回流护栏 +- `.github/workflows/pr-gate.yml`:当前 PR 自动门禁入口 + +### compat + +- 当前无仍需保留的 E2E compat 文档 + +### deprecated + +- `tauri-driver` 作为仓库推荐 E2E 方案的说法 +- `npm run test:e2e` 作为现行测试入口的说法 + +### dead + +- `npm run test:e2e` 作为现行仓库命令已不存在,不应继续作为测试标准引用 + +## 2. 已从待办移除的事项 + +以下能力已具备基础,不再保留在优先级清单中: + +- 前端 `Vitest` 覆盖已经足够广,`src/components`、`src/hooks`、`src/lib/api`、`src/features/browser-runtime` 等已有大量测试 +- Rust 单测 / 集成测试基础已经存在,`src-tauri/src` 与多个 workspace crate 都有可运行测试 +- 本地统一校验入口已经存在:`test:frontend`、`test:bridge`、`test:rust`、`verify:local`、`verify:local:full` +- 桥接基础测试已经存在:`src/lib/dev-bridge/safeInvoke.test.ts`、`src/lib/tauri-mock/core.test.ts` +- legacy 治理护栏已经存在:`npm run governance:legacy-report` +- 旧权限表面治理护栏已经补齐:`src/lib/governance/legacyToolPermissionGuard.test.ts` + `npm run governance:legacy-report` +- 跨层命令契约检查基础版已经落地:`npm run test:contracts` 已进入 `scripts/local-ci.mjs` 与 `.github/workflows/pr-gate.yml` +- 命令契约延期例外已经收口:`agent_terminal_command_response`、`agent_term_scrollback_response` 已退出 `runtimeGatewayCommands`,改为 `dead-candidate` 治理监控 +- 首条自包含 smoke 已落地:`npm run smoke:workspace-ready` 可自动校验 DevBridge 就绪、默认 workspace 获取、目录修复与路径回查 +- 测试文档事实源已经收口:`docs/test/README.md`、`docs/test/e2e-tests.md`、`docs/aiprompts/playwright-e2e.md` 已按“索引 / 总览 / 详细事实源”分层 +- PR 自动门禁已经补齐:`.github/workflows/pr-gate.yml` 已覆盖前端、bridge、Rust 三类基础检查 + +## 3. 当前仍未解决的问题优先级 + +| 优先级 | 事项 | 为什么重要 | 当前证据 | 完成定义 | +| ------ | --------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| P0 | 自包含 smoke 仍然不足 | 单测很多,但主链路仍缺少无需人工准备的自动回归 | 目前仅有 `smoke:workspace-ready` 属于自包含 smoke;`smoke:social-workbench` 仍依赖已有 session,`bridge:e2e` 更像排障脚本 | 至少补齐 3 条无需人工准备的 smoke;当前已完成 1 条,仍需补 server / terminal / browser runtime 等 2 条以上 | +| P1 | Agent eval 尚未工程化 | 价值高,但建立在前面基础门禁稳定之后 | 仓库已有理念和局部真实测试,但缺少任务集、grader、nightly 报表 | 形成固定任务集、采样归档、grader、nightly 输出与趋势指标 | + +## 4. 建议执行顺序 + +### 第 1 步:把 smoke 升级为自包含场景 + +先只挑 3 条最高价值场景,不要贪多: + +1. 应用启动 + workspace 可创建 / 打开 +2. server 基础链路可自动打通 +3. terminal 或 browser runtime 至少有一条基础链路可自动打通 + +验收标准是“本地和 CI 都能重复执行”,而不是“方便人工排障”。 + +### 第 2 步:把 Agent eval 工程化 + +这一步放在最后,不是因为不重要,而是它依赖前面的基础设施稳定: + +- 有稳定门禁 +- 有稳定契约检查 +- 有可重复 smoke + +完成后再上: + +- 固定任务集 +- transcript 存档 +- grader +- nightly 报表 + +## 5. 当前建议 + +如果只看投入产出比,当前最值得先做的两刀是: + +1. 把 smoke 升级为自包含场景 +2. 把 Agent eval 工程化 + +这两步做完之后,再继续往 nightly 与趋势报表收口,收益会更高。 diff --git a/package.json b/package.json index b26f15a65..2f2c365d8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "0.91.0", + "version": "0.92.0", "type": "module", "engines": { "node": ">=22.0.0" @@ -28,14 +28,22 @@ "tauri:dev:profile:trace-console": "node scripts/run-tauri-profile.mjs trace-console", "tauri:dev:profile:trace-console:devtools": "node scripts/run-tauri-profile.mjs trace-console --open-devtools", "lint": "eslint src --max-warnings 0", + "typecheck": "tsc --noEmit", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "prepare": "husky", "test": "vitest --run", "test:watch": "vitest", + "test:frontend": "npm run lint && npm run typecheck && npm test", + "test:bridge": "npm test -- src/lib/dev-bridge/safeInvoke.test.ts src/lib/tauri-mock/core.test.ts", + "test:contracts": "node scripts/check-command-contracts.mjs", + "test:rust": "cargo test --manifest-path \"src-tauri/Cargo.toml\"", + "lint:rust": "cargo clippy --manifest-path \"src-tauri/Cargo.toml\"", "detect-translations": "tsx scripts/detect-missing-translations.ts", "detect-translations:fix": "tsx scripts/detect-missing-translations.ts --fix", "detect-translations:verbose": "tsx scripts/detect-missing-translations.ts --verbose", "verify:app-version": "node scripts/check-app-version-consistency.mjs", + "verify:local": "node scripts/local-ci.mjs", + "verify:local:full": "node scripts/local-ci.mjs --full", "ai-verify": "tsx scripts/ai-code-verify.ts", "ai-verify:level1": "tsx scripts/ai-code-verify.ts --level 1", "ai-verify:level2": "tsx scripts/ai-code-verify.ts --level 2", @@ -43,6 +51,7 @@ "ai-verify:file": "tsx scripts/ai-code-verify.ts --files", "bridge:e2e": "node scripts/chrome-bridge-e2e.mjs", "bridge:health": "node scripts/check-dev-bridge-health.mjs", + "smoke:workspace-ready": "node scripts/workspace-ready-smoke.mjs", "smoke:social-workbench": "node scripts/social-workbench-e2e-smoke.mjs", "dev:web-bridge": "node scripts/start-web-bridge-dev.mjs", "governance:legacy-report": "node scripts/report-legacy-surfaces.mjs", diff --git a/scripts/check-command-contracts.mjs b/scripts/check-command-contracts.mjs new file mode 100644 index 000000000..7eda03b49 --- /dev/null +++ b/scripts/check-command-contracts.mjs @@ -0,0 +1,427 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const repoRoot = process.cwd(); + +const sourceRoots = ["src"]; +const sourceExtensions = new Set([".ts", ".tsx", ".js", ".jsx"]); +const ignoredDirectories = new Set([ + ".git", + ".idea", + ".vscode", + "coverage", + "dist", + "docs", + "node_modules", + "target", +]); + +const frontendCommandPatterns = [ + /\bsafeInvoke(?:<[^>]+>)?\s*\(\s*["'`]([^"'`]+)["'`]/g, + /\binvoke(?:<[^>]+>)?\s*\(\s*["'`]([^"'`]+)["'`]/g, +]; + +const knownDeferredRegistrationReasons = new Map(); + +function normalizePath(filePath) { + return filePath.split(path.sep).join("/"); +} + +function isRuntimeSource(relativePath) { + const normalizedPath = normalizePath(relativePath); + const extension = path.extname(normalizedPath); + if (!sourceExtensions.has(extension)) { + return false; + } + if (normalizedPath.endsWith(".d.ts")) { + return false; + } + if ( + normalizedPath.includes("/__tests__/") || + normalizedPath.includes("/__mocks__/") || + /\.test\.[^.]+$/.test(normalizedPath) || + /\.spec\.[^.]+$/.test(normalizedPath) + ) { + return false; + } + return true; +} + +function walkDirectory(rootDirectory) { + const results = []; + if (!fs.existsSync(rootDirectory)) { + return results; + } + + const entries = fs.readdirSync(rootDirectory, { withFileTypes: true }); + for (const entry of entries) { + if (ignoredDirectories.has(entry.name)) { + continue; + } + + const absolutePath = path.join(rootDirectory, entry.name); + if (entry.isDirectory()) { + results.push(...walkDirectory(absolutePath)); + continue; + } + + const relativePath = normalizePath(path.relative(repoRoot, absolutePath)); + if (isRuntimeSource(relativePath)) { + results.push(relativePath); + } + } + + return results; +} + +function addUsage(map, command, relativePath) { + if (!map.has(command)) { + map.set(command, new Set()); + } + map.get(command).add(relativePath); +} + +function extractCommandsFromSource(sourceCode) { + const commands = new Set(); + for (const pattern of frontendCommandPatterns) { + for (const match of sourceCode.matchAll(pattern)) { + commands.add(match[1]); + } + } + return commands; +} + +function collectFrontendCommandUsage() { + const commandUsage = new Map(); + for (const root of sourceRoots) { + const absoluteRoot = path.join(repoRoot, root); + for (const relativePath of walkDirectory(absoluteRoot)) { + const absolutePath = path.join(repoRoot, relativePath); + const sourceCode = fs.readFileSync(absolutePath, "utf8"); + for (const command of extractCommandsFromSource(sourceCode)) { + addUsage(commandUsage, command, relativePath); + } + } + } + return commandUsage; +} + +function extractBalancedBlock(sourceCode, startIndex, openChar, closeChar) { + let depth = 0; + let inSingleQuote = false; + let inDoubleQuote = false; + let inTemplateString = false; + let inLineComment = false; + let inBlockComment = false; + let escaped = false; + + for (let index = startIndex; index < sourceCode.length; index += 1) { + const currentChar = sourceCode[index]; + const nextChar = sourceCode[index + 1]; + + if (inLineComment) { + if (currentChar === "\n") { + inLineComment = false; + } + continue; + } + + if (inBlockComment) { + if (currentChar === "*" && nextChar === "/") { + inBlockComment = false; + index += 1; + } + continue; + } + + if (inSingleQuote) { + if (!escaped && currentChar === "'") { + inSingleQuote = false; + } + escaped = !escaped && currentChar === "\\"; + continue; + } + + if (inDoubleQuote) { + if (!escaped && currentChar === '"') { + inDoubleQuote = false; + } + escaped = !escaped && currentChar === "\\"; + continue; + } + + if (inTemplateString) { + if (!escaped && currentChar === "`") { + inTemplateString = false; + } + escaped = !escaped && currentChar === "\\"; + continue; + } + + if (currentChar === "/" && nextChar === "/") { + inLineComment = true; + index += 1; + continue; + } + + if (currentChar === "/" && nextChar === "*") { + inBlockComment = true; + index += 1; + continue; + } + + if (currentChar === "'") { + inSingleQuote = true; + escaped = false; + continue; + } + + if (currentChar === '"') { + inDoubleQuote = true; + escaped = false; + continue; + } + + if (currentChar === "`") { + inTemplateString = true; + escaped = false; + continue; + } + + if (currentChar === openChar) { + depth += 1; + continue; + } + + if (currentChar === closeChar) { + depth -= 1; + if (depth === 0) { + return sourceCode.slice(startIndex + 1, index); + } + } + } + + throw new Error(`无法提取 ${openChar}${closeChar} 平衡块`); +} + +function collectRegisteredCommands() { + const runnerPath = path.join(repoRoot, "src-tauri/src/app/runner.rs"); + const sourceCode = fs.readFileSync(runnerPath, "utf8"); + const marker = "tauri::generate_handler!["; + const markerIndex = sourceCode.indexOf(marker); + if (markerIndex < 0) { + throw new Error("未找到 tauri::generate_handler! 注册块"); + } + + const bracketStart = markerIndex + marker.length - 1; + const handlerBody = extractBalancedBlock(sourceCode, bracketStart, "[", "]"); + const registeredCommands = new Set(); + const withoutBlockComments = handlerBody.replace(/\/\*[\s\S]*?\*\//g, ""); + + for (const line of withoutBlockComments.split("\n")) { + const trimmedLine = line.replace(/\/\/.*$/, "").trim(); + if (!trimmedLine) { + continue; + } + + const match = trimmedLine.match(/^([A-Za-z0-9_:]+)\s*,?$/); + if (!match) { + continue; + } + + const fullPath = match[1]; + const command = fullPath.split("::").pop(); + if (command) { + registeredCommands.add(command); + } + } + + return registeredCommands; +} + +function collectMockPriorityCommands() { + const filePath = path.join( + repoRoot, + "src/lib/dev-bridge/mockPriorityCommands.ts", + ); + const sourceCode = fs.readFileSync(filePath, "utf8"); + const match = sourceCode.match( + /const mockPriorityCommands = new Set\(\[([\s\S]*?)\]\);/, + ); + if (!match) { + throw new Error("未找到 mockPriorityCommands 定义"); + } + + const commands = new Set(); + for (const stringMatch of match[1].matchAll(/["'`]([^"'`]+)["'`]/g)) { + commands.add(stringMatch[1]); + } + return commands; +} + +function collectDefaultMockCommands() { + const filePath = path.join(repoRoot, "src/lib/tauri-mock/core.ts"); + const sourceCode = fs.readFileSync(filePath, "utf8"); + const marker = "const defaultMocks: Record = {"; + const markerIndex = sourceCode.indexOf(marker); + if (markerIndex < 0) { + throw new Error("未找到 tauri-mock defaultMocks 定义"); + } + + const braceStart = markerIndex + marker.length - 1; + const objectBody = extractBalancedBlock(sourceCode, braceStart, "{", "}"); + const mockCommands = new Set(); + + for (const match of objectBody.matchAll(/^ ([A-Za-z0-9_]+)\s*:/gm)) { + mockCommands.add(match[1]); + } + + return mockCommands; +} + +function readAgentCommandCatalog() { + const catalogPath = path.join( + repoRoot, + "src/lib/governance/agentCommandCatalog.json", + ); + return JSON.parse(fs.readFileSync(catalogPath, "utf8")); +} + +function sortCommands(commands) { + return [...commands].sort((left, right) => left.localeCompare(right)); +} + +function printCommandGroup(title, commands, usageMap) { + console.error(`\n## ${title}`); + for (const command of sortCommands(commands)) { + console.error(`- ${command}`); + if (usageMap?.has(command)) { + const files = sortCommands(usageMap.get(command)); + for (const file of files) { + console.error(` - ${file}`); + } + } + } +} + +function main() { + const frontendUsage = collectFrontendCommandUsage(); + const frontendCommands = new Set(frontendUsage.keys()); + const registeredCommands = collectRegisteredCommands(); + const mockPriorityCommands = collectMockPriorityCommands(); + const defaultMockCommands = collectDefaultMockCommands(); + const agentCommandCatalog = readAgentCommandCatalog(); + + const deprecatedCommands = new Set( + Object.keys(agentCommandCatalog.deprecatedCommandReplacements ?? {}), + ); + const runtimeGatewayCommands = new Set( + agentCommandCatalog.runtimeGatewayCommands ?? [], + ); + + const deferredCommands = new Set(knownDeferredRegistrationReasons.keys()); + + const missingRegistrations = new Set( + [...frontendCommands].filter( + (command) => + !registeredCommands.has(command) && !deferredCommands.has(command), + ), + ); + const deprecatedCommandsStillUsed = new Set( + [...frontendCommands].filter((command) => deprecatedCommands.has(command)), + ); + const mockPriorityMissingMocks = new Set( + [...mockPriorityCommands].filter( + (command) => !defaultMockCommands.has(command), + ), + ); + const mockPriorityMissingRegistrations = new Set( + [...mockPriorityCommands].filter( + (command) => + !registeredCommands.has(command) && !deferredCommands.has(command), + ), + ); + const runtimeGatewayMissingRegistrations = new Set( + [...runtimeGatewayCommands].filter( + (command) => + !registeredCommands.has(command) && !deferredCommands.has(command), + ), + ); + + console.log("[command-contracts] frontend commands:", frontendCommands.size); + console.log( + "[command-contracts] rust registered commands:", + registeredCommands.size, + ); + console.log( + "[command-contracts] mock priority commands:", + mockPriorityCommands.size, + ); + console.log( + "[command-contracts] default mock commands:", + defaultMockCommands.size, + ); + + if (knownDeferredRegistrationReasons.size > 0) { + console.log("\n[command-contracts] 已登记的延期命令:"); + for (const command of sortCommands( + knownDeferredRegistrationReasons.keys(), + )) { + console.log(`- ${command}`); + console.log(` ${knownDeferredRegistrationReasons.get(command)}`); + } + } + + let hasError = false; + + if (missingRegistrations.size > 0) { + hasError = true; + printCommandGroup( + "前端调用但未注册的命令", + missingRegistrations, + frontendUsage, + ); + } + + if (deprecatedCommandsStillUsed.size > 0) { + hasError = true; + printCommandGroup( + "前端仍在调用的废弃命令", + deprecatedCommandsStillUsed, + frontendUsage, + ); + } + + if (mockPriorityMissingMocks.size > 0) { + hasError = true; + printCommandGroup("mock 优先命令缺少 mock 实现", mockPriorityMissingMocks); + } + + if (mockPriorityMissingRegistrations.size > 0) { + hasError = true; + printCommandGroup( + "mock 优先命令缺少 Rust 注册", + mockPriorityMissingRegistrations, + ); + } + + if (runtimeGatewayMissingRegistrations.size > 0) { + hasError = true; + printCommandGroup( + "runtime gateway 命令缺少 Rust 注册", + runtimeGatewayMissingRegistrations, + ); + } + + if (hasError) { + process.exitCode = 1; + return; + } + + console.log("\n[command-contracts] 所有命令契约检查通过。"); +} + +main(); diff --git a/scripts/local-ci.mjs b/scripts/local-ci.mjs new file mode 100644 index 000000000..56d369c81 --- /dev/null +++ b/scripts/local-ci.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node + +import { execFileSync, spawnSync } from "node:child_process"; +import process from "node:process"; + +const options = parseArgs(process.argv.slice(2)); +const rootDir = process.cwd(); + +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const cargoCommand = process.platform === "win32" ? "cargo.exe" : "cargo"; +const gitCommand = process.platform === "win32" ? "git.exe" : "git"; + +const FRONTEND_ROOT_FILES = new Set([ + "package.json", + "package-lock.json", + "vite.config.ts", + "tsconfig.json", + "tsconfig.node.json", + "eslint.config.js", + "tailwind.config.js", + "postcss.config.js", + "index.html", +]); + +const BRIDGE_FILES = new Set([ + "vite.config.ts", + "scripts/check-dev-bridge-health.mjs", + "scripts/social-workbench-e2e-smoke.mjs", + "scripts/chrome-bridge-e2e.mjs", + "docs/aiprompts/playwright-e2e.md", +]); + +function parseArgs(argv) { + const result = { + full: false, + staged: false, + base: "", + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--full") { + result.full = true; + continue; + } + if (arg === "--staged") { + result.staged = true; + continue; + } + if (arg === "--base" && argv[index + 1]) { + result.base = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--help" || arg === "-h") { + result.help = true; + } + } + + return result; +} + +function printHelp() { + console.log(` +Lime 本地校验入口 + +用法: + npm run verify:local + npm run verify:local -- --staged + npm run verify:local -- --base origin/main + npm run verify:local:full + +选项: + --full 忽略改动检测,执行全量本地校验 + --staged 仅基于已暂存文件判断要跑的检查 + --base REF 基于指定基线计算改动文件 + -h, --help 显示帮助 +`); +} + +function runCommand(command, args) { + console.log(`\n[local-ci] > ${command} ${args.join(" ")}`); + const result = spawnSync(command, args, { + cwd: rootDir, + stdio: "inherit", + env: process.env, + }); + + if (typeof result.status === "number" && result.status !== 0) { + process.exit(result.status); + } + + if (result.error) { + throw result.error; + } +} + +function gitOutput(args) { + try { + return execFileSync(gitCommand, args, { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return ""; + } +} + +function splitLines(value) { + if (!value) { + return []; + } + return value + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); +} + +function resolveDiffBase() { + if (options.base) { + return options.base; + } + + const upstream = gitOutput(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]); + if (upstream) { + return upstream; + } + + for (const candidate of ["origin/main", "origin/master", "main", "master"]) { + const exists = gitOutput(["rev-parse", "--verify", candidate]); + if (exists) { + return candidate; + } + } + + return ""; +} + +function collectChangedFiles() { + if (options.full) { + return []; + } + + if (options.staged) { + return uniquePaths( + splitLines(gitOutput(["diff", "--cached", "--name-only", "--diff-filter=ACMR"])), + ); + } + + const base = resolveDiffBase(); + const candidates = []; + + if (base) { + candidates.push( + ...splitLines(gitOutput(["diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`])), + ); + } + + candidates.push( + ...splitLines(gitOutput(["diff", "--name-only", "--diff-filter=ACMR", "HEAD"])), + ); + candidates.push( + ...splitLines(gitOutput(["ls-files", "--others", "--exclude-standard"])), + ); + + return uniquePaths(candidates); +} + +function uniquePaths(paths) { + return Array.from(new Set(paths)); +} + +function isFrontendChange(file) { + return ( + file.startsWith("src/") || + FRONTEND_ROOT_FILES.has(file) + ); +} + +function isRustChange(file) { + return file.startsWith("src-tauri/"); +} + +function isBridgeChange(file) { + return ( + file.startsWith("src/lib/dev-bridge/") || + file.startsWith("src/lib/tauri-mock/") || + BRIDGE_FILES.has(file) + ); +} + +function isDocsOnlyChange(files) { + return files.length > 0 && files.every((file) => file.startsWith("docs/")); +} + +function detectTasks(changedFiles) { + if (options.full) { + return { + frontend: true, + rust: true, + bridge: true, + }; + } + + if (changedFiles.length === 0) { + return { + frontend: true, + rust: true, + bridge: true, + fallback: true, + }; + } + + if (isDocsOnlyChange(changedFiles)) { + return { + frontend: false, + rust: false, + bridge: false, + docsOnly: true, + }; + } + + const frontend = changedFiles.some(isFrontendChange); + const rust = changedFiles.some(isRustChange); + const bridge = changedFiles.some(isBridgeChange); + + return { + frontend, + rust, + bridge, + }; +} + +function printSummary(changedFiles, tasks) { + console.log("[local-ci] 模式:", options.full ? "full" : "smart"); + if (!options.full) { + console.log("[local-ci] 检测到改动文件数:", changedFiles.length); + if (changedFiles.length > 0) { + const preview = changedFiles.slice(0, 12); + for (const file of preview) { + console.log(`[local-ci] - ${file}`); + } + if (changedFiles.length > preview.length) { + console.log(`[local-ci] ... 其余 ${changedFiles.length - preview.length} 个文件省略`); + } + } + } + + if (tasks.docsOnly) { + console.log("[local-ci] 当前仅检测到文档改动,跳过本地代码校验。"); + return; + } + + console.log("[local-ci] 计划执行:"); + if (tasks.frontend) { + console.log("[local-ci] - 前端校验"); + } + if (tasks.bridge) { + console.log("[local-ci] - bridge 校验"); + } + if (tasks.rust) { + console.log("[local-ci] - Rust 校验"); + } + if (tasks.fallback) { + console.log("[local-ci] - 未检测到改动,执行全量兜底校验"); + } +} + +function runSelectedTasks(tasks) { + if (tasks.docsOnly) { + return; + } + + if (tasks.frontend) { + runCommand(npmCommand, ["run", "lint"]); + runCommand(npmCommand, ["run", "typecheck"]); + runCommand(npmCommand, ["test"]); + } + + if (tasks.bridge) { + if (!tasks.frontend) { + runCommand(npmCommand, ["run", "test:bridge"]); + } + runCommand(npmCommand, ["run", "test:contracts"]); + } + + if (tasks.rust) { + runCommand(cargoCommand, ["test", "--manifest-path", "src-tauri/Cargo.toml"]); + if (options.full) { + runCommand(cargoCommand, ["clippy", "--manifest-path", "src-tauri/Cargo.toml"]); + } + } +} + +function main() { + if (options.help) { + printHelp(); + return; + } + + const changedFiles = collectChangedFiles(); + const tasks = detectTasks(changedFiles); + printSummary(changedFiles, tasks); + runSelectedTasks(tasks); + console.log("\n[local-ci] 本地校验完成。"); +} + +main(); diff --git a/scripts/report-legacy-surfaces.mjs b/scripts/report-legacy-surfaces.mjs index 58a2f0e52..94ff53698 100644 --- a/scripts/report-legacy-surfaces.mjs +++ b/scripts/report-legacy-surfaces.mjs @@ -172,6 +172,27 @@ const importSurfaceMonitors = [ targets: ["src/lib/api/contextMemory.ts"], allowedPaths: [], }, + { + id: "team-subagent-scheduler-hook", + classification: "compat", + description: "旧 SubAgent scheduler Hook 只允许停留在 compat 展示层", + targets: ["src/hooks/useSubAgentScheduler.ts"], + allowedPaths: [ + "src/components/agent/chat/hooks/useCompatSubagentRuntime.ts", + "src/components/subagent/SubAgentProgress.tsx", + "src/components/subagent/index.ts", + ], + }, + { + id: "team-subagent-scheduler-api", + classification: "compat", + description: "旧 SubAgent scheduler API 只允许被 compat Hook 与降级展示层引用", + targets: ["src/lib/api/subAgentScheduler.ts"], + allowedPaths: [ + "src/hooks/useSubAgentScheduler.ts", + "src/components/agent/chat/utils/compatSubagentRuntime.ts", + ], + }, ]; const commandSurfaceMonitors = [ @@ -293,10 +314,24 @@ const commandSurfaceMonitors = [ ], allowedPaths: [], }, + { + id: "team-subagent-scheduler-commands", + classification: "compat", + description: "旧 execute_subagent_tasks/cancel_subagent_tasks 只允许通过 compat API 网关暴露", + commands: ["execute_subagent_tasks", "cancel_subagent_tasks"], + allowedPaths: ["src/lib/api/subAgentScheduler.ts"], + }, ]; const frontendTextSurfaceMonitors = [ ...agentLegacyHelperSurfaceMonitors, + { + id: "frontend-subagent-scheduler-event-bus", + classification: "compat", + description: "旧 subagent scheduler 事件名只允许 compat Hook 持有", + patterns: ["subagent-scheduler-event"], + allowedPaths: ["src/hooks/useSubAgentScheduler.ts"], + }, { id: "frontend-assistant-settings-surfaces", classification: "dead-candidate", @@ -323,6 +358,13 @@ const frontendTextSurfaceMonitors = [ ]; const rustTextSurfaceMonitors = [ + { + id: "rust-subagent-scheduler-event-bus", + classification: "compat", + description: "旧 subagent scheduler 事件名只允许 compat Rust emitter 持有", + patterns: ["subagent-scheduler-event"], + allowedPaths: ["src-tauri/src/agent/subagent_scheduler.rs"], + }, { id: "rust-general-chat-dao", classification: "dead-candidate", @@ -378,7 +420,8 @@ const rustTextSurfaceMonitors = [ { id: "rust-legacy-general-module-imports", classification: "dead-candidate", - description: "已零引用的 Rust 外部模块 direct pending/legacy general 子模块", + description: + "已零引用的 Rust 外部模块 direct pending/legacy general 子模块", patterns: [ "crate::database::legacy_general_chat::", "lime_core::database::legacy_general_chat::", @@ -441,7 +484,8 @@ const rustTextSurfaceMonitors = [ { id: "rust-memory-profile-prompt-helper-leak", classification: "deprecated", - description: "低层 build_memory_profile_prompt helper 泄漏到统一装配边界之外", + description: + "低层 build_memory_profile_prompt helper 泄漏到统一装配边界之外", patterns: ["build_memory_profile_prompt("], includePathPrefixes: ["src-tauri/src"], allowedPaths: ["src-tauri/src/services/memory_profile_prompt_service.rs"], @@ -449,7 +493,8 @@ const rustTextSurfaceMonitors = [ { id: "rust-memory-sources-prompt-helper-leak", classification: "deprecated", - description: "低层 build_memory_sources_prompt helper 泄漏到统一装配边界之外", + description: + "低层 build_memory_sources_prompt helper 泄漏到统一装配边界之外", patterns: ["build_memory_sources_prompt("], includePathPrefixes: ["src-tauri/src"], allowedPaths: [ @@ -525,7 +570,8 @@ const rustTextSurfaceMonitors = [ { id: "rust-skill-runtime-command-bootstrap-leak", classification: "dead-candidate", - description: "已零引用的 skill runtime 准备与 provider fallback 回流到 skill_exec_cmd 命令层", + description: + "已零引用的 skill runtime 准备与 provider fallback 回流到 skill_exec_cmd 命令层", patterns: [ "ensure_browser_mcp_tools_registered(", "ensure_social_image_tool_registered(", @@ -540,7 +586,8 @@ const rustTextSurfaceMonitors = [ { id: "rust-skill-catalog-command-leak", classification: "dead-candidate", - description: "已零引用的 skill catalog 枚举与详情装配回流到 skill_exec_cmd 命令层", + description: + "已零引用的 skill catalog 枚举与详情装配回流到 skill_exec_cmd 命令层", patterns: [ "get_skill_roots(", "load_skills_from_directory(", @@ -557,9 +604,10 @@ const rustTextSurfaceMonitors = [ { id: "rust-skill-mode-branch-command-leak", classification: "dead-candidate", - description: "已零引用的 skill execution_mode 分支回流到 skill_exec_cmd 命令层", + description: + "已零引用的 skill execution_mode 分支回流到 skill_exec_cmd 命令层", patterns: [ - "skill.execution_mode == \"workflow\"", + 'skill.execution_mode == "workflow"', "!skill.workflow_steps.is_empty()", "execute_skill_workflow(", "execute_skill_prompt(", @@ -581,7 +629,8 @@ const rustTextSurfaceMonitors = [ { id: "rust-service-agent-table-query-leak", classification: "dead-candidate", - description: "已零引用的 Tauri service 层 direct agent_sessions/agent_messages 查询回流", + description: + "已零引用的 Tauri service 层 direct agent_sessions/agent_messages 查询回流", patterns: [ "FROM agent_sessions s", "FROM agent_messages m", @@ -593,8 +642,12 @@ const rustTextSurfaceMonitors = [ { id: "rust-service-model-usage-table-query-leak", classification: "dead-candidate", - description: "已零引用的 Tauri service 层 direct model_usage_stats 查询回流", - patterns: ["FROM model_usage_stats", "SELECT COUNT(*) FROM model_usage_stats"], + description: + "已零引用的 Tauri service 层 direct model_usage_stats 查询回流", + patterns: [ + "FROM model_usage_stats", + "SELECT COUNT(*) FROM model_usage_stats", + ], includePathPrefixes: ["src-tauri/src/services"], allowedPaths: [], }, @@ -617,12 +670,15 @@ const rustTextSurfaceMonitors = [ description: "legacy runtime queue 表名从数据库迁移边界向外扩散", patterns: ["agent_runtime_queued_turns"], includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], - allowedPaths: ["src-tauri/crates/core/src/database/agent_runtime_queue_repository.rs"], + allowedPaths: [ + "src-tauri/crates/core/src/database/agent_runtime_queue_repository.rs", + ], }, { id: "rust-agent-runtime-legacy-queue-migration-leak", classification: "dead-candidate", - description: "已零引用的 legacy runtime queue 启动迁移 helper 回流到其他模块", + description: + "已零引用的 legacy runtime queue 启动迁移 helper 回流到其他模块", patterns: ["migrate_legacy_runtime_queue_to_aster_store("], includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], allowedPaths: [], @@ -638,15 +694,20 @@ const rustTextSurfaceMonitors = [ { id: "rust-agent-session-structured-todo-helper-bypass", classification: "dead-candidate", - description: "已零引用的 Lime 业务层绕过 unified todo helper 直接读取 TodoListState", - patterns: ["TodoListState::from_extension_data(", "TodoListState::from_markdown("], + description: + "已零引用的 Lime 业务层绕过 unified todo helper 直接读取 TodoListState", + patterns: [ + "TodoListState::from_extension_data(", + "TodoListState::from_markdown(", + ], includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], allowedPaths: [], }, { id: "rust-services-default-workspace-query-leak", classification: "dead-candidate", - description: "已零引用的 services crate direct 默认 workspace root 查询回流", + description: + "已零引用的 services crate direct 默认 workspace root 查询回流", patterns: ["SELECT root_path FROM workspaces WHERE is_default = 1 LIMIT 1"], includePathPrefixes: ["src-tauri/crates/services/src"], allowedPaths: [], @@ -670,7 +731,9 @@ const rustTextSurfaceMonitors = [ "AgentDao::update_working_dir(", "AgentDao::update_execution_strategy(", ], - allowedPaths: ["src-tauri/crates/core/src/database/agent_session_repository.rs"], + allowedPaths: [ + "src-tauri/crates/core/src/database/agent_session_repository.rs", + ], }, { id: "rust-agent-session-direct-delete", @@ -684,7 +747,9 @@ const rustTextSurfaceMonitors = [ classification: "deprecated", description: "Rust 业务层 direct AgentDao::create_session 回流", patterns: ["AgentDao::create_session("], - allowedPaths: ["src-tauri/crates/core/src/database/agent_session_repository.rs"], + allowedPaths: [ + "src-tauri/crates/core/src/database/agent_session_repository.rs", + ], }, { id: "rust-agent-dao-row-type-leak", @@ -746,6 +811,92 @@ const rustTextSurfaceMonitors = [ includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"], allowedPaths: [], }, + { + id: "rust-agent-tool-permission-public-module-leak", + classification: "dead-candidate", + description: "lime-agent 重新对 crate 外暴露旧 tool_permissions 模块", + patterns: ["pub mod tool_permissions;"], + includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"], + allowedPaths: [], + }, + { + id: "rust-agent-shell-security-public-module-leak", + classification: "dead-candidate", + description: "lime-agent 重新对 crate 外暴露旧 shell_security 模块", + patterns: ["pub mod shell_security;"], + includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"], + allowedPaths: [], + }, + { + id: "rust-agent-tool-permission-module-compiled-leak", + classification: "dead-candidate", + description: "旧 tool_permissions 模块重新回到 lime-agent lib.rs 编译图", + patterns: ["mod tool_permissions;"], + includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"], + allowedPaths: [], + }, + { + id: "rust-agent-shell-security-module-compiled-leak", + classification: "dead-candidate", + description: "旧 shell_security 模块重新回到 lime-agent lib.rs 编译图", + patterns: ["mod shell_security;"], + includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"], + allowedPaths: [], + }, + { + id: "rust-agent-tool-permission-root-export-leak", + classification: "dead-candidate", + description: "lime-agent crate 根重新暴露旧 tool_permissions 类型出口", + patterns: ["pub use tool_permissions::"], + includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"], + allowedPaths: [], + }, + { + id: "rust-agent-shell-security-root-export-leak", + classification: "dead-candidate", + description: "lime-agent crate 根重新暴露旧 shell_security 类型出口", + patterns: ["pub use shell_security::"], + includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"], + allowedPaths: [], + }, + { + id: "rust-agent-tool-permission-direct-module-usage", + classification: "dead-candidate", + description: + "上层模块重新 direct 依赖 lime_agent::tool_permissions 模块路径", + patterns: ["lime_agent::tool_permissions::"], + includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], + allowedPaths: [], + }, + { + id: "rust-agent-shell-security-direct-module-usage", + classification: "dead-candidate", + description: "上层模块重新 direct 依赖 lime_agent::shell_security 模块路径", + patterns: ["lime_agent::shell_security::"], + includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], + allowedPaths: [], + }, + { + id: "rust-agent-tool-permission-root-type-usage", + classification: "dead-candidate", + description: "上层模块重新 direct 依赖 lime_agent 根导出的旧权限类型", + patterns: [ + "lime_agent::DynamicPermissionCheck", + "lime_agent::PermissionBehavior", + "lime_agent::ShellSecurityChecker", + ], + includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], + allowedPaths: [], + }, + { + id: "rust-agent-tool-permission-internal-module-usage", + classification: "dead-candidate", + description: + "lime-agent 内部除兼容壳外重新扩散 crate::tool_permissions 模块依赖", + patterns: ["crate::tool_permissions::"], + includePathPrefixes: ["src-tauri/crates/agent/src"], + allowedPaths: ["src-tauri/crates/agent/src/shell_security.rs"], + }, { id: "rust-agent-integration-public-module-leak", classification: "dead-candidate", @@ -773,7 +924,8 @@ const rustTextSurfaceMonitors = [ { id: "rust-agent-subagent-direct-module-usage", classification: "dead-candidate", - description: "应用层重新 direct 依赖 crate::agent::subagent_scheduler 模块路径", + description: + "应用层重新 direct 依赖 crate::agent::subagent_scheduler 模块路径", patterns: ["crate::agent::subagent_scheduler::"], includePathPrefixes: ["src-tauri/src"], allowedPaths: [], @@ -781,7 +933,8 @@ const rustTextSurfaceMonitors = [ { id: "rust-aster-runtime-snapshot-helper-leak", classification: "dead-candidate", - description: "已零引用的 Lime 业务层 direct Aster runtime snapshot helper 回流", + description: + "已零引用的 Lime 业务层 direct Aster runtime snapshot helper 回流", patterns: ["load_session_runtime_snapshot("], includePathPrefixes: ["src-tauri/src", "src-tauri/crates"], allowedPaths: [], @@ -789,7 +942,8 @@ const rustTextSurfaceMonitors = [ { id: "rust-aster-runtime-store-leak", classification: "dead-candidate", - description: "已零引用的 Lime 业务层 direct Aster shared runtime store 回流", + description: + "已零引用的 Lime 业务层 direct Aster shared runtime store 回流", patterns: [ "shared_thread_runtime_store(", "initialize_shared_thread_runtime_store(", @@ -801,33 +955,43 @@ const rustTextSurfaceMonitors = [ { id: "rust-aster-runtime-store-public-require-api-leak", classification: "dead-candidate", - description: "Aster runtime support 重新对 crate 外暴露 require_aster_thread_runtime_store", + description: + "Aster runtime support 重新对 crate 外暴露 require_aster_thread_runtime_store", patterns: ["pub fn require_aster_thread_runtime_store("], - includePathPrefixes: ["src-tauri/crates/agent/src/aster_runtime_support.rs"], + includePathPrefixes: [ + "src-tauri/crates/agent/src/aster_runtime_support.rs", + ], allowedPaths: [], }, { id: "rust-aster-runtime-init-return-store-api-leak", classification: "dead-candidate", - description: "Aster runtime 启动初始化 API 重新向 crate 外返回 runtime store", + description: + "Aster runtime 启动初始化 API 重新向 crate 外返回 runtime store", patterns: [ "pub fn initialize_aster_thread_runtime_store() -> Result, String>", ], - includePathPrefixes: ["src-tauri/crates/agent/src/aster_runtime_support.rs"], + includePathPrefixes: [ + "src-tauri/crates/agent/src/aster_runtime_support.rs", + ], allowedPaths: [], }, { id: "rust-aster-runtime-public-legacy-init-helper-leak", classification: "dead-candidate", - description: "Aster runtime support 重新对 crate 外暴露旧 initialize_aster_thread_runtime_store helper", + description: + "Aster runtime support 重新对 crate 外暴露旧 initialize_aster_thread_runtime_store helper", patterns: ["pub fn initialize_aster_thread_runtime_store("], - includePathPrefixes: ["src-tauri/crates/agent/src/aster_runtime_support.rs"], + includePathPrefixes: [ + "src-tauri/crates/agent/src/aster_runtime_support.rs", + ], allowedPaths: [], }, { id: "rust-aster-runtime-snapshot-root-export-leak", classification: "dead-candidate", - description: "lime-agent crate 根重新暴露 load_aster_runtime_snapshot helper", + description: + "lime-agent crate 根重新暴露 load_aster_runtime_snapshot helper", patterns: ["load_aster_runtime_snapshot"], includePathPrefixes: ["src-tauri/crates/agent/src/lib.rs"], allowedPaths: [], @@ -835,16 +999,20 @@ const rustTextSurfaceMonitors = [ { id: "rust-aster-runtime-queue-service-leak", classification: "dead-candidate", - description: "已零引用的 Lime 业务层 direct Aster shared runtime queue service 回流", + description: + "已零引用的 Lime 业务层 direct Aster shared runtime queue service 回流", patterns: [], - regexPatterns: [String.raw`(? - isStatusClassificationDrift( - getTextStatus(result), - result.classification, - ), + isStatusClassificationDrift(getTextStatus(result), result.classification), ) .map( (result) => @@ -1710,10 +1877,7 @@ const classificationDriftCandidates = [ ), ...rustTextResults .filter((result) => - isStatusClassificationDrift( - getTextStatus(result), - result.classification, - ), + isStatusClassificationDrift(getTextStatus(result), result.classification), ) .map( (result) => diff --git a/scripts/workspace-ready-smoke.mjs b/scripts/workspace-ready-smoke.mjs new file mode 100644 index 000000000..bee704ce3 --- /dev/null +++ b/scripts/workspace-ready-smoke.mjs @@ -0,0 +1,340 @@ +#!/usr/bin/env node + +import path from "node:path"; +import process from "node:process"; + +const DEFAULTS = { + healthUrl: "http://127.0.0.1:3030/health", + invokeUrl: "http://127.0.0.1:3030/invoke", + timeoutMs: 60_000, + intervalMs: 1_000, + sampleProjectName: "Lime Smoke Workspace", +}; + +function printHelp() { + console.log(` +Lime Workspace Ready Smoke + +用途: + 验证 DevBridge 已就绪,并检查默认 workspace 可获取、可修复、可按路径回查。 + +用法: + node scripts/workspace-ready-smoke.mjs [选项] + +选项: + --health-url 健康检查地址,默认 http://127.0.0.1:3030/health + --invoke-url DevBridge invoke 地址,默认 http://127.0.0.1:3030/invoke + --timeout-ms 等待健康检查超时,默认 60000 + --interval-ms 健康检查轮询间隔,默认 1000 + --sample-project-name 用于校验目录解析的示例项目名 + -h, --help 显示帮助 + +示例: + npm run smoke:workspace-ready + npm run smoke:workspace-ready -- --timeout-ms 120000 +`); +} + +function parseArgs(argv) { + const options = { ...DEFAULTS }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--health-url" && argv[index + 1]) { + options.healthUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--invoke-url" && argv[index + 1]) { + options.invokeUrl = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--timeout-ms" && argv[index + 1]) { + options.timeoutMs = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--interval-ms" && argv[index + 1]) { + options.intervalMs = Number(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--sample-project-name" && argv[index + 1]) { + options.sampleProjectName = String(argv[index + 1]).trim(); + index += 1; + continue; + } + if (arg === "--help" || arg === "-h") { + printHelp(); + process.exit(0); + } + } + + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 1000) { + throw new Error("--timeout-ms 必须是 >= 1000 的数字"); + } + if (!Number.isFinite(options.intervalMs) || options.intervalMs < 100) { + throw new Error("--interval-ms 必须是 >= 100 的数字"); + } + if (!options.sampleProjectName) { + throw new Error("--sample-project-name 不能为空"); + } + + return options; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function normalizePath(value) { + return String(value || "") + .trim() + .replace(/\\/g, "/") + .replace(/\/+$/, ""); +} + +function pickStringField(target, ...keys) { + if (!target || typeof target !== "object") { + return ""; + } + + for (const key of keys) { + const value = target[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + + return ""; +} + +async function checkHealth(url) { + const response = await fetch(url, { method: "GET" }); + const text = await response.text(); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return text ? JSON.parse(text) : null; +} + +async function waitForHealth(options) { + const startedAt = Date.now(); + let lastError = null; + + while (Date.now() - startedAt < options.timeoutMs) { + try { + const payload = await checkHealth(options.healthUrl); + console.log( + `[smoke:workspace-ready] DevBridge 已就绪 (${Date.now() - startedAt}ms)${ + payload?.status ? ` status=${payload.status}` : "" + }`, + ); + return payload; + } catch (error) { + lastError = error; + await sleep(options.intervalMs); + } + } + + const detail = + lastError instanceof Error + ? lastError.message + : String(lastError || "unknown error"); + throw new Error( + `[smoke:workspace-ready] DevBridge 未就绪,请先启动 npm run tauri:dev:headless。最后错误: ${detail}`, + ); +} + +async function invoke(invokeUrl, cmd, args) { + const response = await fetch(invokeUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ cmd, args }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const payload = await response.json(); + if (payload?.error) { + throw new Error(String(payload.error)); + } + + return payload?.result; +} + +function assertWorkspaceShape(project, label) { + assert(project && typeof project === "object", `${label} 返回为空`); + assert( + typeof project.id === "string" && project.id.trim(), + `${label} 缺少 id`, + ); + assert( + pickStringField(project, "rootPath", "root_path"), + `${label} 缺少 rootPath`, + ); +} + +async function main() { + if (typeof fetch !== "function") { + throw new Error("当前 Node 运行时不支持 fetch,请使用 Node 18+"); + } + + const options = parseArgs(process.argv.slice(2)); + await waitForHealth(options); + + const projectsRoot = await invoke( + options.invokeUrl, + "workspace_get_projects_root", + ); + assert( + typeof projectsRoot === "string" && projectsRoot.trim(), + "workspace_get_projects_root 返回为空", + ); + + const resolvedSamplePath = await invoke( + options.invokeUrl, + "workspace_resolve_project_path", + { + name: options.sampleProjectName, + }, + ); + assert( + typeof resolvedSamplePath === "string" && resolvedSamplePath.trim(), + "workspace_resolve_project_path 返回为空", + ); + + const normalizedProjectsRoot = normalizePath(path.resolve(projectsRoot)); + const normalizedResolvedSamplePath = normalizePath( + path.resolve(resolvedSamplePath), + ); + assert( + normalizedResolvedSamplePath.startsWith(`${normalizedProjectsRoot}/`) || + normalizedResolvedSamplePath === normalizedProjectsRoot, + `解析后的项目目录未落在 workspace 根目录下: ${resolvedSamplePath}`, + ); + + const defaultProject = await invoke( + options.invokeUrl, + "get_or_create_default_project", + ); + assertWorkspaceShape(defaultProject, "get_or_create_default_project"); + + const defaultProjectDetail = await invoke( + options.invokeUrl, + "workspace_get", + { + id: defaultProject.id, + }, + ); + assertWorkspaceShape(defaultProjectDetail, "workspace_get"); + + const defaultProjectFromDefault = await invoke( + options.invokeUrl, + "workspace_get_default", + ); + assertWorkspaceShape(defaultProjectFromDefault, "workspace_get_default"); + assert( + defaultProjectFromDefault.id === defaultProject.id, + "workspace_get_default 与 get_or_create_default_project 返回的默认 workspace 不一致", + ); + + const ensureDefault = await invoke( + options.invokeUrl, + "workspace_ensure_default_ready", + ); + assert( + ensureDefault && typeof ensureDefault === "object", + "默认 workspace 健康检查返回为空", + ); + assert( + pickStringField(ensureDefault, "workspaceId", "workspace_id") === + defaultProject.id, + "workspace_ensure_default_ready 返回的 workspace_id 不匹配", + ); + assert( + pickStringField(ensureDefault, "rootPath", "root_path"), + "workspace_ensure_default_ready 缺少 rootPath", + ); + + const ensureExplicit = await invoke( + options.invokeUrl, + "workspace_ensure_ready", + { + id: defaultProject.id, + }, + ); + assert( + ensureExplicit && typeof ensureExplicit === "object", + "workspace_ensure_ready 返回为空", + ); + assert( + pickStringField(ensureExplicit, "workspaceId", "workspace_id") === + defaultProject.id, + "workspace_ensure_ready 返回的 workspace_id 不匹配", + ); + + const ensuredRootPath = pickStringField( + ensureExplicit, + "rootPath", + "root_path", + ); + assert(ensuredRootPath, "workspace_ensure_ready 缺少 rootPath"); + + const workspaceByPath = await invoke( + options.invokeUrl, + "workspace_get_by_path", + { + rootPath: ensuredRootPath, + }, + ); + assertWorkspaceShape(workspaceByPath, "workspace_get_by_path"); + assert( + workspaceByPath.id === defaultProject.id, + "workspace_get_by_path 未返回默认 workspace", + ); + + const workspaces = await invoke(options.invokeUrl, "workspace_list"); + assert(Array.isArray(workspaces), "workspace_list 返回非数组"); + assert( + workspaces.some((item) => item?.id === defaultProject.id), + "workspace_list 中未找到默认 workspace", + ); + + console.log("\n[smoke:workspace-ready] 通过"); + console.log( + JSON.stringify( + { + projectsRoot, + sampleProjectPath: resolvedSamplePath, + defaultWorkspaceId: defaultProject.id, + defaultWorkspaceRoot: ensuredRootPath, + workspaceCount: workspaces.length, + repaired: Boolean(ensureExplicit.repaired), + relocated: Boolean(ensureExplicit.relocated), + }, + null, + 2, + ), + ); +} + +main().catch((error) => { + console.error( + error instanceof Error ? error.message : String(error || "unknown error"), + ); + process.exit(1); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 670910617..6b4cb7c3d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -369,7 +369,7 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster-core" -version = "0.19.0" +version = "0.20.0" dependencies = [ "ahash", "anyhow", @@ -461,7 +461,7 @@ dependencies = [ [[package]] name = "aster-models" -version = "0.19.0" +version = "0.20.0" dependencies = [ "serde", "serde_json", @@ -2399,7 +2399,7 @@ dependencies = [ "dtoa-short", "itoa", "matches", - "phf 0.8.0", + "phf 0.10.1", "proc-macro2", "quote", "smallvec", @@ -2415,7 +2415,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.8.0", + "phf 0.11.3", "smallvec", ] @@ -4336,7 +4336,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.57.0", ] [[package]] @@ -5062,7 +5062,7 @@ dependencies = [ [[package]] name = "lime" -version = "0.91.0" +version = "0.92.0" dependencies = [ "anyhow", "arboard", @@ -5165,7 +5165,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "0.91.0" +version = "0.92.0" dependencies = [ "aster-core", "async-trait", @@ -5192,7 +5192,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "0.91.0" +version = "0.92.0" dependencies = [ "chrono", "futures", @@ -5209,7 +5209,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "0.91.0" +version = "0.92.0" dependencies = [ "async-trait", "lime-core", @@ -5225,7 +5225,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "0.91.0" +version = "0.92.0" dependencies = [ "aster-models", "async-trait", @@ -5265,7 +5265,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "0.91.0" +version = "0.92.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5300,7 +5300,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "0.91.0" +version = "0.92.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5321,7 +5321,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "0.91.0" +version = "0.92.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5341,7 +5341,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "0.91.0" +version = "0.92.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5373,7 +5373,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "0.91.0" +version = "0.92.0" dependencies = [ "async-trait", "lime-core", @@ -5392,7 +5392,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "0.91.0" +version = "0.92.0" dependencies = [ "anyhow", "async-stream", @@ -5446,7 +5446,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "0.91.0" +version = "0.92.0" dependencies = [ "aster-core", "async-stream", @@ -5491,7 +5491,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "0.91.0" +version = "0.92.0" dependencies = [ "axum 0.7.9", "futures", @@ -5506,7 +5506,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "0.91.0" +version = "0.92.0" dependencies = [ "anyhow", "aster-core", @@ -5548,7 +5548,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "0.91.0" +version = "0.92.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5566,7 +5566,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "0.91.0" +version = "0.92.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5593,7 +5593,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "0.91.0" +version = "0.92.0" dependencies = [ "axum 0.7.9", "chrono", @@ -6266,7 +6266,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 2.0.2", "proc-macro2", "quote", "syn 2.0.117", @@ -6993,9 +6993,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" dependencies = [ - "phf_macros 0.8.0", "phf_shared 0.8.0", - "proc-macro-hack", ] [[package]] @@ -7004,7 +7002,9 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" dependencies = [ + "phf_macros 0.10.0", "phf_shared 0.10.0", + "proc-macro-hack", ] [[package]] @@ -7108,12 +7108,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", + "phf_generator 0.10.0", + "phf_shared 0.10.0", "proc-macro-hack", "proc-macro2", "quote", @@ -7525,7 +7525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -8992,7 +8992,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 4.0.0", + "dirs 6.0.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 44a8b6844..0c467fb64 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.91.0" +version = "0.92.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -127,8 +127,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.19.0" } -aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.19.0" } +aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.20.0" } +aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.20.0" } # MCP (Model Context Protocol) rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] } @@ -191,7 +191,7 @@ version = "2.4" [package] name = "lime" -version = "0.91.0" +version = "0.92.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/agent_tools/mod.rs b/src-tauri/crates/agent/src/agent_tools/mod.rs new file mode 100644 index 000000000..0ff8cbbeb --- /dev/null +++ b/src-tauri/crates/agent/src/agent_tools/mod.rs @@ -0,0 +1,8 @@ +#[path = "../../../../src/agent_tools/catalog.rs"] +pub mod catalog; + +#[path = "../../../../src/agent_tools/execution.rs"] +pub mod execution; + +#[path = "../../../../src/agent_tools/inventory.rs"] +pub mod inventory; diff --git a/src-tauri/crates/agent/src/aster_runtime_support.rs b/src-tauri/crates/agent/src/aster_runtime_support.rs index 291150cf2..87f0e15df 100644 --- a/src-tauri/crates/agent/src/aster_runtime_support.rs +++ b/src-tauri/crates/agent/src/aster_runtime_support.rs @@ -150,7 +150,7 @@ pub(crate) async fn prepare_aster_runtime_queue_resumption() -> Result Result { let store = require_aster_runtime_store()?; diff --git a/src-tauri/crates/agent/src/aster_state.rs b/src-tauri/crates/agent/src/aster_state.rs index 22a6e216a..3164710e7 100644 --- a/src-tauri/crates/agent/src/aster_state.rs +++ b/src-tauri/crates/agent/src/aster_state.rs @@ -23,7 +23,6 @@ //! 参考文档:`docs/prd/chat-architecture-redesign.md` use aster::agents::Agent; -use aster::model::ModelConfig; #[cfg(test)] use aster::skills::{global_registry, load_skills_from_directory, SkillSource}; use aster::tools::{create_shared_history, EditTool, WriteTool}; @@ -84,6 +83,8 @@ impl QueuedTurnTask { pub struct ProviderConfig { /// Provider 名称 (openai, anthropic, google, ollama 等) pub provider_name: String, + /// Provider 选择器(优先保留前端 provider_id / pool provider_type) + pub provider_selector: Option, /// 模型名称 pub model_name: String, /// API Key (可选,某些 provider 从环境变量读取) @@ -92,6 +93,8 @@ pub struct ProviderConfig { pub base_url: Option, /// 凭证 UUID(来自凭证池,用于记录使用和健康状态) pub credential_uuid: Option, + /// 是否强制 OpenAI provider 使用 Responses API + pub force_responses_api: bool, } /// Aster Agent 全局状态 @@ -234,17 +237,19 @@ impl AsterAgentState { // 确保 Agent 已初始化(使用带数据库的版本) self.init_agent_with_db(db).await?; - // 设置环境变量(Aster 的 provider 从环境变量读取配置) - self.set_provider_env_vars(&config); - - // 创建 ModelConfig - let model_config = ModelConfig::new(&config.model_name) - .map_err(|e| format!("创建 ModelConfig 失败: {e}"))?; - - // 创建 Provider - let provider = aster::providers::create(&config.provider_name, model_config) - .await - .map_err(|e| format!("创建 Provider 失败: {e}"))?; + let provider = create_aster_provider(&AsterProviderConfig { + provider_name: config.provider_name.clone(), + model_name: config.model_name.clone(), + api_key: config.api_key.clone(), + base_url: config.base_url.clone(), + credential_uuid: config + .credential_uuid + .clone() + .unwrap_or_else(|| format!("manual:{session_id}")), + force_responses_api: config.force_responses_api, + }) + .await + .map_err(|e| format!("创建 Provider 失败: {e}"))?; // 更新 Agent 的 Provider let agent_guard = self.agent.read().await; @@ -315,10 +320,12 @@ impl AsterAgentState { // 保存当前配置 let config = ProviderConfig { provider_name: aster_config.provider_name.clone(), + provider_selector: Some(provider_type.trim().to_string()), model_name: aster_config.model_name.clone(), api_key: aster_config.api_key.clone(), base_url: aster_config.base_url.clone(), credential_uuid: Some(aster_config.credential_uuid.clone()), + force_responses_api: aster_config.force_responses_api, }; let mut config_guard = self.current_provider_config.write().await; *config_guard = Some(config); @@ -371,57 +378,6 @@ impl AsterAgentState { } } - /// 设置 Provider 相关的环境变量 - fn set_provider_env_vars(&self, config: &ProviderConfig) { - tracing::info!( - "[AsterAgent] set_provider_env_vars: provider_name={}, model_name={}, has_api_key={}, base_url={:?}", - config.provider_name, - config.model_name, - config.api_key.is_some(), - config.base_url - ); - - // 根据 provider 类型设置对应的环境变量 - let env_key = match config.provider_name.as_str() { - "openai" => "OPENAI_API_KEY", - "anthropic" => "ANTHROPIC_API_KEY", - "google" => "GOOGLE_API_KEY", - "deepseek" | "custom_deepseek" => "OPENAI_API_KEY", // DeepSeek 使用 OpenAI 兼容 API - "groq" => "OPENAI_API_KEY", // Groq 使用 OpenAI 兼容 API - "mistral" => "OPENAI_API_KEY", // Mistral 使用 OpenAI 兼容 API - "openrouter" => "OPENROUTER_API_KEY", - "ollama" => return, // Ollama 不需要 API Key - _ => { - tracing::warn!( - "[AsterAgent] 未知的 provider_name: {}, 使用通用 OpenAI 格式", - config.provider_name - ); - // 通用 OpenAI 兼容格式 - if let Some(api_key) = &config.api_key { - std::env::set_var("OPENAI_API_KEY", api_key); - } - if let Some(base_url) = &config.base_url { - std::env::set_var("OPENAI_BASE_URL", base_url); - } - return; - } - }; - - tracing::info!("[AsterAgent] 设置环境变量: {}=***", env_key); - - if let Some(api_key) = &config.api_key { - std::env::set_var(env_key, api_key); - } - - if let Some(base_url) = &config.base_url { - let base_url_key = format!( - "{}_BASE_URL", - config.provider_name.to_uppercase().replace("_", "") - ); - std::env::set_var(base_url_key, base_url); - } - } - /// 获取当前 Provider 配置 pub async fn get_provider_config(&self) -> Option { self.current_provider_config.read().await.clone() @@ -638,17 +594,6 @@ mod tests { assert!(!state.cancel_session(session_id).await); } - #[test] - fn test_session_turn_queue_manager_execution_gate() { - let gate = SessionTurnExecutionGate::default(); - - assert!(gate.try_start("session-queue")); - assert!(gate.is_active("session-queue")); - assert!(!gate.try_start("session-queue")); - assert!(gate.finish("session-queue")); - assert!(!gate.is_active("session-queue")); - } - #[test] fn test_session_turn_queue_manager_snapshot() { let task = QueuedTurnTask { diff --git a/src-tauri/crates/agent/src/aster_state_support.rs b/src-tauri/crates/agent/src/aster_state_support.rs index 4d42928e5..49ac5bc09 100644 --- a/src-tauri/crates/agent/src/aster_state_support.rs +++ b/src-tauri/crates/agent/src/aster_state_support.rs @@ -182,4 +182,15 @@ Lime 是一个 AI 代理服务应用,帮助用户: - 简洁专业,直接给出解决方案 - 友好但不啰嗦,像经验丰富的技术伙伴 - 遇到问题时,先分析原因再提供方案 + +## Team 协作原则 + +- 只有在任务存在多个相互独立的子问题、并行评审/验证、或用户明确要求多代理时,才进入 team 模式 +- 简单问题不要创建子代理;先判断当前阻塞步骤是否真的适合委派 +- 先区分关键路径与 sidecar 任务:如果下一步立即依赖结果,优先主线程自己做;只有不会阻塞下一步的独立子任务才适合并发委派 +- 多个子代理并发时,必须明确分工,避免让不同子代理修改同一片文件或重复劳动 +- 子代理默认不应继续创建新的子代理,避免团队深度失控 +- 优先复用已有子代理上下文,通过 send_input 继续推进强相关任务,而不是反复创建新子代理 +- 只有当主线程确实被结果阻塞时,才调用 wait_agent;可以一次等待多个 id,且不要反复机械等待 +- 旧的 SubAgentTask 仅视为兼容入口,不应作为新的 team runtime 主路径 "#; diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index 7d1bc3a4e..615a8517f 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -1741,7 +1741,7 @@ mod tests { let extracted = extract_tool_result_data(&payload); assert_eq!(extracted.diagnostics.output_chars, 5); assert_eq!(extracted.diagnostics.image_count, 0); - assert_eq!(extracted.diagnostics.text_truncated, false); + assert!(!extracted.diagnostics.text_truncated); assert!(extracted.diagnostics.raw_json_bytes.is_some()); } diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index 998da2564..4ea4cd06c 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -10,6 +10,7 @@ #![allow(clippy::derivable_impls)] #![allow(clippy::borrowed_box)] +pub mod agent_tools; pub mod ask_bridge; pub mod aster_runtime_support; pub mod aster_state; @@ -25,11 +26,11 @@ pub mod queued_turn; pub mod request_tool_policy; pub mod runtime_queue; mod session_store; -pub mod shell_security; pub mod skill_execution; +pub mod subagent_control; +pub mod subagent_profiles; pub mod subagent_scheduler; pub mod tool_io_offload; -pub mod tool_permissions; pub mod tools; mod write_artifact_events; @@ -52,6 +53,7 @@ pub use event_converter::{ convert_agent_event, convert_item_runtime, convert_to_tauri_message, convert_turn_runtime, TauriAgentEvent, TauriArtifactSnapshot, TauriRuntimeStatus, }; +pub use lime_mcp as mcp; pub use lsp_bridge::create_lsp_callback; pub use prompt::SystemPromptBuilder; pub use queued_turn::QueuedTurnSnapshot; @@ -63,25 +65,40 @@ pub use request_tool_policy::{ WebSearchExecutionTracker, REQUEST_TOOL_POLICY_MARKER, }; pub use runtime_queue::{ - clear_runtime_queue, list_runtime_queue_snapshots, remove_runtime_queued_turn, - resume_persisted_runtime_queues_on_startup, resume_runtime_queue_if_needed, - submit_runtime_turn, RuntimeQueueEventEmitter, RuntimeQueueExecutor, + clear_runtime_queue, list_runtime_queue_snapshots, promote_runtime_queued_turn, + remove_runtime_queued_turn, resume_persisted_runtime_queues_on_startup, + resume_runtime_queue_if_needed, submit_runtime_turn, RuntimeQueueEventEmitter, + RuntimeQueueExecutor, }; pub use session_store::{ create_session_sync, delete_session, get_persisted_session_metadata_sync, get_runtime_session_detail, get_session_sync, list_sessions_sync, list_title_preview_messages_sync, rename_session_sync, update_session_execution_strategy_sync, - update_session_working_dir_sync, PersistedSessionMetadata, SessionDetail, SessionInfo, - SessionTitlePreviewMessage, SessionTodoItem, + update_session_working_dir_sync, ChildSubagentRuntimeStatus, ChildSubagentSession, + PersistedSessionMetadata, SessionDetail, SessionInfo, SessionTitlePreviewMessage, + SessionTodoItem, SubagentParentContext, }; -pub use shell_security::ShellSecurityChecker; pub use skill_execution::{ execute_skill_prompt, execute_skill_workflow, SkillEventEmitter, SkillExecutionError, SkillExecutionResult, SkillWorkflowExecution, StepResult, }; +pub use subagent_control::{ + collect_subagent_cascade_session_ids, derive_subagent_runtime_status_kind, + list_subagent_cascade_session_ids, load_subagent_runtime_status, read_subagent_control_state, + write_subagent_control_state, SubagentControlState, SubagentRuntimeStatus, + SubagentRuntimeStatusInput, SubagentRuntimeStatusKind, +}; +pub use subagent_profiles::{ + build_subagent_customization_prompt, builtin_profile_descriptor_by_id, + builtin_profile_name_by_id, builtin_skill_descriptor_by_id, + builtin_team_preset_descriptor_by_id, builtin_team_preset_label_by_id, + summarize_builtin_profile, summarize_builtin_skill, summarize_builtin_team_preset, + BuiltinProfileDescriptor, BuiltinSkillDescriptor, BuiltinTeamPresetDescriptor, + SubagentCustomizationState, SubagentProfileSummary, SubagentSkillPromptBlock, + SubagentSkillSummary, TeamPresetSummary, +}; pub use subagent_scheduler::{ LimeScheduler, LimeSubAgentExecutor, SchedulerEventEmitter, SubAgentProgressEvent, SubAgentRole, }; -pub use tool_permissions::{DynamicPermissionCheck, PermissionBehavior}; pub use tools::{BrowserAction, BrowserTool, BrowserToolError, BrowserToolResult}; pub use write_artifact_events::WriteArtifactEventEmitter; diff --git a/src-tauri/crates/agent/src/prompt/templates.rs b/src-tauri/crates/agent/src/prompt/templates.rs index 6e5b90727..2811a98b0 100644 --- a/src-tauri/crates/agent/src/prompt/templates.rs +++ b/src-tauri/crates/agent/src/prompt/templates.rs @@ -41,7 +41,8 @@ pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略 - **EnterPlanMode** / **ExitPlanMode**: 显式进入或结束规划阶段 ### 委派工具 -- **SubAgentTask**: 将独立子问题委派给隔离上下文的子代理执行 +- **spawn_agent / send_input / wait_agent / resume_agent / close_agent**: 当前 team runtime 主路径 +- **SubAgentTask**: 兼容入口,仅用于历史 prompt/schema 仍输出旧格式时兜底 ### 人在环工具 - **ask**: 向用户请求确认或补充信息 @@ -52,7 +53,7 @@ pub const TOOL_GUIDELINES: &str = r#"# 工具使用策略 2. **并行调用**:如果多个工具调用之间没有依赖关系,应该并行调用 3. **先读后改**:修改文件前必须先读取文件内容 4. **最小权限**:只执行必要的操作,避免不必要的文件修改 -5. **独立子问题再委派**:只有当任务需要隔离上下文、并行探索或分离执行时,才使用 SubAgentTask"#; +5. **独立子问题再委派**:只有当任务需要隔离上下文、并行探索或分离执行时,才使用 team runtime 工具;优先 `spawn_agent`,不要默认走 `SubAgentTask`"#; /// 代码编写指南 pub const CODING_GUIDELINES: &str = r#"# 代码编写指南 @@ -61,7 +62,7 @@ pub const CODING_GUIDELINES: &str = r#"# 代码编写指南 1. **先理解再修改**:在修改代码之前,先阅读相关文件理解现有模式和架构 2. **使用 TodoWrite 规划**:对于复杂任务,先用 TodoWrite 工具规划步骤 -3. **需要隔离上下文时委派**:对于可以独立完成的研究、规划或执行子问题,使用 SubAgentTask +3. **需要隔离上下文时委派**:对于可以独立完成的研究、规划或执行子问题,使用 `spawn_agent` 创建真实子代理;对强依赖既有上下文的延续任务,优先 `send_input` 4. **安全第一**:避免引入安全漏洞(命令注入、XSS、SQL 注入等) 5. **避免过度工程**:只做必要的修改,保持解决方案简单 @@ -96,7 +97,7 @@ pub const TASK_MANAGEMENT: &str = r#"# 任务管理 不要批量完成多个任务后再标记,应该完成一个标记一个。 -如果某个子问题可以独立分析、规划或执行,并且不需要持续共享主对话上下文,可以使用 SubAgentTask 委派出去。"#; +如果某个子问题可以独立分析、规划或执行,并且不需要持续共享主对话上下文,可以使用 `spawn_agent` 委派出去;`SubAgentTask` 只保留给兼容旧 schema 的场景。"#; /// Git 操作指南 pub const GIT_GUIDELINES: &str = r#"# Git 操作 diff --git a/src-tauri/crates/agent/src/runtime_queue.rs b/src-tauri/crates/agent/src/runtime_queue.rs index 36dea5bd6..f2b3cff64 100644 --- a/src-tauri/crates/agent/src/runtime_queue.rs +++ b/src-tauri/crates/agent/src/runtime_queue.rs @@ -1,5 +1,5 @@ use crate::aster_runtime_support::{ - clear_aster_runtime_queued_turns, list_aster_runtime_queued_turns, + clear_aster_runtime_queued_turns, enqueue_aster_runtime_turn, list_aster_runtime_queued_turns, prepare_aster_runtime_queue_resumption, queued_turn_event_name_from_runtime, queued_turn_runtime_from_task, queued_turn_snapshot_from_runtime, remove_aster_runtime_queued_turn, @@ -242,6 +242,56 @@ pub async fn remove_runtime_queued_turn( Ok(true) } +pub async fn promote_runtime_queued_turn( + session_id: &str, + queued_turn_id: &str, +) -> Result { + let queued_turns = list_aster_runtime_queued_turns(session_id).await?; + if queued_turns.is_empty() { + return Ok(false); + } + + if queued_turns + .first() + .map(|queued_turn| queued_turn.queued_turn_id == queued_turn_id) + .unwrap_or(false) + { + return Ok(true); + } + + let Some(target_index) = queued_turns + .iter() + .position(|queued_turn| queued_turn.queued_turn_id == queued_turn_id) + else { + return Ok(false); + }; + + let mut reordered_turns = Vec::with_capacity(queued_turns.len()); + reordered_turns.push(queued_turns[target_index].clone()); + reordered_turns.extend( + queued_turns + .iter() + .enumerate() + .filter(|(index, _)| *index != target_index) + .map(|(_, queued_turn)| queued_turn.clone()), + ); + + let original_turns = queued_turns; + clear_aster_runtime_queued_turns(session_id).await?; + + for queued_turn in &reordered_turns { + if let Err(error) = enqueue_aster_runtime_turn(queued_turn.clone()).await { + clear_aster_runtime_queued_turns(session_id).await?; + for original_turn in original_turns { + enqueue_aster_runtime_turn(original_turn).await?; + } + return Err(error); + } + } + + Ok(true) +} + pub async fn resume_persisted_runtime_queues_on_startup( context: C, executor: RuntimeQueueExecutor, diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index 64e38c445..114012cf7 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -4,7 +4,10 @@ //! 数据事实源收敛到 lime_core::database::agent_session_repository + Lime 数据库。 use aster::session::extension_data::{resolve_todo_list_state, TodoListItem, TodoListItemStatus}; -use aster::session::SessionRuntimeSnapshot; +use aster::session::{ + list_subagent_child_sessions, resolve_subagent_session_metadata, Session as AsterSession, + SessionManager, SessionRuntimeSnapshot, +}; use chrono::Utc; use lime_core::agent::types::{AgentMessage, AgentSession, ContentPart, MessageContent}; use lime_core::database::agent_session_repository::{ @@ -18,12 +21,15 @@ use lime_core::database::DbConnection; use lime_core::workspace::WorkspaceManager; use lime_services::aster_session_store::LimeSessionStore; use std::collections::HashMap; +use std::path::Path; use uuid::Uuid; use crate::aster_runtime_support::load_aster_runtime_snapshot; use crate::event_converter::{ convert_item_runtime, convert_turn_runtime, TauriMessage, TauriMessageContent, }; +use crate::subagent_control::{load_subagent_runtime_status, SubagentRuntimeStatusKind}; +use crate::subagent_profiles::{SubagentCustomizationState, SubagentSkillSummary}; use crate::tool_io_offload::{ build_history_tool_io_eviction_plan_for_model, force_offload_plain_tool_output_for_history, force_offload_tool_arguments_for_history, maybe_offload_plain_tool_output, @@ -63,6 +69,105 @@ pub struct SessionDetail { pub items: Vec, #[serde(default)] pub todo_items: Vec, + #[serde(default)] + pub child_subagent_sessions: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub subagent_parent_context: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ChildSubagentSession { + pub id: String, + pub name: String, + pub created_at: i64, + pub updated_at: i64, + pub session_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub working_dir: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub task_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub role_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub origin_tool: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_from_turn_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub role_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub team_preset_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_contract: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skill_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skills: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latest_turn_status: Option, + #[serde(default, skip_serializing_if = "is_zero_usize")] + pub queued_turn_count: usize, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SubagentParentContext { + pub parent_session_id: String, + pub parent_session_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub role_hint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub task_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub origin_tool: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_from_turn_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub role_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub team_preset_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_contract: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skill_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skills: Vec, + #[serde(default)] + pub sibling_subagent_sessions: Vec, +} + +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ChildSubagentRuntimeStatus { + Idle, + Queued, + Running, + Completed, + Failed, + Aborted, + Closed, +} + +fn is_zero_usize(value: &usize) -> bool { + *value == 0 } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] @@ -172,6 +277,260 @@ fn load_session_todo_items_from_conn( .unwrap_or_default() } +fn resolve_workspace_id_by_working_dir( + db: &DbConnection, + working_dir: Option<&str>, +) -> Option { + let resolved_working_dir = working_dir?.trim(); + if resolved_working_dir.is_empty() { + return None; + } + + let manager = WorkspaceManager::new(db.clone()); + match manager.get_by_path(Path::new(resolved_working_dir)) { + Ok(workspace) => workspace.map(|entry| entry.id), + Err(error) => { + tracing::warn!( + "[SessionStore] 解析 child subagent workspace 失败,已降级忽略: working_dir={}, error={}", + resolved_working_dir, + error + ); + None + } + } +} + +fn resolve_subagent_model_name(session: &AsterSession) -> Option { + session + .model_config + .as_ref() + .map(|config| config.model_name.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| normalize_optional_text(session.provider_name.clone())) +} + +fn map_child_subagent_runtime_status( + status: SubagentRuntimeStatusKind, +) -> Option { + match status { + SubagentRuntimeStatusKind::Idle => Some(ChildSubagentRuntimeStatus::Idle), + SubagentRuntimeStatusKind::Queued => Some(ChildSubagentRuntimeStatus::Queued), + SubagentRuntimeStatusKind::Running => Some(ChildSubagentRuntimeStatus::Running), + SubagentRuntimeStatusKind::Completed => Some(ChildSubagentRuntimeStatus::Completed), + SubagentRuntimeStatusKind::Failed => Some(ChildSubagentRuntimeStatus::Failed), + SubagentRuntimeStatusKind::Aborted => Some(ChildSubagentRuntimeStatus::Aborted), + SubagentRuntimeStatusKind::Closed => Some(ChildSubagentRuntimeStatus::Closed), + SubagentRuntimeStatusKind::NotFound => None, + } +} + +#[cfg(test)] +fn resolve_child_subagent_runtime_status_from_snapshot( + snapshot: &SessionRuntimeSnapshot, +) -> ChildSubagentRuntimeStatus { + snapshot + .threads + .iter() + .flat_map(|thread| thread.turns.iter()) + .max_by(|left, right| { + left.updated_at + .cmp(&right.updated_at) + .then_with(|| left.created_at.cmp(&right.created_at)) + .then_with(|| left.id.cmp(&right.id)) + }) + .and_then(|turn| { + map_child_subagent_runtime_status(match turn.status { + aster::session::TurnStatus::Queued => SubagentRuntimeStatusKind::Queued, + aster::session::TurnStatus::Running => SubagentRuntimeStatusKind::Running, + aster::session::TurnStatus::Completed => SubagentRuntimeStatusKind::Completed, + aster::session::TurnStatus::Failed => SubagentRuntimeStatusKind::Failed, + aster::session::TurnStatus::Aborted => SubagentRuntimeStatusKind::Aborted, + }) + }) + .unwrap_or(ChildSubagentRuntimeStatus::Idle) +} + +fn build_child_subagent_session_summary( + db: Option<&DbConnection>, + session: AsterSession, +) -> Option { + let metadata = resolve_subagent_session_metadata(&session.extension_data)?; + let customization = SubagentCustomizationState::from_session(&session).unwrap_or_default(); + let working_dir = + normalize_optional_text(Some(session.working_dir.to_string_lossy().to_string())); + let workspace_id = + db.and_then(|conn| resolve_workspace_id_by_working_dir(conn, working_dir.as_deref())); + let model = resolve_subagent_model_name(&session); + let provider_name = normalize_optional_text(session.provider_name.clone()); + let name = normalize_optional_text(Some(session.name.clone())) + .unwrap_or_else(|| "子代理会话".to_string()); + + Some(ChildSubagentSession { + id: session.id, + name, + created_at: session.created_at.timestamp(), + updated_at: session.updated_at.timestamp(), + session_type: session.session_type.to_string(), + model, + provider_name, + working_dir, + workspace_id, + task_summary: normalize_optional_nonempty_body(metadata.task_summary), + role_hint: normalize_optional_text(metadata.role_hint), + origin_tool: normalize_optional_text(Some(metadata.origin_tool)), + created_from_turn_id: normalize_optional_text(metadata.created_from_turn_id), + profile_id: customization.profile_id, + profile_name: customization.profile_name, + role_key: customization.role_key, + team_preset_id: customization.team_preset_id, + theme: customization.theme, + output_contract: customization.output_contract, + skill_ids: customization.skill_ids, + skills: customization.skills, + runtime_status: None, + latest_turn_status: None, + queued_turn_count: 0, + }) +} + +fn apply_runtime_status_to_child_subagent_session( + summary: &mut ChildSubagentSession, + status: crate::subagent_control::SubagentRuntimeStatus, +) { + summary.runtime_status = map_child_subagent_runtime_status(status.kind); + summary.latest_turn_status = status + .latest_turn_status + .and_then(map_child_subagent_runtime_status); + summary.queued_turn_count = status.queued_turn_count; +} + +fn build_child_subagent_session_summaries( + db: Option<&DbConnection>, + sessions: Vec, +) -> Vec { + let mut summaries = sessions + .into_iter() + .filter_map(|session| build_child_subagent_session_summary(db, session)) + .collect::>(); + + summaries.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.id.cmp(&right.id)) + }); + summaries +} + +fn build_subagent_parent_context( + current_session_id: &str, + parent_session: Option<&AsterSession>, + metadata: aster::session::SubagentSessionMetadata, + customization: Option, + sibling_subagent_sessions: Vec, +) -> SubagentParentContext { + let parent_session_name = parent_session + .and_then(|session| normalize_optional_text(Some(session.name.clone()))) + .unwrap_or_else(|| "父会话".to_string()); + + let customization = customization.unwrap_or_default(); + SubagentParentContext { + parent_session_id: metadata.parent_session_id, + parent_session_name, + role_hint: normalize_optional_text(metadata.role_hint), + task_summary: normalize_optional_nonempty_body(metadata.task_summary), + origin_tool: normalize_optional_text(Some(metadata.origin_tool)), + created_from_turn_id: normalize_optional_text(metadata.created_from_turn_id), + profile_id: customization.profile_id, + profile_name: customization.profile_name, + role_key: customization.role_key, + team_preset_id: customization.team_preset_id, + theme: customization.theme, + output_contract: customization.output_contract, + skill_ids: customization.skill_ids, + skills: customization.skills, + sibling_subagent_sessions: sibling_subagent_sessions + .into_iter() + .filter(|session| session.id != current_session_id) + .collect(), + } +} + +async fn load_child_subagent_sessions( + db: &DbConnection, + session_id: &str, +) -> Result, String> { + let sessions = list_subagent_child_sessions(session_id) + .await + .map_err(|error| format!("读取 child subagent sessions 失败: {error}"))?; + let mut summaries = build_child_subagent_session_summaries(Some(db), sessions); + for summary in &mut summaries { + match load_subagent_runtime_status(&summary.id).await { + Ok(status) => apply_runtime_status_to_child_subagent_session(summary, status), + Err(error) => { + tracing::debug!( + "[SessionStore] child subagent runtime 状态不可用,按 idle 展示: session_id={}, error={}", + summary.id, + error + ); + } + } + } + Ok(summaries) +} + +async fn load_subagent_parent_context( + db: &DbConnection, + session_id: &str, +) -> Result, String> { + let current_session = SessionManager::get_session(session_id, false) + .await + .map_err(|error| format!("读取当前 subagent session 失败: {error}"))?; + let Some(metadata) = resolve_subagent_session_metadata(¤t_session.extension_data) else { + return Ok(None); + }; + + let parent_session = match SessionManager::get_session(&metadata.parent_session_id, false).await + { + Ok(session) => Some(session), + Err(error) => { + tracing::warn!( + "[SessionStore] 读取 parent session 失败,已降级为匿名父会话: session_id={}, parent_session_id={}, error={}", + session_id, + metadata.parent_session_id, + error + ); + None + } + }; + + let sibling_subagent_sessions = match load_child_subagent_sessions( + db, + &metadata.parent_session_id, + ) + .await + { + Ok(sessions) => sessions, + Err(error) => { + tracing::warn!( + "[SessionStore] 读取 sibling subagent sessions 失败,已降级为空列表: session_id={}, parent_session_id={}, error={}", + session_id, + metadata.parent_session_id, + error + ); + Vec::new() + } + }; + + Ok(Some(build_subagent_parent_context( + session_id, + parent_session.as_ref(), + metadata, + SubagentCustomizationState::from_session(¤t_session), + sibling_subagent_sessions, + ))) +} + fn sort_runtime_turns(turns: &mut [AgentThreadTurn]) { turns.sort_by(|left, right| { left.started_at @@ -453,6 +812,8 @@ pub fn get_session_sync(db: &DbConnection, session_id: &str) -> Result { + detail.child_subagent_sessions = child_subagent_sessions; + } + Err(error) => { + tracing::warn!( + "[SessionStore] 读取 child subagent sessions 失败: session_id={}, error={}", + session_id, + error + ); + } + } + + match load_subagent_parent_context(db, session_id).await { + Ok(subagent_parent_context) => { + detail.subagent_parent_context = subagent_parent_context; + } + Err(error) => { + tracing::warn!( + "[SessionStore] 读取 subagent parent context 失败: session_id={}, error={}", + session_id, + error + ); + } + } + Ok(detail) } @@ -699,6 +1086,11 @@ fn convert_agent_message( #[cfg(test)] mod tests { use super::*; + use aster::session::{ + SessionType as AsterSessionType, SubagentSessionMetadata, ThreadRuntime, + ThreadRuntimeSnapshot, TurnRuntime, TurnStatus, + }; + use chrono::{Duration, Utc}; use lime_core::agent::types::{FunctionCall, ImageUrl, ToolCall}; use lime_core::database::{schema, DbConnection}; use std::ffi::OsString; @@ -787,6 +1179,38 @@ mod tests { .expect("add message"); } + fn build_test_subagent_session( + session_id: &str, + name: &str, + parent_session_id: Option<&str>, + updated_at: chrono::DateTime, + task_summary: Option<&str>, + role_hint: Option<&str>, + created_from_turn_id: Option<&str>, + ) -> AsterSession { + let mut session = AsterSession { + id: session_id.to_string(), + name: name.to_string(), + session_type: AsterSessionType::SubAgent, + created_at: updated_at - Duration::minutes(1), + updated_at, + provider_name: Some("openai".to_string()), + working_dir: std::path::PathBuf::from("/tmp/workspace-child"), + ..AsterSession::default() + }; + + if let Some(parent_session_id) = parent_session_id { + session.extension_data = SubagentSessionMetadata::new(parent_session_id.to_string()) + .with_task_summary(task_summary.map(str::to_string)) + .with_role_hint(role_hint.map(str::to_string)) + .with_created_from_turn_id(created_from_turn_id.map(str::to_string)) + .into_updated_extension_data(&AsterSession::default()) + .expect("build child metadata"); + } + + session + } + #[test] fn parse_tool_call_arguments_should_parse_json_or_keep_raw() { let parsed = parse_tool_call_arguments(r#"{"path":"./a.txt"}"#); @@ -796,6 +1220,261 @@ mod tests { assert_eq!(fallback["raw"], serde_json::json!("not-json")); } + #[test] + fn build_child_subagent_session_summaries_should_filter_and_sort_by_updated_at_desc() { + let now = Utc::now(); + let summaries = build_child_subagent_session_summaries( + None, + vec![ + build_test_subagent_session( + "child-old", + "旧子代理", + Some("parent-1"), + now - Duration::minutes(5), + Some("先检查日志"), + Some("explorer"), + Some("turn-1"), + ), + build_test_subagent_session( + "ignored", + "忽略项", + None, + now - Duration::minutes(1), + None, + None, + None, + ), + build_test_subagent_session( + "child-new", + "新子代理", + Some("parent-1"), + now, + Some("补充真实 team runtime"), + Some("planner"), + Some("turn-2"), + ), + ], + ); + + assert_eq!(summaries.len(), 2); + assert_eq!(summaries[0].id, "child-new"); + assert_eq!(summaries[0].session_type, "sub_agent"); + assert_eq!( + summaries[0].task_summary.as_deref(), + Some("补充真实 team runtime") + ); + assert_eq!(summaries[0].role_hint.as_deref(), Some("planner")); + assert_eq!(summaries[0].created_from_turn_id.as_deref(), Some("turn-2")); + assert_eq!(summaries[1].id, "child-old"); + } + + #[test] + fn build_child_subagent_session_summary_should_merge_customization_state() { + let now = Utc::now(); + let mut session = build_test_subagent_session( + "child-customized", + "自定义子代理", + Some("parent-1"), + now, + Some("整理 customization"), + Some("Image #1"), + Some("turn-9"), + ); + session.extension_data = SubagentCustomizationState { + profile_id: Some("code-explorer".to_string()), + profile_name: Some("代码分析员".to_string()), + role_key: Some("explorer".to_string()), + team_preset_id: Some("code-triage-team".to_string()), + theme: Some("engineering".to_string()), + output_contract: Some("输出证据、影响面与建议。".to_string()), + system_overlay: None, + skill_ids: vec!["repo-exploration".to_string()], + skills: vec![SubagentSkillSummary { + id: "repo-exploration".to_string(), + name: "仓库探索".to_string(), + description: Some("优先读事实源".to_string()), + source: Some("builtin".to_string()), + directory: None, + }], + } + .into_updated_extension_data(&session) + .expect("merge customization"); + + let summary = build_child_subagent_session_summary(None, session) + .expect("child summary should exist"); + + assert_eq!(summary.profile_id.as_deref(), Some("code-explorer")); + assert_eq!(summary.profile_name.as_deref(), Some("代码分析员")); + assert_eq!(summary.role_key.as_deref(), Some("explorer")); + assert_eq!(summary.team_preset_id.as_deref(), Some("code-triage-team")); + assert_eq!(summary.theme.as_deref(), Some("engineering")); + assert_eq!( + summary.output_contract.as_deref(), + Some("输出证据、影响面与建议。") + ); + assert_eq!(summary.skill_ids, vec!["repo-exploration".to_string()]); + assert_eq!(summary.skills.len(), 1); + assert_eq!(summary.skills[0].name, "仓库探索"); + } + + #[test] + fn build_subagent_parent_context_should_keep_parent_name_and_filter_current_session() { + let now = Utc::now(); + let metadata = SubagentSessionMetadata::new("parent-1".to_string()) + .with_task_summary(Some("处理父线程拆分出来的图片任务".to_string())) + .with_role_hint(Some("Image #1".to_string())) + .with_created_from_turn_id(Some("turn-2".to_string())); + let parent_session = AsterSession { + id: "parent-1".to_string(), + name: "主线程会话".to_string(), + session_type: AsterSessionType::User, + ..AsterSession::default() + }; + let sibling_subagent_sessions = build_child_subagent_session_summaries( + None, + vec![ + build_test_subagent_session( + "child-current", + "Image #1", + Some("parent-1"), + now - Duration::seconds(10), + Some("当前子代理"), + Some("Image #1"), + Some("turn-2"), + ), + build_test_subagent_session( + "child-sibling", + "Image #2", + Some("parent-1"), + now, + Some("兄弟子代理"), + Some("Image #2"), + Some("turn-2"), + ), + ], + ); + + let context = build_subagent_parent_context( + "child-current", + Some(&parent_session), + metadata, + None, + sibling_subagent_sessions, + ); + + assert_eq!(context.parent_session_id, "parent-1"); + assert_eq!(context.parent_session_name, "主线程会话"); + assert_eq!(context.role_hint.as_deref(), Some("Image #1")); + assert_eq!( + context.task_summary.as_deref(), + Some("处理父线程拆分出来的图片任务") + ); + assert_eq!(context.created_from_turn_id.as_deref(), Some("turn-2")); + assert_eq!(context.sibling_subagent_sessions.len(), 1); + assert_eq!(context.sibling_subagent_sessions[0].id, "child-sibling"); + } + + #[test] + fn resolve_child_subagent_runtime_status_from_snapshot_should_use_latest_turn_status() { + let now = Utc::now(); + let snapshot = SessionRuntimeSnapshot { + session_id: "child-session-1".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread: ThreadRuntime::new( + "thread-1", + "child-session-1", + std::path::PathBuf::from("/tmp/workspace-child"), + ), + turns: vec![ + TurnRuntime { + id: "turn-old".to_string(), + session_id: "child-session-1".to_string(), + thread_id: "thread-1".to_string(), + status: TurnStatus::Running, + input_text: Some("旧任务".to_string()), + error_message: None, + context_override: None, + created_at: now - Duration::minutes(2), + started_at: Some(now - Duration::minutes(2)), + completed_at: None, + updated_at: now - Duration::minutes(1), + }, + TurnRuntime { + id: "turn-new".to_string(), + session_id: "child-session-1".to_string(), + thread_id: "thread-1".to_string(), + status: TurnStatus::Completed, + input_text: Some("新任务".to_string()), + error_message: None, + context_override: None, + created_at: now - Duration::seconds(30), + started_at: Some(now - Duration::seconds(30)), + completed_at: Some(now - Duration::seconds(10)), + updated_at: now, + }, + ], + items: Vec::new(), + }], + }; + + assert_eq!( + resolve_child_subagent_runtime_status_from_snapshot(&snapshot), + ChildSubagentRuntimeStatus::Completed + ); + } + + #[test] + fn apply_runtime_status_to_child_subagent_session_should_keep_runtime_detail() { + let mut summary = ChildSubagentSession { + id: "child-1".to_string(), + name: "研究员".to_string(), + created_at: 1_710_000_000, + updated_at: 1_710_000_100, + session_type: "sub_agent".to_string(), + model: Some("claude-sonnet-4".to_string()), + provider_name: Some("openai".to_string()), + working_dir: Some("/tmp/workspace-child".to_string()), + workspace_id: Some("workspace-1".to_string()), + task_summary: Some("整理事实源".to_string()), + role_hint: Some("explorer".to_string()), + origin_tool: Some("spawn_agent".to_string()), + created_from_turn_id: Some("turn-1".to_string()), + profile_id: None, + profile_name: None, + role_key: None, + team_preset_id: None, + theme: None, + output_contract: None, + skill_ids: Vec::new(), + skills: Vec::new(), + runtime_status: None, + latest_turn_status: None, + queued_turn_count: 0, + }; + + apply_runtime_status_to_child_subagent_session( + &mut summary, + crate::subagent_control::SubagentRuntimeStatus { + session_id: "child-1".to_string(), + kind: SubagentRuntimeStatusKind::Queued, + latest_turn_id: Some("turn-queued".to_string()), + latest_turn_status: Some(SubagentRuntimeStatusKind::Completed), + queued_turn_count: 2, + closed: false, + }, + ); + + assert_eq!( + summary.runtime_status, + Some(ChildSubagentRuntimeStatus::Queued) + ); + assert_eq!( + summary.latest_turn_status, + Some(ChildSubagentRuntimeStatus::Completed) + ); + assert_eq!(summary.queued_turn_count, 2); + } + #[test] fn convert_agent_message_should_preserve_tool_request_and_response() { let assistant = AgentMessage { diff --git a/src-tauri/crates/agent/src/subagent_control.rs b/src-tauri/crates/agent/src/subagent_control.rs new file mode 100644 index 000000000..1fdc1780e --- /dev/null +++ b/src-tauri/crates/agent/src/subagent_control.rs @@ -0,0 +1,417 @@ +use crate::aster_runtime_support::{list_aster_runtime_queued_turns, load_aster_runtime_snapshot}; +use aster::session::extension_data::{ExtensionData, ExtensionState}; +use aster::session::{ + list_subagent_sessions_with_metadata, require_shared_session_runtime_queue_service, + resolve_subagent_session_metadata, QueuedTurnRuntime, Session, SessionManager, SessionType, + TurnStatus, +}; +use chrono::Utc; +use std::collections::{HashMap, VecDeque}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)] +pub struct SubagentControlState { + #[serde(default)] + pub closed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub closed_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub closed_reason: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub stashed_queued_turns: Vec, +} + +impl ExtensionState for SubagentControlState { + const EXTENSION_NAME: &'static str = "subagent_control"; + const VERSION: &'static str = "v0"; +} + +impl SubagentControlState { + pub fn from_extension_data(extension_data: &ExtensionData) -> Option { + ::from_extension_data(extension_data) + } + + pub fn from_session(session: &Session) -> Option { + Self::from_extension_data(&session.extension_data) + } + + pub fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> { + ::to_extension_data(self, extension_data) + .map_err(|error| error.to_string()) + } + + pub fn into_updated_extension_data(self, session: &Session) -> Result { + let mut extension_data = session.extension_data.clone(); + self.to_extension_data(&mut extension_data)?; + Ok(extension_data) + } + + pub fn closed(reason: Option, stashed_queued_turns: Vec) -> Self { + Self { + closed: true, + closed_at: Some(Utc::now().to_rfc3339()), + closed_reason: normalize_optional_text(reason), + stashed_queued_turns, + } + } + + pub fn opened(mut self) -> Self { + self.closed = false; + self.closed_at = None; + self.closed_reason = None; + self + } +} + +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SubagentRuntimeStatusKind { + Idle, + Queued, + Running, + Completed, + Failed, + Aborted, + Closed, + NotFound, +} + +impl SubagentRuntimeStatusKind { + pub fn is_final(self) -> bool { + matches!( + self, + Self::Completed | Self::Failed | Self::Aborted | Self::Closed | Self::NotFound + ) + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct SubagentRuntimeStatus { + pub session_id: String, + pub kind: SubagentRuntimeStatusKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest_turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest_turn_status: Option, + #[serde(default, skip_serializing_if = "is_zero")] + pub queued_turn_count: usize, + #[serde(default)] + pub closed: bool, +} + +impl SubagentRuntimeStatus { + fn not_found(session_id: &str) -> Self { + Self { + session_id: session_id.to_string(), + kind: SubagentRuntimeStatusKind::NotFound, + latest_turn_id: None, + latest_turn_status: None, + queued_turn_count: 0, + closed: false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LatestTurnProjection { + turn_id: String, + status: TurnStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SubagentRuntimeStatusInput { + pub closed: bool, + pub has_active_turn: bool, + pub queued_turn_count: usize, + pub latest_turn_status: Option, +} + +fn is_zero(value: &usize) -> bool { + *value == 0 +} + +fn normalize_optional_text(value: Option) -> Option { + let trimmed = value?.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn looks_like_session_not_found(error: &str) -> bool { + let normalized = error.to_ascii_lowercase(); + normalized.contains("not found") || error.contains("不存在") +} + +fn ensure_subagent_session(session: &Session) -> Result<(), String> { + if session.session_type != SessionType::SubAgent { + return Err(format!( + "会话不是 subagent session: session_id={}, session_type={}", + session.id, session.session_type + )); + } + Ok(()) +} + +fn map_turn_status(status: TurnStatus) -> SubagentRuntimeStatusKind { + match status { + TurnStatus::Queued => SubagentRuntimeStatusKind::Queued, + TurnStatus::Running => SubagentRuntimeStatusKind::Running, + TurnStatus::Completed => SubagentRuntimeStatusKind::Completed, + TurnStatus::Failed => SubagentRuntimeStatusKind::Failed, + TurnStatus::Aborted => SubagentRuntimeStatusKind::Aborted, + } +} + +fn latest_turn_projection( + snapshot: &aster::session::SessionRuntimeSnapshot, +) -> Option { + snapshot + .threads + .iter() + .flat_map(|thread| thread.turns.iter()) + .max_by(|left, right| { + left.updated_at + .cmp(&right.updated_at) + .then_with(|| left.created_at.cmp(&right.created_at)) + .then_with(|| left.id.cmp(&right.id)) + }) + .map(|turn| LatestTurnProjection { + turn_id: turn.id.clone(), + status: turn.status, + }) +} + +pub fn derive_subagent_runtime_status_kind( + input: SubagentRuntimeStatusInput, +) -> SubagentRuntimeStatusKind { + if input.closed { + return SubagentRuntimeStatusKind::Closed; + } + + if input.has_active_turn { + return SubagentRuntimeStatusKind::Running; + } + + if input.queued_turn_count > 0 { + return SubagentRuntimeStatusKind::Queued; + } + + input + .latest_turn_status + .map(map_turn_status) + .unwrap_or(SubagentRuntimeStatusKind::Idle) +} + +pub async fn read_subagent_control_state( + session_id: &str, +) -> Result<(Session, SubagentControlState), String> { + let session = SessionManager::get_session(session_id, false) + .await + .map_err(|error| format!("读取 subagent session 失败: {error}"))?; + ensure_subagent_session(&session)?; + Ok(( + session.clone(), + SubagentControlState::from_session(&session).unwrap_or_default(), + )) +} + +pub async fn write_subagent_control_state( + session: &Session, + control_state: &SubagentControlState, +) -> Result<(), String> { + ensure_subagent_session(session)?; + let extension_data = control_state + .clone() + .into_updated_extension_data(session) + .map_err(|error| format!("写入 subagent control state 失败: {error}"))?; + SessionManager::update_session(&session.id) + .extension_data(extension_data) + .apply() + .await + .map_err(|error| format!("持久化 subagent control state 失败: {error}")) +} + +pub async fn load_subagent_runtime_status( + session_id: &str, +) -> Result { + let session = match SessionManager::get_session(session_id, false).await { + Ok(session) => session, + Err(error) => { + let message = error.to_string(); + if looks_like_session_not_found(&message) { + return Ok(SubagentRuntimeStatus::not_found(session_id)); + } + return Err(format!("读取 subagent session 失败: {message}")); + } + }; + ensure_subagent_session(&session)?; + + let control_state = SubagentControlState::from_session(&session).unwrap_or_default(); + let latest_turn = match load_aster_runtime_snapshot(session_id).await { + Ok(snapshot) => latest_turn_projection(&snapshot), + Err(error) => { + tracing::debug!( + "[SubagentControl] 读取 runtime snapshot 失败,按无运行态继续: session_id={}, error={}", + session_id, + error + ); + None + } + }; + + let queued_turn_count = list_aster_runtime_queued_turns(session_id).await?.len(); + let has_active_turn = require_shared_session_runtime_queue_service() + .map_err(|error| format!("读取 runtime queue service 失败: {error}"))? + .has_active_turn(session_id); + let kind = derive_subagent_runtime_status_kind(SubagentRuntimeStatusInput { + closed: control_state.closed, + has_active_turn, + queued_turn_count, + latest_turn_status: latest_turn.as_ref().map(|turn| turn.status), + }); + + Ok(SubagentRuntimeStatus { + session_id: session_id.to_string(), + kind, + latest_turn_id: latest_turn.as_ref().map(|turn| turn.turn_id.clone()), + latest_turn_status: latest_turn.map(|turn| map_turn_status(turn.status)), + queued_turn_count, + closed: control_state.closed, + }) +} + +pub async fn list_subagent_cascade_session_ids(session_id: &str) -> Result, String> { + let root_session = SessionManager::get_session(session_id, false) + .await + .map_err(|error| format!("读取 subagent session 失败: {error}"))?; + ensure_subagent_session(&root_session)?; + + let sessions = list_subagent_sessions_with_metadata() + .await + .map_err(|error| format!("读取 subagent session 列表失败: {error}"))?; + Ok(collect_subagent_cascade_session_ids(session_id, &sessions)) +} + +pub fn collect_subagent_cascade_session_ids(session_id: &str, sessions: &[Session]) -> Vec { + let mut children_by_parent: HashMap> = HashMap::new(); + for session in sessions { + let Some(metadata) = resolve_subagent_session_metadata(&session.extension_data) else { + continue; + }; + children_by_parent + .entry(metadata.parent_session_id) + .or_default() + .push(session.id.clone()); + } + + let mut ordered = vec![session_id.to_string()]; + let mut queue = VecDeque::from([session_id.to_string()]); + while let Some(parent_id) = queue.pop_front() { + let Some(children) = children_by_parent.get(&parent_id) else { + continue; + }; + for child_id in children { + ordered.push(child_id.clone()); + queue.push_back(child_id.clone()); + } + } + ordered +} + +#[cfg(test)] +mod tests { + use super::*; + use aster::session::Session; + use chrono::{Duration, Utc}; + + #[test] + fn subagent_control_state_roundtrip() { + let state = SubagentControlState::closed( + Some("manual_close".to_string()), + vec![QueuedTurnRuntime { + queued_turn_id: "queued-1".to_string(), + session_id: "child-1".to_string(), + message_preview: "preview".to_string(), + message_text: "message".to_string(), + created_at: 1, + image_count: 0, + payload: serde_json::json!({ "message": "test" }), + metadata: HashMap::new(), + }], + ); + + let mut extension_data = ExtensionData::default(); + state.to_extension_data(&mut extension_data).unwrap(); + let restored = SubagentControlState::from_extension_data(&extension_data).unwrap(); + + assert_eq!(restored, state); + } + + #[test] + fn collect_subagent_cascade_session_ids_returns_breadth_first_tree() { + let now = Utc::now(); + let child_a = Session { + id: "child-a".to_string(), + session_type: SessionType::SubAgent, + updated_at: now, + extension_data: aster::session::SubagentSessionMetadata::new("root") + .into_updated_extension_data(&Session::default()) + .unwrap(), + ..Session::default() + }; + let child_b = Session { + id: "child-b".to_string(), + session_type: SessionType::SubAgent, + updated_at: now - Duration::minutes(1), + extension_data: aster::session::SubagentSessionMetadata::new("root") + .into_updated_extension_data(&Session::default()) + .unwrap(), + ..Session::default() + }; + let grandchild = Session { + id: "grandchild".to_string(), + session_type: SessionType::SubAgent, + updated_at: now - Duration::minutes(2), + extension_data: aster::session::SubagentSessionMetadata::new("child-a") + .into_updated_extension_data(&Session::default()) + .unwrap(), + ..Session::default() + }; + + let ids = collect_subagent_cascade_session_ids("root", &[child_a, child_b, grandchild]); + + assert_eq!(ids, vec!["root", "child-a", "child-b", "grandchild"]); + } + + #[test] + fn derive_subagent_runtime_status_kind_prioritizes_closed_and_final_states() { + assert_eq!( + derive_subagent_runtime_status_kind(SubagentRuntimeStatusInput { + closed: true, + has_active_turn: true, + queued_turn_count: 2, + latest_turn_status: Some(TurnStatus::Running), + }), + SubagentRuntimeStatusKind::Closed + ); + assert_eq!( + derive_subagent_runtime_status_kind(SubagentRuntimeStatusInput { + closed: false, + has_active_turn: false, + queued_turn_count: 0, + latest_turn_status: Some(TurnStatus::Completed), + }), + SubagentRuntimeStatusKind::Completed + ); + assert_eq!( + derive_subagent_runtime_status_kind(SubagentRuntimeStatusInput { + closed: false, + has_active_turn: false, + queued_turn_count: 1, + latest_turn_status: Some(TurnStatus::Completed), + }), + SubagentRuntimeStatusKind::Queued + ); + } +} diff --git a/src-tauri/crates/agent/src/subagent_profiles.rs b/src-tauri/crates/agent/src/subagent_profiles.rs new file mode 100644 index 000000000..d5bc155a0 --- /dev/null +++ b/src-tauri/crates/agent/src/subagent_profiles.rs @@ -0,0 +1,428 @@ +use aster::session::extension_data::{ExtensionData, ExtensionState}; +use aster::session::Session; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct SubagentSkillSummary { + pub id: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub directory: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct SubagentProfileSummary { + pub id: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role_key: Option, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub theme: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_contract: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_overlay: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skill_ids: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct TeamPresetSummary { + pub id: String, + pub name: String, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub theme: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub profile_ids: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq, Eq)] +pub struct SubagentCustomizationState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_preset_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub theme: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_contract: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_overlay: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skill_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skills: Vec, +} + +impl ExtensionState for SubagentCustomizationState { + const EXTENSION_NAME: &'static str = "subagent_customization"; + const VERSION: &'static str = "v0"; +} + +impl SubagentCustomizationState { + pub fn from_extension_data(extension_data: &ExtensionData) -> Option { + ::from_extension_data(extension_data) + } + + pub fn from_session(session: &Session) -> Option { + Self::from_extension_data(&session.extension_data) + } + + pub fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> { + ::to_extension_data(self, extension_data) + .map_err(|error| error.to_string()) + } + + pub fn into_updated_extension_data(self, session: &Session) -> Result { + let mut extension_data = session.extension_data.clone(); + self.to_extension_data(&mut extension_data)?; + Ok(extension_data) + } + + pub fn is_empty(&self) -> bool { + self.profile_id.is_none() + && self.profile_name.is_none() + && self.role_key.is_none() + && self.team_preset_id.is_none() + && self.theme.is_none() + && self.output_contract.is_none() + && self.system_overlay.is_none() + && self.skill_ids.is_empty() + && self.skills.is_empty() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubagentSkillPromptBlock { + pub title: String, + pub content: String, +} + +#[derive(Debug, Clone, Copy)] +pub struct BuiltinSkillDescriptor { + pub id: &'static str, + pub name: &'static str, + pub description: &'static str, + pub prompt_overlay: &'static str, +} + +#[derive(Debug, Clone, Copy)] +pub struct BuiltinProfileDescriptor { + pub id: &'static str, + pub name: &'static str, + pub role_key: &'static str, + pub description: &'static str, + pub theme: &'static str, + pub output_contract: &'static str, + pub system_overlay: &'static str, + pub skill_ids: &'static [&'static str], +} + +#[derive(Debug, Clone, Copy)] +pub struct BuiltinTeamPresetDescriptor { + pub id: &'static str, + pub name: &'static str, + pub description: &'static str, + pub theme: &'static str, + pub profile_ids: &'static [&'static str], +} + +const BUILTIN_SKILLS: &[BuiltinSkillDescriptor] = &[ + BuiltinSkillDescriptor { + id: "repo-exploration", + name: "仓库探索", + description: "优先读事实源、收敛问题边界,并避免在未确认上下文前直接改动。", + prompt_overlay: + "先确认真实事实源,再输出发现、证据、影响面和下一步建议。不要直接跳到实现。", + }, + BuiltinSkillDescriptor { + id: "bounded-implementation", + name: "边界实现", + description: "实现时只改明确归属的范围,避免与其他子代理写入冲突。", + prompt_overlay: + "实现只覆盖明确授权的范围。若存在未知依赖或潜在写入冲突,应先显式说明假设。", + }, + BuiltinSkillDescriptor { + id: "verification-report", + name: "验证汇报", + description: "强调验证、回归、风险与剩余缺口,而不是泛化总结。", + prompt_overlay: "优先报告验证步骤、通过项、失败项、残余风险和建议回归范围,避免空泛总结。", + }, + BuiltinSkillDescriptor { + id: "source-grounding", + name: "事实收敛", + description: "对调研与分析类任务要求明确区分事实、推断和待验证项。", + prompt_overlay: "输出中要明确区分事实、推断与待验证项;引用来源时尽量给出时间口径。", + }, + BuiltinSkillDescriptor { + id: "structured-writing", + name: "结构写作", + description: "产出面向开发者可直接执行的方案、摘要或说明文档。", + prompt_overlay: + "写作优先输出可直接复用的结构化内容,避免空泛修辞,默认面向有经验的开发者。", + }, +]; + +const BUILTIN_PROFILES: &[BuiltinProfileDescriptor] = &[ + BuiltinProfileDescriptor { + id: "code-explorer", + name: "代码分析员", + role_key: "explorer", + description: "负责阅读代码、收敛问题、定位影响面与事实证据。", + theme: "engineering", + output_contract: "输出问题定位、证据、影响范围、候选方案,不直接大范围改文件。", + system_overlay: + "你是团队中的代码分析员。优先建立事实模型,明确根因和影响面,再给出最小变更建议。", + skill_ids: &["repo-exploration", "source-grounding"], + }, + BuiltinProfileDescriptor { + id: "code-executor", + name: "代码执行员", + role_key: "executor", + description: "负责在清晰边界内实现改动,并回报改动与验证结果。", + theme: "engineering", + output_contract: "只在明确写入范围内实现,并说明改动点、验证结果、未覆盖风险。", + system_overlay: + "你是团队中的代码执行员。只在边界清晰、职责明确的范围里实现,不要扩散到无关模块。", + skill_ids: &["bounded-implementation", "verification-report"], + }, + BuiltinProfileDescriptor { + id: "code-verifier", + name: "代码验证员", + role_key: "verifier", + description: "负责复核结果、补充测试与列出风险。", + theme: "engineering", + output_contract: "输出验证步骤、结论、失败项、剩余风险与建议回归范围。", + system_overlay: + "你是团队中的代码验证员。重点是验证与风险,不重复实现过程,不输出泛泛总结。", + skill_ids: &["verification-report", "source-grounding"], + }, + BuiltinProfileDescriptor { + id: "research-analyst", + name: "研究分析员", + role_key: "researcher", + description: "负责多源材料整理、证据归并与结论提炼。", + theme: "research", + output_contract: "输出事实、结论、待验证项和来源时间口径。", + system_overlay: "你是团队中的研究分析员。优先整理来源、比对差异、提炼可支撑的结论。", + skill_ids: &["source-grounding", "structured-writing"], + }, + BuiltinProfileDescriptor { + id: "doc-writer", + name: "文档起草员", + role_key: "writer", + description: "负责把分析结果转成方案、说明、PRD 或面向团队的文档。", + theme: "documentation", + output_contract: "输出结构清晰、可直接评审或落地的文档草稿。", + system_overlay: + "你是团队中的文档起草员。目标是产出可被开发者直接评审和执行的文档,而不是泛化描述。", + skill_ids: &["structured-writing"], + }, + BuiltinProfileDescriptor { + id: "content-ideator", + name: "内容策划员", + role_key: "ideator", + description: "负责生成创意方向、候选结构与选题角度。", + theme: "content", + output_contract: "输出多个可比较方向,并说明适用场景与取舍。", + system_overlay: "你是团队中的内容策划员。优先给出有区分度的方向,而不是单一平均解。", + skill_ids: &["structured-writing"], + }, + BuiltinProfileDescriptor { + id: "content-reviewer", + name: "内容复核员", + role_key: "reviewer", + description: "负责复核内容一致性、可读性与发布风险。", + theme: "content", + output_contract: "输出问题清单、建议修改项和发布前检查项。", + system_overlay: "你是团队中的内容复核员。重点识别表达问题、逻辑缺口和发布风险。", + skill_ids: &["verification-report", "structured-writing"], + }, +]; + +const BUILTIN_TEAM_PRESETS: &[BuiltinTeamPresetDescriptor] = &[ + BuiltinTeamPresetDescriptor { + id: "code-triage-team", + name: "代码排障团队", + description: "适合代码问题的分析、实现、验证闭环。", + theme: "engineering", + profile_ids: &["code-explorer", "code-executor", "code-verifier"], + }, + BuiltinTeamPresetDescriptor { + id: "research-team", + name: "研究团队", + description: "适合事实收敛、资料分析和文档沉淀。", + theme: "research", + profile_ids: &["research-analyst", "doc-writer", "code-verifier"], + }, + BuiltinTeamPresetDescriptor { + id: "content-creation-team", + name: "内容创作团队", + description: "适合创意拆分、内容起草与复核。", + theme: "content", + profile_ids: &["content-ideator", "doc-writer", "content-reviewer"], + }, +]; + +fn normalize_optional_text(value: Option) -> Option { + let trimmed = value?.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +pub fn builtin_skill_descriptor_by_id(id: &str) -> Option<&'static BuiltinSkillDescriptor> { + BUILTIN_SKILLS + .iter() + .find(|descriptor| descriptor.id == id.trim()) +} + +pub fn builtin_profile_descriptor_by_id(id: &str) -> Option<&'static BuiltinProfileDescriptor> { + BUILTIN_PROFILES + .iter() + .find(|descriptor| descriptor.id == id.trim()) +} + +pub fn builtin_team_preset_descriptor_by_id( + id: &str, +) -> Option<&'static BuiltinTeamPresetDescriptor> { + BUILTIN_TEAM_PRESETS + .iter() + .find(|descriptor| descriptor.id == id.trim()) +} + +pub fn builtin_team_preset_label_by_id(id: &str) -> Option<&'static str> { + builtin_team_preset_descriptor_by_id(id).map(|descriptor| descriptor.name) +} + +pub fn builtin_profile_name_by_id(id: &str) -> Option<&'static str> { + builtin_profile_descriptor_by_id(id).map(|descriptor| descriptor.name) +} + +pub fn summarize_builtin_skill(id: &str) -> Option { + let descriptor = builtin_skill_descriptor_by_id(id)?; + Some(SubagentSkillSummary { + id: descriptor.id.to_string(), + name: descriptor.name.to_string(), + description: Some(descriptor.description.to_string()), + source: Some("builtin".to_string()), + directory: None, + }) +} + +pub fn summarize_builtin_profile(id: &str) -> Option { + let descriptor = builtin_profile_descriptor_by_id(id)?; + Some(SubagentProfileSummary { + id: descriptor.id.to_string(), + name: descriptor.name.to_string(), + role_key: Some(descriptor.role_key.to_string()), + description: descriptor.description.to_string(), + theme: Some(descriptor.theme.to_string()), + output_contract: Some(descriptor.output_contract.to_string()), + system_overlay: Some(descriptor.system_overlay.to_string()), + skill_ids: descriptor + .skill_ids + .iter() + .map(|skill_id| (*skill_id).to_string()) + .collect(), + }) +} + +pub fn summarize_builtin_team_preset(id: &str) -> Option { + let descriptor = builtin_team_preset_descriptor_by_id(id)?; + Some(TeamPresetSummary { + id: descriptor.id.to_string(), + name: descriptor.name.to_string(), + description: descriptor.description.to_string(), + theme: Some(descriptor.theme.to_string()), + profile_ids: descriptor + .profile_ids + .iter() + .map(|profile_id| (*profile_id).to_string()) + .collect(), + }) +} + +pub fn build_subagent_customization_prompt( + customization: &SubagentCustomizationState, + local_skill_blocks: &[SubagentSkillPromptBlock], +) -> Option { + if customization.is_empty() && local_skill_blocks.is_empty() { + return None; + } + + let mut sections = Vec::new(); + let mut header_lines = vec!["【Subagent 定制配置】".to_string()]; + if let Some(team_preset_id) = customization.team_preset_id.as_deref() { + let preset_label = + builtin_team_preset_label_by_id(team_preset_id).unwrap_or(team_preset_id); + header_lines.push(format!("- 团队预设:{preset_label} ({team_preset_id})")); + } + if let Some(profile_name) = customization.profile_name.as_deref() { + let profile_id_suffix = customization + .profile_id + .as_deref() + .map(|profile_id| format!(" ({profile_id})")) + .unwrap_or_default(); + header_lines.push(format!("- Profile:{profile_name}{profile_id_suffix}")); + } else if let Some(profile_id) = customization.profile_id.as_deref() { + let profile_name = builtin_profile_name_by_id(profile_id).unwrap_or(profile_id); + header_lines.push(format!("- Profile:{profile_name} ({profile_id})")); + } + if let Some(role_key) = customization.role_key.as_deref() { + header_lines.push(format!("- Role Key:{role_key}")); + } + if let Some(theme) = customization.theme.as_deref() { + header_lines.push(format!("- Theme:{theme}")); + } + if let Some(output_contract) = customization.output_contract.as_deref() { + header_lines.push(format!("- 输出契约:{output_contract}")); + } + sections.push(header_lines.join("\n")); + + if let Some(system_overlay) = customization.system_overlay.as_deref() { + let trimmed = system_overlay.trim(); + if !trimmed.is_empty() { + sections.push(format!("执行补充要求:\n{trimmed}")); + } + } + + let builtin_skill_blocks = customization + .skill_ids + .iter() + .filter_map(|skill_id| builtin_skill_descriptor_by_id(skill_id)) + .map(|descriptor| SubagentSkillPromptBlock { + title: format!("builtin skill · {}", descriptor.name), + content: descriptor.prompt_overlay.to_string(), + }) + .collect::>(); + + let mut all_skill_blocks = builtin_skill_blocks; + all_skill_blocks.extend(local_skill_blocks.iter().cloned()); + + if !all_skill_blocks.is_empty() { + let rendered_blocks = all_skill_blocks + .iter() + .filter_map(|block| { + let content = normalize_optional_text(Some(block.content.clone()))?; + Some(format!("### {}\n{}", block.title, content)) + }) + .collect::>(); + if !rendered_blocks.is_empty() { + sections.push(format!("附加技能:\n{}", rendered_blocks.join("\n\n"))); + } + } + + Some(sections.join("\n\n")) +} diff --git a/src-tauri/crates/agent/src/tool_permissions.rs b/src-tauri/crates/agent/src/tool_permissions.rs index f93ec4773..d23ab2684 100644 --- a/src-tauri/crates/agent/src/tool_permissions.rs +++ b/src-tauri/crates/agent/src/tool_permissions.rs @@ -230,6 +230,24 @@ impl Default for ToolPermissionChecker { mod tests { use super::*; + struct TestDynamicChecker; + + impl DynamicPermissionCheck for TestDynamicChecker { + fn check_permissions( + &self, + tool_name: &str, + _input: &serde_json::Value, + ) -> PermissionBehavior { + if tool_name == "bash" { + PermissionBehavior::Deny { + reason: "dynamic deny".to_string(), + } + } else { + PermissionBehavior::Allow + } + } + } + #[test] fn test_default_permissions_loaded() { let checker = ToolPermissionChecker::new(); @@ -384,4 +402,18 @@ mod tests { PermissionBehavior::Allow ); } + + #[test] + fn test_set_dynamic_checker_overrides_static_decision() { + let mut checker = ToolPermissionChecker::new(); + checker.set_dynamic_checker(Box::new(TestDynamicChecker)); + + let result = checker.check_permission("bash", Some(&serde_json::json!({}))); + assert_eq!( + result, + PermissionBehavior::Deny { + reason: "dynamic deny".to_string(), + } + ); + } } diff --git a/src-tauri/crates/agent/tests/legacy_permission_surfaces.rs b/src-tauri/crates/agent/tests/legacy_permission_surfaces.rs new file mode 100644 index 000000000..e14a6f0f1 --- /dev/null +++ b/src-tauri/crates/agent/tests/legacy_permission_surfaces.rs @@ -0,0 +1,5 @@ +#[path = "../src/tool_permissions.rs"] +mod tool_permissions; + +#[path = "../src/shell_security.rs"] +mod shell_security; diff --git a/src-tauri/crates/core/src/config/mod.rs b/src-tauri/crates/core/src/config/mod.rs index f3c982244..d6605804a 100644 --- a/src-tauri/crates/core/src/config/mod.rs +++ b/src-tauri/crates/core/src/config/mod.rs @@ -39,9 +39,11 @@ pub use types::{ RateLimitSettings, RemoteManagementConfig, ResponseCacheSettings, RetrySettings, RoutingConfig, ScreenshotChatConfig, SearchEngine, ServerConfig, ShellEnvironmentImportConfig, TaskSchedule, TelegramAccountConfig, TelegramBotConfig, TelegramGroupConfig, TelegramTopicConfig, TlsConfig, - ToolCallingConfig, UpdateCheckConfig, UserProfile, VertexApiKeyEntry, VertexModelAlias, - VoiceConfig, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig, VoiceOutputMode, - VoiceProcessorConfig, WebSearchConfig, WebSearchProvider, WhisperLocalConfig, WhisperModelSize, - WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY, + ToolCallingConfig, ToolExecutionOverrideConfig, ToolExecutionPolicyConfig, + ToolExecutionRestrictionProfileConfig, ToolExecutionSandboxProfileConfig, + ToolExecutionWarningPolicyConfig, UpdateCheckConfig, UserProfile, VertexApiKeyEntry, + VertexModelAlias, VoiceConfig, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig, + VoiceOutputMode, VoiceProcessorConfig, WebSearchConfig, WebSearchProvider, WhisperLocalConfig, + WhisperModelSize, 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 85243815e..ef9702ff7 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -492,6 +492,81 @@ impl Default for WorkspaceSandboxConfig { } } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ToolExecutionWarningPolicyConfig { + #[default] + None, + ShellCommandRisk, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ToolExecutionRestrictionProfileConfig { + #[default] + None, + WorkspacePathRequired, + WorkspacePathOptional, + WorkspaceAbsolutePathRequired, + WorkspaceShellCommand, + AnalyzeImageInput, + SafeHttpsUrlRequired, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ToolExecutionSandboxProfileConfig { + #[default] + None, + WorkspaceCommand, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ToolExecutionOverrideConfig { + #[serde( + default, + alias = "warningPolicy", + skip_serializing_if = "Option::is_none" + )] + pub warning_policy: Option, + #[serde( + default, + alias = "restrictionProfile", + skip_serializing_if = "Option::is_none" + )] + pub restriction_profile: Option, + #[serde( + default, + alias = "sandboxProfile", + skip_serializing_if = "Option::is_none" + )] + pub sandbox_profile: Option, +} + +impl ToolExecutionOverrideConfig { + pub fn is_default(value: &Self) -> bool { + value.warning_policy.is_none() + && value.restriction_profile.is_none() + && value.sandbox_profile.is_none() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ToolExecutionPolicyConfig { + #[serde( + default, + alias = "toolOverrides", + skip_serializing_if = "HashMap::is_empty" + )] + pub tool_overrides: HashMap, +} + +impl ToolExecutionPolicyConfig { + pub fn is_default(value: &Self) -> bool { + value.tool_overrides.is_empty() + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct NativeAgentConfig { /// 是否使用默认系统提示词 @@ -518,6 +593,9 @@ pub struct NativeAgentConfig { /// workspace 本地 sandbox 配置(可选安全增强) #[serde(default, skip_serializing_if = "WorkspaceSandboxConfig::is_default")] pub workspace_sandbox: WorkspaceSandboxConfig, + /// 工具执行权限覆盖配置(默认策略之上的持久化覆盖) + #[serde(default, skip_serializing_if = "ToolExecutionPolicyConfig::is_default")] + pub tool_execution: ToolExecutionPolicyConfig, } fn default_use_default_prompt() -> bool { @@ -546,6 +624,7 @@ impl Default for NativeAgentConfig { temperature: default_temperature(), max_tokens: default_max_tokens(), workspace_sandbox: WorkspaceSandboxConfig::default(), + tool_execution: ToolExecutionPolicyConfig::default(), } } } @@ -573,7 +652,7 @@ fn current_workspace_preferences_schema_version() -> u8 { } fn default_enabled_themes() -> Vec { - vec!["social-media".to_string(), "poster".to_string()] + vec!["social-media".to_string()] } impl Default for ContentCreatorConfig { @@ -2637,7 +2716,7 @@ mod unit_tests { assert_eq!(config.content_creator.schema_version, 1); assert_eq!( config.content_creator.enabled_themes, - vec!["social-media".to_string(), "poster".to_string()] + vec!["social-media".to_string()] ); assert_eq!(config.navigation.schema_version, 1); assert_eq!( @@ -2654,6 +2733,70 @@ mod unit_tests { "memory".to_string(), ] ); + assert!(config.agent.tool_execution.tool_overrides.is_empty()); + } + + #[test] + fn test_tool_execution_policy_config_supports_camel_case_runtime_shape() { + let value = serde_json::json!({ + "toolOverrides": { + "bash": { + "warningPolicy": "none", + "restrictionProfile": "workspace_path_required", + "sandboxProfile": "none" + }, + "Task": { + "warning_policy": "shell_command_risk" + } + } + }); + + let parsed: ToolExecutionPolicyConfig = + serde_json::from_value(value).expect("tool execution config should deserialize"); + + assert_eq!( + parsed + .tool_overrides + .get("bash") + .and_then(|item| item.warning_policy), + Some(ToolExecutionWarningPolicyConfig::None) + ); + assert_eq!( + parsed + .tool_overrides + .get("bash") + .and_then(|item| item.restriction_profile), + Some(ToolExecutionRestrictionProfileConfig::WorkspacePathRequired) + ); + assert_eq!( + parsed + .tool_overrides + .get("Task") + .and_then(|item| item.warning_policy), + Some(ToolExecutionWarningPolicyConfig::ShellCommandRisk) + ); + } + + #[test] + fn test_tool_execution_policy_config_roundtrip_preserves_non_default_overrides() { + let config = ToolExecutionPolicyConfig { + tool_overrides: HashMap::from([( + "bash".to_string(), + ToolExecutionOverrideConfig { + warning_policy: Some(ToolExecutionWarningPolicyConfig::None), + restriction_profile: Some( + ToolExecutionRestrictionProfileConfig::WorkspacePathRequired, + ), + sandbox_profile: Some(ToolExecutionSandboxProfileConfig::None), + }, + )]), + }; + + let value = serde_json::to_value(&config).expect("tool execution config should serialize"); + let parsed: ToolExecutionPolicyConfig = + serde_json::from_value(value).expect("tool execution config should deserialize"); + + assert_eq!(parsed, config); } #[test] @@ -2682,7 +2825,7 @@ mod unit_tests { assert_eq!(config.content_creator.schema_version, 1); assert_eq!( config.content_creator.enabled_themes, - vec!["social-media".to_string(), "poster".to_string()] + vec!["social-media".to_string()] ); assert_eq!(config.navigation.schema_version, 1); assert_eq!( diff --git a/src-tauri/crates/core/src/database/dao/agent.rs b/src-tauri/crates/core/src/database/dao/agent.rs index 2d242b63d..928cd532c 100644 --- a/src-tauri/crates/core/src/database/dao/agent.rs +++ b/src-tauri/crates/core/src/database/dao/agent.rs @@ -917,8 +917,16 @@ impl AgentDao { .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; conn.execute( - "INSERT INTO agent_messages (session_id, role, content_json, timestamp, tool_calls_json, tool_call_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO agent_messages ( + session_id, + role, + content_json, + timestamp, + tool_calls_json, + tool_call_id, + reasoning_content + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ session_id, message.role, @@ -926,6 +934,7 @@ impl AgentDao { message.timestamp, tool_calls_json, message.tool_call_id, + message.reasoning_content.as_deref(), ], )?; @@ -944,7 +953,7 @@ impl AgentDao { session_id: &str, ) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( - "SELECT role, content_json, timestamp, tool_calls_json, tool_call_id + "SELECT role, content_json, timestamp, tool_calls_json, tool_call_id, reasoning_content FROM agent_messages WHERE session_id = ? ORDER BY id ASC", )?; @@ -954,6 +963,7 @@ impl AgentDao { let timestamp: String = row.get(2)?; let tool_calls_json: Option = row.get(3)?; let tool_call_id: Option = row.get(4)?; + let reasoning_content: Option = row.get(5)?; // 解析 JSON - 支持多种格式 // 1. Aster 格式: [{"Text":"..."}, {"Text":"..."}] @@ -969,7 +979,7 @@ impl AgentDao { timestamp, tool_calls, tool_call_id, - reasoning_content: None, + reasoning_content, }) })?; @@ -1094,7 +1104,8 @@ mod tests { content_json TEXT NOT NULL, timestamp TEXT NOT NULL, tool_calls_json TEXT, - tool_call_id TEXT + tool_call_id TEXT, + reasoning_content TEXT ); ", ) @@ -1407,4 +1418,44 @@ mod tests { assert_eq!(renamed.session.title.as_deref(), Some("新的标题")); assert_eq!(renamed.session.updated_at, "2026-03-12T09:00:00+08:00"); } + + #[test] + fn add_message_and_get_messages_should_roundtrip_reasoning_content() { + let conn = setup_pattern_test_db(); + + conn.execute( + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, working_dir, execution_strategy) + VALUES (?1, ?2, NULL, ?3, ?4, ?5, NULL, ?6)", + params![ + "session-reasoning", + "deepseek-reasoner", + "推理会话", + "2026-03-19T10:00:00+08:00", + "2026-03-19T10:00:00+08:00", + "react" + ], + ) + .unwrap(); + + AgentDao::add_message( + &conn, + "session-reasoning", + &crate::agent::types::AgentMessage { + role: "assistant".to_string(), + content: MessageContent::Text("需要继续调用工具".to_string()), + timestamp: "2026-03-19T10:00:01+08:00".to_string(), + tool_calls: None, + tool_call_id: None, + reasoning_content: Some("先分析参数,再继续请求".to_string()), + }, + ) + .unwrap(); + + let messages = AgentDao::get_messages(&conn, "session-reasoning").unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!( + messages[0].reasoning_content.as_deref(), + Some("先分析参数,再继续请求") + ); + } } diff --git a/src-tauri/crates/core/src/database/dao/orchestrator.rs b/src-tauri/crates/core/src/database/dao/orchestrator.rs index d92ee1739..32a287014 100644 --- a/src-tauri/crates/core/src/database/dao/orchestrator.rs +++ b/src-tauri/crates/core/src/database/dao/orchestrator.rs @@ -670,39 +670,40 @@ mod tests { use super::*; use rusqlite::{params, Connection}; + struct ModelUsageStatSeed<'a> { + model_id: &'a str, + credential_id: &'a str, + date: &'a str, + request_count: i64, + success_count: i64, + error_count: i64, + total_tokens: i64, + total_latency_ms: i64, + } + fn setup_test_db() -> Connection { let conn = Connection::open_in_memory().unwrap(); crate::database::schema::create_tables(&conn).unwrap(); conn } - fn insert_model_usage_stat( - conn: &Connection, - model_id: &str, - credential_id: &str, - date: &str, - request_count: i64, - success_count: i64, - error_count: i64, - total_tokens: i64, - total_latency_ms: i64, - ) { + fn insert_model_usage_stat(conn: &Connection, stat: ModelUsageStatSeed<'_>) { conn.execute( "INSERT INTO model_usage_stats ( model_id, credential_id, date, request_count, success_count, error_count, total_tokens, total_latency_ms, avg_latency_ms ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ - model_id, - credential_id, - date, - request_count, - success_count, - error_count, - total_tokens, - total_latency_ms, - if request_count > 0 { - total_latency_ms as f64 / request_count as f64 + stat.model_id, + stat.credential_id, + stat.date, + stat.request_count, + stat.success_count, + stat.error_count, + stat.total_tokens, + stat.total_latency_ms, + if stat.request_count > 0 { + stat.total_latency_ms as f64 / stat.request_count as f64 } else { 0.0 }, @@ -821,27 +822,43 @@ mod tests { insert_model_usage_stat( &conn, - "claude-3-opus", - "cred-1", - "2026-03-10", - 2, - 2, - 0, - 2000, - 1000, + ModelUsageStatSeed { + model_id: "claude-3-opus", + credential_id: "cred-1", + date: "2026-03-10", + request_count: 2, + success_count: 2, + error_count: 0, + total_tokens: 2000, + total_latency_ms: 1000, + }, ); insert_model_usage_stat( &conn, - "claude-3-opus", - "cred-2", - "2026-03-11", - 1, - 1, - 0, - 1200, - 600, + ModelUsageStatSeed { + model_id: "claude-3-opus", + credential_id: "cred-2", + date: "2026-03-11", + request_count: 1, + success_count: 1, + error_count: 0, + total_tokens: 1200, + total_latency_ms: 600, + }, + ); + insert_model_usage_stat( + &conn, + ModelUsageStatSeed { + model_id: "gpt-4.1", + credential_id: "cred-3", + date: "2026-03-12", + request_count: 3, + success_count: 2, + error_count: 1, + total_tokens: 900, + total_latency_ms: 450, + }, ); - insert_model_usage_stat(&conn, "gpt-4.1", "cred-3", "2026-03-12", 3, 2, 1, 900, 450); assert!(OrchestratorDao::has_model_usage_stats(&conn).unwrap()); assert_eq!( diff --git a/src-tauri/crates/core/src/database/migration/general_chat_migration.rs b/src-tauri/crates/core/src/database/migration/general_chat_migration.rs index 6a2ed77fc..449eb3f14 100644 --- a/src-tauri/crates/core/src/database/migration/general_chat_migration.rs +++ b/src-tauri/crates/core/src/database/migration/general_chat_migration.rs @@ -395,7 +395,8 @@ mod tests { content_json TEXT NOT NULL, timestamp TEXT NOT NULL, tool_calls_json TEXT, - tool_call_id TEXT + tool_call_id TEXT, + reasoning_content TEXT ); ", ) @@ -428,7 +429,8 @@ mod tests { content_json TEXT NOT NULL, timestamp TEXT NOT NULL, tool_calls_json TEXT, - tool_call_id TEXT + tool_call_id TEXT, + reasoning_content TEXT ); ", ) diff --git a/src-tauri/crates/core/src/database/schema.rs b/src-tauri/crates/core/src/database/schema.rs index 8aea5380c..303f7eba9 100644 --- a/src-tauri/crates/core/src/database/schema.rs +++ b/src-tauri/crates/core/src/database/schema.rs @@ -565,11 +565,17 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { timestamp TEXT NOT NULL, tool_calls_json TEXT, tool_call_id TEXT, + reasoning_content TEXT, FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE )", [], )?; + let _ = conn.execute( + "ALTER TABLE agent_messages ADD COLUMN reasoning_content TEXT", + [], + ); + // 创建 agent_messages 索引 conn.execute( "CREATE INDEX IF NOT EXISTS idx_agent_messages_session ON agent_messages(session_id)", @@ -1444,106 +1450,6 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn should_upgrade_legacy_browser_profile_table_with_transport_columns() { - let conn = Connection::open_in_memory().unwrap(); - conn.execute( - "CREATE TABLE browser_profiles ( - id TEXT PRIMARY KEY, - profile_key TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - description TEXT, - site_scope TEXT, - launch_url TEXT, - profile_dir TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - last_used_at TEXT, - archived_at TEXT - )", - [], - ) - .unwrap(); - conn.execute( - "INSERT INTO browser_profiles ( - id, profile_key, name, description, site_scope, launch_url, profile_dir, - created_at, updated_at, last_used_at, archived_at - ) VALUES (?1, ?2, ?3, NULL, NULL, ?4, ?5, ?6, ?6, NULL, NULL)", - ( - "profile-1", - "shop_us", - "美区资料", - "https://seller.example.com/", - "/tmp/lime/chrome_profiles/shop_us", - "2026-03-15T00:00:00Z", - ), - ) - .unwrap(); - - create_tables(&conn).expect("应成功升级旧版 browser_profiles 表"); - - let mut columns = conn.prepare("PRAGMA table_info(browser_profiles)").unwrap(); - let column_names = columns - .query_map([], |row| row.get::<_, String>(1)) - .unwrap() - .collect::, _>>() - .unwrap(); - assert!(column_names.iter().any(|name| name == "transport_kind")); - assert!(column_names - .iter() - .any(|name| name == "managed_profile_dir")); - - let upgraded = conn - .query_row( - "SELECT transport_kind, managed_profile_dir - FROM browser_profiles - WHERE id = ?1", - ["profile-1"], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), - ) - .unwrap(); - assert_eq!(upgraded.0, "managed_cdp"); - assert_eq!( - upgraded.1.as_deref(), - Some("/tmp/lime/chrome_profiles/shop_us") - ); - } - - #[test] - fn should_upgrade_legacy_mcp_servers_table_with_enablement_columns() { - let conn = Connection::open_in_memory().unwrap(); - conn.execute( - "CREATE TABLE mcp_servers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - server_config TEXT NOT NULL, - description TEXT, - enabled_claude INTEGER DEFAULT 0 - )", - [], - ) - .unwrap(); - - create_tables(&conn).expect("应成功升级旧版 mcp_servers 表"); - - let mut columns = conn.prepare("PRAGMA table_info(mcp_servers)").unwrap(); - let column_names = columns - .query_map([], |row| row.get::<_, String>(1)) - .unwrap() - .collect::, _>>() - .unwrap(); - - assert!(column_names.iter().any(|name| name == "enabled_lime")); - assert!(column_names.iter().any(|name| name == "enabled_codex")); - assert!(column_names.iter().any(|name| name == "enabled_gemini")); - assert!(column_names.iter().any(|name| name == "created_at")); - } -} - /// 迁移:添加proxy_url列到provider_pool_credentials表 /// 使用重建表结构的方式确保数据完整性 fn migrate_add_proxy_url_column(conn: &Connection) -> Result<(), rusqlite::Error> { @@ -1658,3 +1564,103 @@ fn migrate_add_proxy_url_column(conn: &Connection) -> Result<(), rusqlite::Error } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_upgrade_legacy_browser_profile_table_with_transport_columns() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute( + "CREATE TABLE browser_profiles ( + id TEXT PRIMARY KEY, + profile_key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + site_scope TEXT, + launch_url TEXT, + profile_dir TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_used_at TEXT, + archived_at TEXT + )", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO browser_profiles ( + id, profile_key, name, description, site_scope, launch_url, profile_dir, + created_at, updated_at, last_used_at, archived_at + ) VALUES (?1, ?2, ?3, NULL, NULL, ?4, ?5, ?6, ?6, NULL, NULL)", + ( + "profile-1", + "shop_us", + "美区资料", + "https://seller.example.com/", + "/tmp/lime/chrome_profiles/shop_us", + "2026-03-15T00:00:00Z", + ), + ) + .unwrap(); + + create_tables(&conn).expect("应成功升级旧版 browser_profiles 表"); + + let mut columns = conn.prepare("PRAGMA table_info(browser_profiles)").unwrap(); + let column_names = columns + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + assert!(column_names.iter().any(|name| name == "transport_kind")); + assert!(column_names + .iter() + .any(|name| name == "managed_profile_dir")); + + let upgraded = conn + .query_row( + "SELECT transport_kind, managed_profile_dir + FROM browser_profiles + WHERE id = ?1", + ["profile-1"], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)), + ) + .unwrap(); + assert_eq!(upgraded.0, "managed_cdp"); + assert_eq!( + upgraded.1.as_deref(), + Some("/tmp/lime/chrome_profiles/shop_us") + ); + } + + #[test] + fn should_upgrade_legacy_mcp_servers_table_with_enablement_columns() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute( + "CREATE TABLE mcp_servers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + server_config TEXT NOT NULL, + description TEXT, + enabled_claude INTEGER DEFAULT 0 + )", + [], + ) + .unwrap(); + + create_tables(&conn).expect("应成功升级旧版 mcp_servers 表"); + + let mut columns = conn.prepare("PRAGMA table_info(mcp_servers)").unwrap(); + let column_names = columns + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + + assert!(column_names.iter().any(|name| name == "enabled_lime")); + assert!(column_names.iter().any(|name| name == "enabled_codex")); + assert!(column_names.iter().any(|name| name == "enabled_gemini")); + assert!(column_names.iter().any(|name| name == "created_at")); + } +} diff --git a/src-tauri/crates/core/src/tool_calling.rs b/src-tauri/crates/core/src/tool_calling.rs index 9d51b0f29..d672bea44 100644 --- a/src-tauri/crates/core/src/tool_calling.rs +++ b/src-tauri/crates/core/src/tool_calling.rs @@ -4,6 +4,7 @@ use crate::config::{Config, ToolCallingConfig}; use crate::env_compat; +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -23,6 +24,20 @@ static TOOLCALL_V2_ENABLED: AtomicBool = AtomicBool::new(true); static TOOLCALL_DYNAMIC_FILTERING_ENABLED: AtomicBool = AtomicBool::new(true); static TOOLCALL_NATIVE_INPUT_EXAMPLES_ENABLED: AtomicBool = AtomicBool::new(false); +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ToolSurfaceMetadata { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deferred_loading: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub always_visible: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_callers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub input_examples: Vec, +} + /// 将配置应用到进程内运行时开关。 pub fn apply_tool_calling_runtime_config(config: &Config) { apply_tool_calling_runtime_config_with_flags(&config.tool_calling); @@ -69,6 +84,109 @@ pub fn tool_calling_native_input_examples_enabled() -> bool { false } +fn metadata_extension(schema: &Value) -> &Value { + schema + .get("x-lime") + .or_else(|| schema.get("x_lime")) + .unwrap_or(schema) +} + +fn metadata_read_bool(schema: &Value, key: &str, camel_key: &str) -> Option { + metadata_extension(schema) + .get(key) + .or_else(|| metadata_extension(schema).get(camel_key)) + .and_then(|value| value.as_bool()) +} + +fn metadata_read_string_vec(schema: &Value, key: &str, camel_key: &str) -> Option> { + let values = metadata_extension(schema) + .get(key) + .or_else(|| metadata_extension(schema).get(camel_key)) + .and_then(|value| value.as_array()) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str()) + .map(|item| item.trim().to_ascii_lowercase()) + .filter(|item| !item.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + + (!values.is_empty()).then_some(values) +} + +pub fn normalize_tool_caller(caller: Option<&str>) -> Option { + caller + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()) +} + +pub fn extract_tool_surface_metadata(tool_name: &str, schema: &Value) -> ToolSurfaceMetadata { + ToolSurfaceMetadata { + deferred_loading: metadata_read_bool(schema, "deferred_loading", "deferredLoading"), + always_visible: metadata_read_bool(schema, "always_visible", "alwaysVisible"), + allowed_callers: metadata_read_string_vec(schema, "allowed_callers", "allowedCallers"), + tags: metadata_read_string_vec(schema, "tags", "tags"), + input_examples: resolve_tool_input_examples(tool_name, schema), + } +} + +pub fn tool_visible_in_context(metadata: &ToolSurfaceMetadata, include_deferred: bool) -> bool { + if include_deferred { + return true; + } + + let deferred_loading = metadata.deferred_loading.unwrap_or(false); + let always_visible = metadata.always_visible.unwrap_or(false); + !deferred_loading || always_visible +} + +pub fn tool_matches_caller(metadata: &ToolSurfaceMetadata, caller: Option<&str>) -> bool { + let Some(allowed_callers) = metadata.allowed_callers.as_ref() else { + return true; + }; + let Some(caller) = normalize_tool_caller(caller) else { + return true; + }; + + allowed_callers.iter().any(|item| item == &caller) +} + +pub fn score_tool_match(name: &str, description: &str, tags: &[String], query: &str) -> i32 { + let query = query.trim().to_ascii_lowercase(); + if query.is_empty() { + return 1; + } + + let name_lc = name.to_ascii_lowercase(); + let description_lc = description.to_ascii_lowercase(); + let mut score = 0; + + if name_lc == query { + score += 120; + } else if name_lc.starts_with(&query) { + score += 90; + } else if name_lc.contains(&query) { + score += 70; + } + + if description_lc.contains(&query) { + score += 40; + } + + for tag in tags { + if tag == &query { + score += 35; + } else if tag.contains(&query) { + score += 20; + } + } + + score +} + fn schema_read_examples(schema: &Value) -> Vec { let extension = schema .get("x-lime") @@ -308,4 +426,66 @@ mod tests { let examples = resolve_tool_input_examples("docs_search", &schema); assert!(examples.is_empty()); } + + #[test] + fn test_extract_tool_surface_metadata_reads_extension_fields() { + let schema = serde_json::json!({ + "x-lime": { + "deferred_loading": true, + "always_visible": false, + "allowed_callers": ["assistant", "code_execution"], + "input_examples": [{"query":"rust"}], + "tags": ["docs", "search"] + } + }); + + let metadata = extract_tool_surface_metadata("docs_search", &schema); + assert_eq!(metadata.deferred_loading, Some(true)); + assert_eq!(metadata.always_visible, Some(false)); + assert_eq!( + metadata.allowed_callers, + Some(vec!["assistant".to_string(), "code_execution".to_string()]) + ); + assert_eq!( + metadata.tags, + Some(vec!["docs".to_string(), "search".to_string()]) + ); + assert_eq!( + metadata.input_examples, + vec![serde_json::json!({"query":"rust"})] + ); + } + + #[test] + fn test_tool_visibility_and_caller_match_follow_metadata() { + let metadata = ToolSurfaceMetadata { + deferred_loading: Some(true), + always_visible: Some(false), + allowed_callers: Some(vec!["assistant".to_string()]), + tags: None, + input_examples: Vec::new(), + }; + + assert!(!tool_visible_in_context(&metadata, false)); + assert!(tool_visible_in_context(&metadata, true)); + assert!(tool_matches_caller(&metadata, Some("assistant"))); + assert!(!tool_matches_caller(&metadata, Some("code_execution"))); + } + + #[test] + fn test_score_tool_match_prefers_exact_name() { + let exact = score_tool_match( + "tool_search", + "Search tool surfaces", + &["search".to_string()], + "tool_search", + ); + let partial = score_tool_match( + "tool_lookup", + "Search tool surfaces", + &["search".to_string()], + "tool_search", + ); + assert!(exact > partial); + } } diff --git a/src-tauri/crates/mcp/src/client.rs b/src-tauri/crates/mcp/src/client.rs index 2c5387796..b13a78eed 100644 --- a/src-tauri/crates/mcp/src/client.rs +++ b/src-tauri/crates/mcp/src/client.rs @@ -183,7 +183,8 @@ pub struct McpClientWrapper { pub process: Option, pub server_info: Option, pub client_handler: Arc, - pub running_service: Option>, + pub running_service: + Option>>, } impl McpClientWrapper { @@ -220,15 +221,21 @@ impl McpClientWrapper { &mut self, service: rmcp::service::RunningService, ) { - self.running_service = Some(service); + self.running_service = Some(Arc::new(service)); } pub fn running_service( &self, - ) -> Option<&rmcp::service::RunningService> { + ) -> Option<&Arc>> { self.running_service.as_ref() } + pub fn running_service_arc( + &self, + ) -> Option>> { + self.running_service.clone() + } + pub async fn kill_process(&mut self) -> Result<(), std::io::Error> { if let Some(ref mut process) = self.process { process.kill().await?; diff --git a/src-tauri/crates/mcp/src/manager.rs b/src-tauri/crates/mcp/src/manager.rs index 221c0be55..11ebdea39 100644 --- a/src-tauri/crates/mcp/src/manager.rs +++ b/src-tauri/crates/mcp/src/manager.rs @@ -26,7 +26,7 @@ #![allow(dead_code)] -use lime_core::DynEmitter; +use lime_core::{tool_calling::ToolSurfaceMetadata, DynEmitter}; use std::collections::{HashMap, HashSet}; use std::process::Stdio; use std::sync::Arc; @@ -42,14 +42,7 @@ use rmcp::ServiceExt; use crate::client::McpClientWrapper; use crate::types::*; -#[derive(Debug, Default)] -struct ToolMetadataExtraction { - deferred_loading: Option, - always_visible: Option, - allowed_callers: Option>, - input_examples: Option>, - tags: Option>, -} +const AUTO_DEFER_TOOL_COUNT_THRESHOLD: usize = 6; /// MCP 客户端管理器 /// @@ -730,7 +723,8 @@ impl McpClientManager { ); for tool in tools { let input_schema = serde_json::Value::Object((*tool.input_schema).clone()); - let metadata = Self::extract_tool_metadata(&input_schema); + let metadata = + Self::extract_tool_metadata(tool.name.as_ref(), &input_schema); all_tools.push(McpToolDefinition { name: tool.name.to_string(), description: tool @@ -743,7 +737,8 @@ impl McpClientManager { deferred_loading: metadata.deferred_loading, always_visible: metadata.always_visible, allowed_callers: metadata.allowed_callers, - input_examples: metadata.input_examples, + input_examples: (!metadata.input_examples.is_empty()) + .then_some(metadata.input_examples), tags: metadata.tags, }); } @@ -761,7 +756,8 @@ impl McpClientManager { drop(clients); // 3. 解决名称冲突(添加服务器前缀) - let resolved_tools = Self::resolve_tool_name_conflicts(all_tools); + let resolved_tools = + Self::apply_default_loading_policy(Self::resolve_tool_name_conflicts(all_tools)); // 4. 更新缓存 self.update_tool_cache(resolved_tools.clone()).await; @@ -782,36 +778,23 @@ impl McpClientManager { caller: Option<&str>, include_deferred: bool, ) -> Result, McpError> { - let caller = caller - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.to_ascii_lowercase()); let tools = self.list_tools().await?; let filtered = tools .into_iter() .filter(|tool| { - // deferred_loading=true 且不是 always_visible 时,默认不注入上下文 - if !include_deferred - && tool.deferred_loading.unwrap_or(false) - && !tool.always_visible.unwrap_or(false) - { + let metadata = ToolSurfaceMetadata { + deferred_loading: tool.deferred_loading, + always_visible: tool.always_visible, + allowed_callers: tool.allowed_callers.clone(), + tags: tool.tags.clone(), + input_examples: tool.input_examples.clone().unwrap_or_default(), + }; + + if !lime_core::tool_calling::tool_visible_in_context(&metadata, include_deferred) { return false; } - - // caller 不在 allowed_callers 时,隐藏该工具 - if let (Some(caller), Some(allowed)) = (&caller, tool.allowed_callers.as_ref()) { - let allowed_set: HashSet = allowed - .iter() - .map(|v| v.trim().to_ascii_lowercase()) - .filter(|v| !v.is_empty()) - .collect(); - if !allowed_set.is_empty() && !allowed_set.contains(caller) { - return false; - } - } - - true + lime_core::tool_calling::tool_matches_caller(&metadata, caller) }) .collect(); @@ -867,85 +850,53 @@ impl McpClientManager { Ok(result) } - fn extract_tool_metadata(input_schema: &serde_json::Value) -> ToolMetadataExtraction { - fn read_bool(root: &serde_json::Value, key: &str) -> Option { - root.get(key).and_then(|v| v.as_bool()) - } - - fn read_string_vec(root: &serde_json::Value, key: &str) -> Option> { - let arr = root.get(key)?.as_array()?; - let values = arr - .iter() - .filter_map(|v| v.as_str()) - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - .collect::>(); - (!values.is_empty()).then_some(values) - } - - fn read_examples(root: &serde_json::Value, key: &str) -> Option> { - let arr = root.get(key)?.as_array()?; - let values = arr - .iter() - .filter(|v| !v.is_null()) - .cloned() - .collect::>(); - (!values.is_empty()).then_some(values) - } - - let extension = input_schema - .get("x-lime") - .or_else(|| input_schema.get("x_lime")) - .unwrap_or(input_schema); - - ToolMetadataExtraction { - deferred_loading: read_bool(extension, "deferred_loading") - .or_else(|| read_bool(extension, "deferredLoading")), - always_visible: read_bool(extension, "always_visible") - .or_else(|| read_bool(extension, "alwaysVisible")), - allowed_callers: read_string_vec(extension, "allowed_callers") - .or_else(|| read_string_vec(extension, "allowedCallers")), - input_examples: read_examples(extension, "input_examples") - .or_else(|| read_examples(extension, "inputExamples")), - tags: read_string_vec(extension, "tags"), - } + fn extract_tool_metadata( + tool_name: &str, + input_schema: &serde_json::Value, + ) -> ToolSurfaceMetadata { + lime_core::tool_calling::extract_tool_surface_metadata(tool_name, input_schema) } fn score_tool_match(tool: &McpToolDefinition, query: &str) -> i32 { - let name = tool.name.to_ascii_lowercase(); - let description = tool.description.to_ascii_lowercase(); - - let mut score = 0; - if name == query { - score += 120; - } else if name.starts_with(query) { - score += 90; - } else if name.contains(query) { - score += 70; - } - - if description.contains(query) { - score += 40; - } - - if let Some(tags) = tool.tags.as_ref() { - for tag in tags { - let tag = tag.to_ascii_lowercase(); - if tag == query { - score += 35; - } else if tag.contains(query) { - score += 20; - } - } - } + let score = lime_core::tool_calling::score_tool_match( + &tool.name, + &tool.description, + tool.tags.as_deref().unwrap_or(&[]), + query, + ); if tool.always_visible.unwrap_or(false) { - score += 5; + return score + 5; } score } + fn apply_default_loading_policy(tools: Vec) -> Vec { + let mut server_tool_counts: HashMap = HashMap::new(); + for tool in &tools { + *server_tool_counts + .entry(tool.server_name.clone()) + .or_insert(0) += 1; + } + + tools + .into_iter() + .map(|mut tool| { + if tool.deferred_loading.is_none() { + let should_auto_defer = server_tool_counts + .get(&tool.server_name) + .copied() + .unwrap_or_default() + > AUTO_DEFER_TOOL_COUNT_THRESHOLD + && !tool.always_visible.unwrap_or(false); + tool.deferred_loading = Some(should_auto_defer); + } + tool + }) + .collect() + } + /// 解决工具名称冲突 /// /// 当多个服务器提供同名工具时,为冲突的工具名称添加服务器前缀。 @@ -1975,14 +1926,14 @@ mod tests { "tags": ["search", "docs"] } }); - let meta = McpClientManager::extract_tool_metadata(&schema); + let meta = McpClientManager::extract_tool_metadata("docs_search", &schema); assert_eq!(meta.deferred_loading, Some(true)); assert_eq!(meta.always_visible, Some(false)); assert_eq!( meta.allowed_callers.unwrap_or_default(), vec!["assistant".to_string(), "code_execution".to_string()] ); - assert_eq!(meta.input_examples.unwrap_or_default().len(), 1); + assert_eq!(meta.input_examples.len(), 1); assert_eq!( meta.tags.unwrap_or_default(), vec!["search".to_string(), "docs".to_string()] @@ -2189,6 +2140,160 @@ mod tests { assert!(resolved.is_empty()); } + #[test] + fn test_apply_default_loading_policy_auto_defers_large_server_tools() { + let tools = (0..7) + .map(|index| McpToolDefinition { + name: format!("tool_{index}"), + description: format!("tool {index}"), + input_schema: serde_json::json!({}), + server_name: "large-server".to_string(), + deferred_loading: None, + always_visible: if index == 0 { Some(true) } else { None }, + allowed_callers: None, + input_examples: None, + tags: None, + }) + .collect::>(); + + let resolved = McpClientManager::apply_default_loading_policy(tools); + assert_eq!(resolved.len(), 7); + assert_eq!(resolved[0].deferred_loading, Some(false)); + assert!(resolved + .iter() + .skip(1) + .all(|tool| tool.deferred_loading == Some(true))); + } + + #[test] + fn test_resolve_tool_name_conflicts_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); + tool_a.allowed_callers = Some(vec!["assistant".to_string()]); + tool_a.tags = Some(vec!["docs".to_string()]); + tool_a.input_examples = Some(vec![serde_json::json!({ "query": "rust" })]); + + let mut tool_b = create_test_tool("search", "Search issues", "server2"); + tool_b.deferred_loading = Some(false); + tool_b.always_visible = Some(false); + tool_b.allowed_callers = Some(vec!["code_execution".to_string()]); + 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 server1 = resolved + .iter() + .find(|tool| tool.name == "server1_search") + .expect("server1 tool should be renamed"); + let server2 = resolved + .iter() + .find(|tool| tool.name == "server2_search") + .expect("server2 tool should be renamed"); + + assert_eq!(server1.deferred_loading, Some(true)); + assert_eq!(server1.always_visible, Some(true)); + assert_eq!(server1.allowed_callers, Some(vec!["assistant".to_string()])); + assert_eq!(server1.tags, Some(vec!["docs".to_string()])); + assert_eq!( + server1.input_examples, + Some(vec![serde_json::json!({ "query": "rust" })]) + ); + + assert_eq!(server2.deferred_loading, Some(false)); + assert_eq!(server2.always_visible, Some(false)); + assert_eq!( + server2.allowed_callers, + Some(vec!["code_execution".to_string()]) + ); + assert_eq!(server2.tags, Some(vec!["issues".to_string()])); + assert_eq!( + server2.input_examples, + Some(vec![serde_json::json!({ "query": "bug" })]) + ); + } + + #[test] + fn test_apply_default_loading_policy_respects_threshold_boundary_and_explicit_values() { + let threshold_tools = (0..AUTO_DEFER_TOOL_COUNT_THRESHOLD) + .map(|index| McpToolDefinition { + name: format!("threshold_{index}"), + description: format!("threshold {index}"), + input_schema: serde_json::json!({}), + server_name: "threshold-server".to_string(), + deferred_loading: None, + always_visible: None, + allowed_callers: None, + input_examples: None, + tags: None, + }) + .collect::>(); + + let mut large_tools = (0..5) + .map(|index| McpToolDefinition { + name: format!("auto_{index}"), + description: format!("auto {index}"), + input_schema: serde_json::json!({}), + server_name: "large-server".to_string(), + deferred_loading: None, + always_visible: None, + allowed_callers: None, + input_examples: None, + tags: None, + }) + .collect::>(); + + large_tools.push(McpToolDefinition { + name: "explicit_false".to_string(), + description: "explicit false".to_string(), + input_schema: serde_json::json!({}), + server_name: "large-server".to_string(), + deferred_loading: Some(false), + always_visible: None, + allowed_callers: None, + input_examples: None, + tags: None, + }); + large_tools.push(McpToolDefinition { + name: "explicit_true".to_string(), + description: "explicit true".to_string(), + input_schema: serde_json::json!({}), + server_name: "large-server".to_string(), + deferred_loading: Some(true), + always_visible: None, + allowed_callers: None, + input_examples: None, + tags: None, + }); + + let mut all_tools = threshold_tools; + all_tools.extend(large_tools); + + let resolved = McpClientManager::apply_default_loading_policy(all_tools); + + assert!(resolved + .iter() + .filter(|tool| tool.server_name == "threshold-server") + .all(|tool| tool.deferred_loading == Some(false))); + + let explicit_false = resolved + .iter() + .find(|tool| tool.name == "explicit_false") + .expect("explicit false tool should exist"); + assert_eq!(explicit_false.deferred_loading, Some(false)); + + let explicit_true = resolved + .iter() + .find(|tool| tool.name == "explicit_true") + .expect("explicit true tool should exist"); + assert_eq!(explicit_true.deferred_loading, Some(true)); + + assert!(resolved + .iter() + .filter(|tool| tool.server_name == "large-server" && tool.name.starts_with("auto_")) + .all(|tool| tool.deferred_loading == Some(true))); + } + // ======================================================================== // 工具列表缓存测试(Task 4.3) // ======================================================================== @@ -2295,6 +2400,62 @@ mod tests { assert_eq!(tools[0].name, "weather"); } + #[tokio::test] + async fn test_search_tools_empty_query_prioritizes_always_visible_then_name() { + let manager = McpClientManager::new(None); + manager + .update_tool_cache(vec![ + McpToolDefinition { + name: "alpha".to_string(), + description: "alpha".to_string(), + input_schema: serde_json::json!({}), + server_name: "s1".to_string(), + deferred_loading: Some(false), + always_visible: Some(false), + allowed_callers: None, + input_examples: None, + tags: None, + }, + McpToolDefinition { + name: "zeta".to_string(), + description: "zeta".to_string(), + input_schema: serde_json::json!({}), + server_name: "s1".to_string(), + deferred_loading: Some(true), + always_visible: Some(true), + allowed_callers: None, + input_examples: None, + tags: None, + }, + McpToolDefinition { + name: "beta".to_string(), + description: "beta".to_string(), + input_schema: serde_json::json!({}), + server_name: "s1".to_string(), + deferred_loading: Some(false), + always_visible: Some(true), + allowed_callers: None, + input_examples: None, + tags: None, + }, + ]) + .await; + + let tools = manager + .search_tools("", 2, Some("assistant")) + .await + .expect("empty query search should succeed"); + + assert_eq!(tools.len(), 2); + assert_eq!( + tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + vec!["beta", "zeta"] + ); + } + #[tokio::test] async fn test_call_tool_with_caller_rejects_unauthorized_caller() { let manager = McpClientManager::new(None); diff --git a/src-tauri/crates/providers/src/converter/reasoning_handler.rs b/src-tauri/crates/providers/src/converter/reasoning_handler.rs index 2437700d2..136aa4f89 100644 --- a/src-tauri/crates/providers/src/converter/reasoning_handler.rs +++ b/src-tauri/crates/providers/src/converter/reasoning_handler.rs @@ -6,7 +6,7 @@ //! //! | 模型 | 字段名 | 多轮对话处理 | //! |------|--------|--------------| -//! | DeepSeek R1/Reasoner | `reasoning_content` | 丢弃,只保留 `content` | +//! | DeepSeek R1/Reasoner | `reasoning_content` | 新 user 回合前清空,当前回合保留 | //! | OpenAI o1/o3/o4 | `reasoning` | 通过 `previous_response_id` 引用 | //! //! # 设计原则 @@ -18,11 +18,8 @@ //! //! # 使用状态 //! -//! 此模块为预留功能,将在 Proxy 层集成推理模型时启用。 -//! 目前代码已完成,等待在 `proxy_handler.rs` 中调用 `ReasoningHandler::preprocess_messages`。 - -// 预留功能模块,暂未在主流程中调用 -#![allow(dead_code)] +//! 当前已在 OpenAI 兼容 Provider 请求归一化阶段接入。 +//! 主要用于 DeepSeek R1/Reasoner 的 tool calls + thinking 场景。 use lime_core::models::openai::ChatMessage; @@ -66,9 +63,9 @@ impl ReasoningHandler { /// # DeepSeek 处理规则 /// /// 根据 DeepSeek API 文档: - /// - 多轮对话时,历史消息中的 `reasoning_content` 应该被丢弃 - /// - 只保留 `content` 字段用于上下文 - /// - 这样可以节省网络带宽,避免 400 错误 + /// - 新 user 回合开始后,上一轮 assistant 的 `reasoning_content` 应被清理 + /// - 同一 user 回合中的 tool call 链路需要保留 assistant 的 `reasoning_content` + /// - 否则 DeepSeek Reasoner 在继续 tool calls 时可能返回 400 错误 /// /// # 参数 /// @@ -90,28 +87,22 @@ impl ReasoningHandler { /// 处理 DeepSeek 消息 /// - /// 清除历史消息中的 reasoning_content,只保留最后一条 assistant 消息的 reasoning_content + /// 清除最近一个 user 消息之前的 assistant reasoning_content, + /// 保留当前 user 回合内的 reasoning_content,以支持连续 tool calls。 fn process_deepseek_messages(mut messages: Vec) -> Vec { - // 先找出最后一条 assistant 消息的索引 - let last_assistant_idx = messages + let last_user_idx = messages .iter() .enumerate() .rev() - .find(|(_, m)| m.role == "assistant") + .find(|(_, m)| m.role == "user") .map(|(i, _)| i); for (i, msg) in messages.iter_mut().enumerate() { - // 只处理 assistant 消息 - if msg.role != "assistant" { + if msg.role != "assistant" || msg.reasoning_content.is_none() { continue; } - // 保留最后一条 assistant 消息的 reasoning_content(如果有 tool_calls) - // 因为 DeepSeek 在 tool calls 场景下需要这个字段 - let is_last_assistant = Some(i) == last_assistant_idx; - - if !is_last_assistant { - // 清除非最后一条 assistant 消息的 reasoning_content + if last_user_idx.is_some_and(|idx| i < idx) { msg.reasoning_content = None; } } @@ -221,4 +212,49 @@ mod tests { // 最后一条 assistant 消息的 reasoning_content 应该保留 assert!(processed[3].reasoning_content.is_some()); } + + #[test] + fn test_deepseek_keeps_reasoning_within_same_user_tool_chain() { + let messages = vec![ + ChatMessage { + role: "user".to_string(), + content: Some(MessageContent::Text("帮我查天气".to_string())), + tool_calls: None, + tool_call_id: None, + reasoning_content: None, + }, + ChatMessage { + role: "assistant".to_string(), + content: Some(MessageContent::Text(String::new())), + tool_calls: None, + tool_call_id: None, + reasoning_content: Some("先确定城市".to_string()), + }, + ChatMessage { + role: "tool".to_string(), + content: Some(MessageContent::Text("上海".to_string())), + tool_calls: None, + tool_call_id: Some("call_1".to_string()), + reasoning_content: None, + }, + ChatMessage { + role: "assistant".to_string(), + content: Some(MessageContent::Text(String::new())), + tool_calls: None, + tool_call_id: None, + reasoning_content: Some("继续查询具体天气".to_string()), + }, + ]; + + let processed = ReasoningHandler::preprocess_messages(messages, "deepseek-reasoner"); + + assert_eq!( + processed[1].reasoning_content.as_deref(), + Some("先确定城市") + ); + assert_eq!( + processed[3].reasoning_content.as_deref(), + Some("继续查询具体天气") + ); + } } diff --git a/src-tauri/crates/providers/src/providers/antigravity.rs b/src-tauri/crates/providers/src/providers/antigravity.rs index df3a9b572..f609dac87 100644 --- a/src-tauri/crates/providers/src/providers/antigravity.rs +++ b/src-tauri/crates/providers/src/providers/antigravity.rs @@ -2313,7 +2313,7 @@ mod tests { if expires_in_secs <= -2 { prop_assert!(is_expired(&result), "Expected Expired for expires_in_secs={}", expires_in_secs); - } else if expires_in_secs >= 2 && expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD - 2 { + } else if (2..=TOKEN_EXPIRING_SOON_THRESHOLD - 2).contains(&expires_in_secs) { prop_assert!(is_expiring_soon(&result), "Expected ExpiringSoon for expires_in_secs={}", expires_in_secs); } else if expires_in_secs > TOKEN_EXPIRING_SOON_THRESHOLD + 2 { prop_assert!(is_valid(&result), "Expected Valid for expires_in_secs={}", expires_in_secs); @@ -2338,7 +2338,7 @@ mod tests { if expires_in_secs <= -2 { prop_assert!(is_expired(&result), "Expected Expired for expires_in_secs={}", expires_in_secs); - } else if expires_in_secs >= 2 && expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD - 2 { + } else if (2..=TOKEN_EXPIRING_SOON_THRESHOLD - 2).contains(&expires_in_secs) { prop_assert!(is_expiring_soon(&result), "Expected ExpiringSoon for expires_in_secs={}", expires_in_secs); } else if expires_in_secs > TOKEN_EXPIRING_SOON_THRESHOLD + 2 { prop_assert!(is_valid(&result), "Expected Valid for expires_in_secs={}", expires_in_secs); @@ -2364,7 +2364,7 @@ mod tests { // 由于时间精度问题,允许 2 秒的误差 if expires_in_secs <= 1 { prop_assert!(is_expired(&result) || is_expiring_soon(&result), "Expected Expired or ExpiringSoon for expires_in_secs={}", expires_in_secs); - } else if expires_in_secs >= 2 && expires_in_secs <= TOKEN_EXPIRING_SOON_THRESHOLD - 2 { + } else if (2..=TOKEN_EXPIRING_SOON_THRESHOLD - 2).contains(&expires_in_secs) { prop_assert!(is_expiring_soon(&result), "Expected ExpiringSoon for expires_in_secs={}", expires_in_secs); } else if expires_in_secs > TOKEN_EXPIRING_SOON_THRESHOLD + 2 { prop_assert!(is_valid(&result), "Expected Valid for expires_in_secs={}", expires_in_secs); diff --git a/src-tauri/crates/providers/src/providers/claude_custom.rs b/src-tauri/crates/providers/src/providers/claude_custom.rs index 007269ecc..8105d414b 100644 --- a/src-tauri/crates/providers/src/providers/claude_custom.rs +++ b/src-tauri/crates/providers/src/providers/claude_custom.rs @@ -140,35 +140,12 @@ impl ClaudeCustomProvider { .parameters .clone() .unwrap_or_else(|| serde_json::json!({"type":"object","properties":{}})); - let extension = input_schema - .get("x-lime") - .or_else(|| input_schema.get("x_lime")) - .cloned() - .unwrap_or_else(|| serde_json::json!({})); - let mut input_examples = extension - .get("input_examples") - .or_else(|| extension.get("inputExamples")) - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - if input_examples.is_empty() { - input_examples = lime_core::tool_calling::resolve_tool_input_examples( - &function.name, - &input_schema, - ); - } - let allowed_callers = extension - .get("allowed_callers") - .or_else(|| extension.get("allowedCallers")) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - .collect::>() - }) - .unwrap_or_default(); + let metadata = lime_core::tool_calling::extract_tool_surface_metadata( + &function.name, + &input_schema, + ); + let input_examples = metadata.input_examples; + let allowed_callers = metadata.allowed_callers.unwrap_or_default(); let mut description = function.description.clone().unwrap_or_default(); if !input_examples.is_empty() && !description.contains("[InputExamples]") { @@ -843,4 +820,75 @@ mod tests { .map(|arr| !arr.is_empty()) .unwrap_or(false)); } + + #[test] + fn test_convert_openai_tool_to_anthropic_supports_x_lime_alias() { + let tool = Tool::Function { + function: FunctionDef { + name: "create_ticket".to_string(), + description: Some("Create support ticket".to_string()), + parameters: Some(serde_json::json!({ + "type": "object", + "properties": { + "title": {"type": "string"} + }, + "x_lime": { + "inputExamples": [{"title":"Billing issue"}], + "allowedCallers": ["tool_search"] + } + })), + }, + }; + + let converted = ClaudeCustomProvider::convert_openai_tool_to_anthropic(&tool) + .expect("tool should be converted"); + let description = converted + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + assert!(description.contains("[InputExamples]")); + assert!(description.contains("[AllowedCallers]")); + assert_eq!( + converted["input_examples"], + serde_json::json!([{"title":"Billing issue"}]) + ); + assert_eq!( + converted["allowed_callers"], + serde_json::json!(["tool_search"]) + ); + } + + #[test] + fn test_convert_openai_tool_to_anthropic_does_not_duplicate_markers() { + let tool = Tool::Function { + function: FunctionDef { + name: "create_ticket".to_string(), + description: Some( + "Create support ticket\n\n[InputExamples] {\"title\":\"Preset\"}\n\n[AllowedCallers] assistant" + .to_string(), + ), + parameters: Some(serde_json::json!({ + "type": "object", + "properties": { + "title": {"type": "string"} + }, + "x-lime": { + "input_examples": [{"title":"Billing issue"}], + "allowed_callers": ["assistant"] + } + })), + }, + }; + + let converted = ClaudeCustomProvider::convert_openai_tool_to_anthropic(&tool) + .expect("tool should be converted"); + let description = converted + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + assert_eq!(description.matches("[InputExamples]").count(), 1); + assert_eq!(description.matches("[AllowedCallers]").count(), 1); + } } diff --git a/src-tauri/crates/providers/src/providers/gemini.rs b/src-tauri/crates/providers/src/providers/gemini.rs index 62692d08a..2141ec1ba 100644 --- a/src-tauri/crates/providers/src/providers/gemini.rs +++ b/src-tauri/crates/providers/src/providers/gemini.rs @@ -752,10 +752,7 @@ mod gemini_api_key_tests { #[test] fn test_gemini_api_key_provider_new() { - let provider = GeminiApiKeyProvider::new(); - // Just verify it can be created - assert!(true); - let _ = provider; + let _provider = GeminiApiKeyProvider::new(); } } diff --git a/src-tauri/crates/providers/src/providers/novita.rs b/src-tauri/crates/providers/src/providers/novita.rs index ba05604ff..ae83b1c8e 100644 --- a/src-tauri/crates/providers/src/providers/novita.rs +++ b/src-tauri/crates/providers/src/providers/novita.rs @@ -142,13 +142,10 @@ impl NovitaProvider { let url = self.build_url("chat/completions"); - eprintln!( - "[NOVITA] call_api URL: {url} model: {}", - request.model - ); + eprintln!("[NOVITA] call_api URL: {url} model: {}", request.model); - let payload = serde_json::to_value(request) - .map_err(|e| format!("序列化 Novita 请求失败: {e}"))?; + let payload = + serde_json::to_value(request).map_err(|e| format!("序列化 Novita 请求失败: {e}"))?; let resp = self .client @@ -367,10 +364,7 @@ mod tests { Some("https://proxy.example.com/novita/v1".to_string()), ); let url = provider.build_url("chat/completions"); - assert_eq!( - url, - "https://proxy.example.com/novita/v1/chat/completions" - ); + assert_eq!(url, "https://proxy.example.com/novita/v1/chat/completions"); } #[test] diff --git a/src-tauri/crates/providers/src/providers/openai_custom.rs b/src-tauri/crates/providers/src/providers/openai_custom.rs index cb4e159b2..8accc6db9 100644 --- a/src-tauri/crates/providers/src/providers/openai_custom.rs +++ b/src-tauri/crates/providers/src/providers/openai_custom.rs @@ -1,5 +1,6 @@ //! OpenAI Custom Provider (自定义 OpenAI 兼容 API) -use lime_core::models::openai::ChatCompletionRequest; +use crate::converter::ReasoningHandler; +use lime_core::models::openai::{ChatCompletionRequest, ChatMessage}; use reqwest::Client; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; @@ -51,6 +52,23 @@ impl OpenAICustomProvider { } fn normalize_openai_request_payload(&self, payload: &mut serde_json::Value) { + let model_name = payload + .get("model") + .and_then(|value| value.as_str()) + .map(str::to_owned); + + if let (Some(model_name), Some(messages_value)) = (model_name, payload.get_mut("messages")) + { + if let Ok(messages) = serde_json::from_value::>(messages_value.clone()) + { + if let Ok(normalized_messages) = serde_json::to_value( + ReasoningHandler::preprocess_messages(messages, &model_name), + ) { + *messages_value = normalized_messages; + } + } + } + if !Self::tool_calling_v2_enabled() { return; } @@ -76,43 +94,15 @@ impl OpenAICustomProvider { .get("parameters") .cloned() .unwrap_or_else(|| serde_json::json!({})); - let extension = parameters - .get("x-lime") - .or_else(|| parameters.get("x_lime")) - .cloned() - .unwrap_or_else(|| serde_json::json!({})); - - let mut input_examples = extension - .get("input_examples") - .or_else(|| extension.get("inputExamples")) - .and_then(|v| v.as_array()) - .cloned() + let tool_name = function + .get("name") + .and_then(|v| v.as_str()) .unwrap_or_default(); - if input_examples.is_empty() { - let tool_name = function - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - input_examples = - lime_core::tool_calling::resolve_tool_input_examples(tool_name, ¶meters); - } - let allowed_callers = extension - .get("allowed_callers") - .or_else(|| extension.get("allowedCallers")) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - .collect::>() - }) - .unwrap_or_default(); - let deferred_loading = extension - .get("deferred_loading") - .or_else(|| extension.get("deferredLoading")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + let metadata = + lime_core::tool_calling::extract_tool_surface_metadata(tool_name, ¶meters); + let input_examples = metadata.input_examples; + let allowed_callers = metadata.allowed_callers.unwrap_or_default(); + let deferred_loading = metadata.deferred_loading.unwrap_or(false); let description = function .get("description") @@ -778,6 +768,116 @@ mod tests { assert!(description.contains("[InputExamples]")); } + #[test] + fn test_normalize_openai_request_payload_does_not_duplicate_existing_metadata_markers() { + let provider = OpenAICustomProvider::default(); + let mut payload = serde_json::json!({ + "model": "deepseek-chat", + "messages": [{"role":"user","content":"hi"}], + "tools": [{ + "type":"function", + "function": { + "name":"search_docs", + "description":"Search docs\n\n[InputExamples] {\"query\":\"preset\"}\n\n[AllowedCallers] assistant\n\n[DeferredLoading] true", + "parameters": { + "type":"object", + "properties":{"query":{"type":"string"}}, + "x_lime": { + "inputExamples":[{"query":"tool search"}], + "allowedCallers":["assistant"], + "deferredLoading": true + } + } + } + }] + }); + + provider.normalize_openai_request_payload(&mut payload); + let description = payload["tools"][0]["function"]["description"] + .as_str() + .unwrap_or_default(); + + assert_eq!(description.matches("[InputExamples]").count(), 1); + assert_eq!(description.matches("[AllowedCallers]").count(), 1); + assert_eq!(description.matches("[DeferredLoading]").count(), 1); + } + + #[test] + fn test_normalize_openai_request_payload_keeps_reasoning_within_same_user_turn() { + let provider = OpenAICustomProvider::default(); + let mut payload = serde_json::json!({ + "model": "deepseek-reasoner", + "messages": [ + { + "role":"user", + "content":"帮我查天气" + }, + { + "role":"assistant", + "content":"", + "reasoning_content":"先确定城市" + }, + { + "role":"tool", + "content":"上海", + "tool_call_id":"call_1" + }, + { + "role":"assistant", + "content":"", + "reasoning_content":"继续查询具体天气" + } + ] + }); + + provider.normalize_openai_request_payload(&mut payload); + + assert_eq!( + payload["messages"][1]["reasoning_content"], + serde_json::json!("先确定城市") + ); + assert_eq!( + payload["messages"][3]["reasoning_content"], + serde_json::json!("继续查询具体天气") + ); + } + + #[test] + fn test_normalize_openai_request_payload_clears_reasoning_before_latest_user() { + let provider = OpenAICustomProvider::default(); + let mut payload = serde_json::json!({ + "model": "deepseek-reasoner", + "messages": [ + { + "role":"user", + "content":"第一轮" + }, + { + "role":"assistant", + "content":"需要工具", + "reasoning_content":"第一轮思考" + }, + { + "role":"user", + "content":"第二轮" + }, + { + "role":"assistant", + "content":"继续处理", + "reasoning_content":"第二轮思考" + } + ] + }); + + provider.normalize_openai_request_payload(&mut payload); + + assert!(payload["messages"][1].get("reasoning_content").is_none()); + assert_eq!( + payload["messages"][3]["reasoning_content"], + serde_json::json!("第二轮思考") + ); + } + #[test] fn test_build_urls_with_fallbacks_supports_nested_proxy_path() { let provider = OpenAICustomProvider::with_config( diff --git a/src-tauri/crates/providers/src/providers/tests.rs b/src-tauri/crates/providers/src/providers/tests.rs index 628c71e72..dbfb6fd7f 100644 --- a/src-tauri/crates/providers/src/providers/tests.rs +++ b/src-tauri/crates/providers/src/providers/tests.rs @@ -13,12 +13,6 @@ fn arb_lead_time_mins() -> impl Strategy { 1i64..30i64 } -/// Generate a random offset from now in seconds (-3600 to +7200) -/// Negative means past, positive means future -fn arb_time_offset_secs() -> impl Strategy { - -3600i64..7200i64 -} - /// 生成不会与 lead_time 边界冲突的时间偏移 /// 避免 time_offset_secs 恰好等于 lead_time_mins * 60 的情况 #[allow(dead_code)] diff --git a/src-tauri/crates/services/src/session_context_service.rs b/src-tauri/crates/services/src/session_context_service.rs index 5fa355957..e3cadc45f 100644 --- a/src-tauri/crates/services/src/session_context_service.rs +++ b/src-tauri/crates/services/src/session_context_service.rs @@ -599,6 +599,7 @@ mod tests { timestamp TEXT NOT NULL, tool_calls_json TEXT, tool_call_id TEXT, + reasoning_content TEXT, FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE )", [], @@ -618,33 +619,6 @@ mod tests { conn } - fn create_test_messages(session_id: &str, count: usize) -> Vec { - let mut messages = Vec::new(); - let base_time = chrono::Utc::now().timestamp_millis(); - - for i in 0..count { - let role = if i % 2 == 0 { - MessageRole::User - } else { - MessageRole::Assistant - }; - let content = format!("这是第 {} 条消息,包含一些测试内容", i + 1); - - messages.push(ChatMessage { - id: format!("msg-{}", i + 1), - session_id: session_id.to_string(), - role, - content, - blocks: None, - status: "complete".to_string(), - created_at: base_time + i as i64, - metadata: None, - }); - } - - messages - } - fn create_unified_general_session(session_id: &str) -> UnifiedChatSession { let now = chrono::Utc::now().to_rfc3339(); UnifiedChatSession { diff --git a/src-tauri/crates/skills/src/skill_matcher.rs b/src-tauri/crates/skills/src/skill_matcher.rs index db15adbea..a4965fd9e 100644 --- a/src-tauri/crates/skills/src/skill_matcher.rs +++ b/src-tauri/crates/skills/src/skill_matcher.rs @@ -321,7 +321,7 @@ mod tests { let matcher = SkillMatcher::new(skills); let results = matcher.match_skills("review code quality and commit"); // code-review 应该有更高的 confidence(匹配了更多 trigger) - assert!(results.len() >= 1); + assert!(!results.is_empty()); } #[test] diff --git a/src-tauri/crates/terminal/src/tests.rs b/src-tauri/crates/terminal/src/tests.rs index ad3f8bde2..fd7727f89 100644 --- a/src-tauri/crates/terminal/src/tests.rs +++ b/src-tauri/crates/terminal/src/tests.rs @@ -8,7 +8,7 @@ //! - 事件结构序列化 #[cfg(test)] -mod tests { +mod unit_tests { use super::super::error::TerminalError; use super::super::events::{SessionStatus, TerminalOutputEvent, TerminalStatusEvent}; diff --git a/src-tauri/src/agent/README.md b/src-tauri/src/agent/README.md index 8ccca4ff2..bca2f6500 100644 --- a/src-tauri/src/agent/README.md +++ b/src-tauri/src/agent/README.md @@ -101,6 +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_respond_action` | 统一响应工具确认 / ask / elicitation | ## 凭证池桥接 diff --git a/src-tauri/src/agent/mod.rs b/src-tauri/src/agent/mod.rs index c978f1307..3bb7367fc 100644 --- a/src-tauri/src/agent/mod.rs +++ b/src-tauri/src/agent/mod.rs @@ -24,8 +24,10 @@ pub use credential_bridge::{ create_aster_provider, AsterProviderConfig, CredentialBridge, CredentialBridgeError, }; pub use lime_agent::{ - convert_agent_event, convert_to_tauri_message, initialize_aster_runtime, QueuedTurnSnapshot, - QueuedTurnTask, TauriAgentEvent, + convert_agent_event, convert_to_tauri_message, initialize_aster_runtime, + ChildSubagentRuntimeStatus, ChildSubagentSession, QueuedTurnSnapshot, QueuedTurnTask, + SubagentControlState, SubagentParentContext, SubagentRuntimeStatus, SubagentRuntimeStatusKind, + TauriAgentEvent, }; pub use subagent_scheduler::{ LimeScheduler, LimeSubAgentExecutor, SubAgentProgressEvent, SubAgentRole, diff --git a/src-tauri/src/agent/runtime_queue_service.rs b/src-tauri/src/agent/runtime_queue_service.rs index 72ad464ac..efbb51e62 100644 --- a/src-tauri/src/agent/runtime_queue_service.rs +++ b/src-tauri/src/agent/runtime_queue_service.rs @@ -14,6 +14,7 @@ use aster::session::QueuedTurnRuntime; use lime_agent::{ clear_runtime_queue as clear_runtime_queue_impl, list_runtime_queue_snapshots as list_runtime_queue_snapshots_impl, + promote_runtime_queued_turn as promote_runtime_queued_turn_impl, remove_runtime_queued_turn as remove_runtime_queued_turn_impl, resume_persisted_runtime_queues_on_startup as resume_persisted_runtime_queues_on_startup_impl, resume_runtime_queue_if_needed as resume_runtime_queue_if_needed_impl, @@ -178,6 +179,13 @@ pub(crate) async fn remove_runtime_queued_turn( .await } +pub(crate) async fn promote_runtime_queued_turn( + session_id: &str, + queued_turn_id: &str, +) -> Result { + promote_runtime_queued_turn_impl(session_id, queued_turn_id).await +} + pub(crate) async fn resume_persisted_runtime_queues_on_startup( app: AppHandle, state: &AsterAgentState, diff --git a/src-tauri/src/agent_tools/catalog.rs b/src-tauri/src/agent_tools/catalog.rs new file mode 100644 index 000000000..b650329b9 --- /dev/null +++ b/src-tauri/src/agent_tools/catalog.rs @@ -0,0 +1,805 @@ +use crate::mcp::McpToolDefinition; +use serde::{Deserialize, Serialize}; + +pub const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; +pub const SOCIAL_IMAGE_TOOL_NAME: &str = "social_generate_cover_image"; +pub const LIME_CREATE_VIDEO_TASK_TOOL_NAME: &str = "lime_create_video_generation_task"; +pub const LIME_CREATE_BROADCAST_TASK_TOOL_NAME: &str = "lime_create_broadcast_generation_task"; +pub const LIME_CREATE_COVER_TASK_TOOL_NAME: &str = "lime_create_cover_generation_task"; +pub const LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME: &str = + "lime_create_modal_resource_search_task"; +pub const LIME_CREATE_IMAGE_TASK_TOOL_NAME: &str = "lime_create_image_generation_task"; +pub const LIME_CREATE_URL_PARSE_TASK_TOOL_NAME: &str = "lime_create_url_parse_task"; +pub const LIME_CREATE_TYPESETTING_TASK_TOOL_NAME: &str = "lime_create_typesetting_task"; +pub const BROWSER_RUNTIME_TOOL_PREFIX: &str = "mcp__lime-browser__"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolSurfaceProfile { + Core, + Creator, + BrowserAssist, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolCapability { + Planning, + Delegation, + WebSearch, + SkillExecution, + SessionControl, + ContentCreation, + BrowserRuntime, + WorkspaceIo, + Execution, + Vision, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolLifecycle { + Current, + Compat, + Deprecated, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolSourceKind { + AsterBuiltin, + LimeInjected, + BrowserCompatibility, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolPermissionPlane { + SessionAllowlist, + ParameterRestricted, + CallerFiltered, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct ToolCatalogEntry { + pub name: &'static str, + pub profiles: &'static [ToolSurfaceProfile], + pub capabilities: &'static [ToolCapability], + pub lifecycle: ToolLifecycle, + pub source: ToolSourceKind, + pub permission_plane: ToolPermissionPlane, + pub workspace_default_allow: bool, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct WorkspaceToolSurface { + pub creator: bool, + pub browser_assist: bool, +} + +impl WorkspaceToolSurface { + pub const fn core() -> Self { + Self { + creator: false, + browser_assist: false, + } + } + + pub const fn creator() -> Self { + Self { + creator: true, + browser_assist: false, + } + } + + pub const fn browser_assist() -> Self { + Self { + creator: false, + browser_assist: true, + } + } + + pub const fn creator_with_browser_assist() -> Self { + Self { + creator: true, + browser_assist: true, + } + } + + pub const fn includes_profile(self, profile: ToolSurfaceProfile) -> bool { + match profile { + ToolSurfaceProfile::Core => true, + ToolSurfaceProfile::Creator => self.creator, + ToolSurfaceProfile::BrowserAssist => self.browser_assist, + } + } +} + +const CORE_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::Core]; +const CREATOR_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::Creator]; +const BROWSER_PROFILES: &[ToolSurfaceProfile] = &[ToolSurfaceProfile::BrowserAssist]; + +const PLAN_CAP: &[ToolCapability] = &[ToolCapability::Planning]; +const DELEGATION_CAP: &[ToolCapability] = + &[ToolCapability::Delegation, ToolCapability::SessionControl]; +const SEARCH_CAP: &[ToolCapability] = &[ToolCapability::WebSearch]; +const SKILL_CAP: &[ToolCapability] = &[ToolCapability::SkillExecution]; +const CONTENT_CAP: &[ToolCapability] = &[ToolCapability::ContentCreation]; +const BROWSER_CAP: &[ToolCapability] = &[ToolCapability::BrowserRuntime]; +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", + profiles: CORE_PROFILES, + capabilities: WORKSPACE_IO_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::ParameterRestricted, + workspace_default_allow: false, + }, + ToolCatalogEntry { + name: "write", + profiles: CORE_PROFILES, + capabilities: WORKSPACE_IO_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::ParameterRestricted, + workspace_default_allow: false, + }, + ToolCatalogEntry { + name: "edit", + profiles: CORE_PROFILES, + capabilities: WORKSPACE_IO_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::ParameterRestricted, + workspace_default_allow: false, + }, + ToolCatalogEntry { + name: "glob", + profiles: CORE_PROFILES, + capabilities: WORKSPACE_IO_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::ParameterRestricted, + workspace_default_allow: false, + }, + ToolCatalogEntry { + name: "grep", + profiles: CORE_PROFILES, + capabilities: WORKSPACE_IO_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::ParameterRestricted, + workspace_default_allow: false, + }, + ToolCatalogEntry { + name: "bash", + profiles: CORE_PROFILES, + capabilities: EXECUTION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::ParameterRestricted, + workspace_default_allow: false, + }, + ToolCatalogEntry { + name: "lsp", + profiles: CORE_PROFILES, + capabilities: WORKSPACE_IO_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::ParameterRestricted, + workspace_default_allow: false, + }, + ToolCatalogEntry { + name: "Skill", + profiles: CORE_PROFILES, + capabilities: SKILL_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "Task", + profiles: CORE_PROFILES, + capabilities: EXECUTION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::ParameterRestricted, + workspace_default_allow: false, + }, + ToolCatalogEntry { + name: "TaskOutput", + profiles: CORE_PROFILES, + capabilities: PLAN_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "KillShell", + profiles: CORE_PROFILES, + capabilities: EXECUTION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "TodoWrite", + profiles: CORE_PROFILES, + capabilities: PLAN_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "NotebookEdit", + profiles: CORE_PROFILES, + capabilities: WORKSPACE_IO_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::ParameterRestricted, + workspace_default_allow: false, + }, + ToolCatalogEntry { + name: "EnterPlanMode", + profiles: CORE_PROFILES, + capabilities: PLAN_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "ExitPlanMode", + profiles: CORE_PROFILES, + capabilities: PLAN_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "WebFetch", + profiles: CORE_PROFILES, + capabilities: SEARCH_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::ParameterRestricted, + workspace_default_allow: false, + }, + ToolCatalogEntry { + name: "WebSearch", + profiles: CORE_PROFILES, + capabilities: SEARCH_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "analyze_image", + profiles: CORE_PROFILES, + capabilities: VISION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::ParameterRestricted, + workspace_default_allow: false, + }, + ToolCatalogEntry { + name: "ask", + profiles: CORE_PROFILES, + capabilities: PLAN_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::AsterBuiltin, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: TOOL_SEARCH_TOOL_NAME, + profiles: CORE_PROFILES, + capabilities: SEARCH_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "spawn_agent", + profiles: CORE_PROFILES, + capabilities: DELEGATION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "send_input", + profiles: CORE_PROFILES, + capabilities: DELEGATION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "wait_agent", + profiles: CORE_PROFILES, + capabilities: DELEGATION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "resume_agent", + profiles: CORE_PROFILES, + capabilities: DELEGATION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "close_agent", + profiles: CORE_PROFILES, + capabilities: DELEGATION_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: "SubAgentTask", + profiles: CORE_PROFILES, + capabilities: DELEGATION_CAP, + lifecycle: ToolLifecycle::Compat, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: false, + }, + ToolCatalogEntry { + name: SOCIAL_IMAGE_TOOL_NAME, + profiles: CREATOR_PROFILES, + capabilities: CONTENT_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: LIME_CREATE_VIDEO_TASK_TOOL_NAME, + profiles: CREATOR_PROFILES, + capabilities: CONTENT_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: LIME_CREATE_BROADCAST_TASK_TOOL_NAME, + profiles: CREATOR_PROFILES, + capabilities: CONTENT_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: LIME_CREATE_COVER_TASK_TOOL_NAME, + profiles: CREATOR_PROFILES, + capabilities: CONTENT_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME, + profiles: CREATOR_PROFILES, + capabilities: CONTENT_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: LIME_CREATE_IMAGE_TASK_TOOL_NAME, + profiles: CREATOR_PROFILES, + capabilities: CONTENT_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: LIME_CREATE_URL_PARSE_TASK_TOOL_NAME, + profiles: CREATOR_PROFILES, + capabilities: CONTENT_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: LIME_CREATE_TYPESETTING_TASK_TOOL_NAME, + profiles: CREATOR_PROFILES, + capabilities: CONTENT_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::LimeInjected, + permission_plane: ToolPermissionPlane::SessionAllowlist, + workspace_default_allow: true, + }, + ToolCatalogEntry { + name: BROWSER_RUNTIME_TOOL_PREFIX, + profiles: BROWSER_PROFILES, + capabilities: BROWSER_CAP, + lifecycle: ToolLifecycle::Current, + source: ToolSourceKind::BrowserCompatibility, + permission_plane: ToolPermissionPlane::CallerFiltered, + workspace_default_allow: false, + }, +]; + +pub fn native_tool_catalog() -> &'static [ToolCatalogEntry] { + NATIVE_TOOL_CATALOG +} + +pub fn tool_catalog_entry(tool_name: &str) -> Option<&'static ToolCatalogEntry> { + let normalized_name = tool_name.trim(); + native_tool_catalog() + .iter() + .filter(|entry| { + if entry.name.ends_with("__") { + normalized_name.starts_with(entry.name) + } else { + entry.name == normalized_name + } + }) + .max_by_key(|entry| entry.name.len()) +} + +pub fn tool_catalog_entries_for_surface( + surface: WorkspaceToolSurface, +) -> Vec<&'static ToolCatalogEntry> { + native_tool_catalog() + .iter() + .filter(|entry| { + entry + .profiles + .iter() + .any(|profile| surface.includes_profile(*profile)) + }) + .collect() +} + +pub fn workspace_default_allowed_tool_names(surface: WorkspaceToolSurface) -> Vec<&'static str> { + let mut names = tool_catalog_entries_for_surface(surface) + .into_iter() + .filter(|entry| entry.workspace_default_allow) + .filter(|entry| entry.lifecycle == ToolLifecycle::Current) + .filter(|entry| !entry.name.ends_with("__")) + .map(|entry| entry.name) + .collect::>(); + names.sort_unstable(); + names.dedup(); + names +} + +pub fn workspace_allowed_tool_names(surface: WorkspaceToolSurface) -> Vec<&'static str> { + workspace_default_allowed_tool_names(surface) +} + +pub fn creator_tool_names() -> Vec<&'static str> { + tool_catalog_entries_for_surface(WorkspaceToolSurface::creator()) + .into_iter() + .filter(|entry| entry.profiles.contains(&ToolSurfaceProfile::Creator)) + .filter(|entry| entry.name != BROWSER_RUNTIME_TOOL_PREFIX) + .map(|entry| entry.name) + .collect() +} + +pub fn browser_runtime_tool_prefix() -> &'static str { + BROWSER_RUNTIME_TOOL_PREFIX +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpExtensionSurface { + pub extension_name: String, + pub description: String, + pub available_tools: Vec, + pub always_expose_tools: Vec, + pub deferred_loading: bool, + pub allowed_caller: Option, +} + +impl McpExtensionSurface { + pub fn has_tools(&self) -> bool { + !self.available_tools.is_empty() + } +} + +pub fn build_mcp_extension_surface( + extension_name: &str, + description: impl Into, + tools: &[McpToolDefinition], +) -> McpExtensionSurface { + let mut available_tools = tools + .iter() + .map(|tool| tool.name.clone()) + .collect::>(); + available_tools.sort(); + available_tools.dedup(); + + let mut always_expose_tools = tools + .iter() + .filter(|tool| { + tool.always_visible.unwrap_or(false) || !tool.deferred_loading.unwrap_or(false) + }) + .map(|tool| tool.name.clone()) + .collect::>(); + always_expose_tools.sort(); + always_expose_tools.dedup(); + + let deferred_loading = tools + .iter() + .any(|tool| tool.deferred_loading.unwrap_or(false)); + let allowed_caller = collapse_extension_allowed_caller(tools); + + McpExtensionSurface { + extension_name: extension_name.to_string(), + description: description.into(), + available_tools, + always_expose_tools, + deferred_loading, + allowed_caller, + } +} + +fn collapse_extension_allowed_caller(tools: &[McpToolDefinition]) -> Option { + let mut collapsed: Option = None; + + for tool in tools { + let allowed = tool.allowed_callers.as_ref()?; + if allowed.len() != 1 { + return None; + } + let caller = allowed[0].trim(); + if caller.is_empty() { + return None; + } + match collapsed.as_deref() { + Some(existing) if existing != caller => return None, + Some(_) => {} + None => collapsed = Some(caller.to_string()), + } + } + + collapsed +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + fn sample_tool( + name: &str, + deferred_loading: Option, + always_visible: Option, + allowed_callers: Option>, + ) -> McpToolDefinition { + McpToolDefinition { + name: name.to_string(), + description: format!("desc for {name}"), + input_schema: serde_json::json!({ "type": "object" }), + server_name: "docs".to_string(), + deferred_loading, + always_visible, + allowed_callers: allowed_callers.map(|items| { + items + .into_iter() + .map(|item| item.to_string()) + .collect::>() + }), + input_examples: None, + tags: None, + } + } + + #[test] + fn test_tool_catalog_entry_matches_browser_prefix() { + let entry = tool_catalog_entry("mcp__lime-browser__navigate") + .expect("browser tool should match prefix catalog entry"); + assert_eq!(entry.name, BROWSER_RUNTIME_TOOL_PREFIX); + assert_eq!(entry.source, ToolSourceKind::BrowserCompatibility); + } + + #[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(&"WebSearch")); + assert!(!names.contains(&"SubAgentTask")); + assert!(!names.contains(&"read")); + assert!(!names.contains(&"bash")); + assert!(!names.contains(&SOCIAL_IMAGE_TOOL_NAME)); + } + + #[test] + fn test_workspace_default_allowed_tool_names_includes_creator_surface() { + let names = workspace_default_allowed_tool_names(WorkspaceToolSurface::creator()); + assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME)); + assert!(names.contains(&LIME_CREATE_VIDEO_TASK_TOOL_NAME)); + } + + #[test] + fn test_tool_catalog_entries_for_surface_counts_and_lifecycle_boundaries() { + let core = tool_catalog_entries_for_surface(WorkspaceToolSurface::core()); + assert_eq!(core.len(), 26); + assert_eq!( + core.iter() + .filter(|entry| entry.lifecycle == ToolLifecycle::Current) + .count(), + 25 + ); + assert_eq!( + core.iter() + .filter(|entry| entry.lifecycle == ToolLifecycle::Compat) + .count(), + 1 + ); + assert!(core + .iter() + .all(|entry| !entry.profiles.contains(&ToolSurfaceProfile::Creator))); + assert!(core + .iter() + .all(|entry| !entry.profiles.contains(&ToolSurfaceProfile::BrowserAssist))); + + let creator = tool_catalog_entries_for_surface(WorkspaceToolSurface::creator()); + assert_eq!(creator.len(), 34); + assert!(creator + .iter() + .any(|entry| entry.name == SOCIAL_IMAGE_TOOL_NAME)); + assert!(!creator + .iter() + .any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX)); + + let browser = tool_catalog_entries_for_surface(WorkspaceToolSurface::browser_assist()); + assert_eq!(browser.len(), 27); + assert!(browser + .iter() + .any(|entry| entry.name == BROWSER_RUNTIME_TOOL_PREFIX)); + + let combined = + tool_catalog_entries_for_surface(WorkspaceToolSurface::creator_with_browser_assist()); + assert_eq!(combined.len(), 35); + } + + #[test] + fn test_creator_tool_names_only_returns_creator_increment() { + let names = creator_tool_names().into_iter().collect::>(); + assert_eq!(names.len(), 8); + assert!(names.contains(SOCIAL_IMAGE_TOOL_NAME)); + assert!(names.contains(LIME_CREATE_VIDEO_TASK_TOOL_NAME)); + assert!(!names.contains("tool_search")); + assert!(!names.contains(BROWSER_RUNTIME_TOOL_PREFIX)); + } + + #[test] + fn test_workspace_default_allowed_tool_names_creator_with_browser_assist_excludes_prefix_tool() + { + let names = workspace_default_allowed_tool_names( + WorkspaceToolSurface::creator_with_browser_assist(), + ); + assert_eq!(names.len(), 22); + assert!(names.contains(&SOCIAL_IMAGE_TOOL_NAME)); + assert!(names.contains(&"tool_search")); + assert!(!names + .iter() + .any(|name| name.starts_with(BROWSER_RUNTIME_TOOL_PREFIX))); + } + + #[test] + fn test_build_mcp_extension_surface_collapses_single_caller() { + let tools = vec![ + sample_tool( + "search_docs", + Some(true), + Some(false), + Some(vec!["assistant"]), + ), + sample_tool( + "read_docs", + Some(false), + Some(true), + Some(vec!["assistant"]), + ), + ]; + + let surface = build_mcp_extension_surface("docs", "docs tools", &tools); + assert!(surface.deferred_loading); + assert_eq!(surface.allowed_caller.as_deref(), Some("assistant")); + assert_eq!(surface.always_expose_tools, vec!["read_docs".to_string()]); + } + + #[test] + fn test_build_mcp_extension_surface_drops_mixed_callers() { + let tools = vec![ + sample_tool( + "search_docs", + Some(true), + Some(false), + Some(vec!["assistant"]), + ), + sample_tool( + "admin_docs", + Some(true), + Some(false), + Some(vec!["code_execution"]), + ), + ]; + + let surface = build_mcp_extension_surface("docs", "docs tools", &tools); + assert_eq!(surface.allowed_caller, None); + } + + #[test] + fn test_build_mcp_extension_surface_dedups_available_and_exposed_tools() { + let tools = vec![ + sample_tool( + "search_docs", + Some(true), + Some(true), + Some(vec!["assistant"]), + ), + sample_tool( + "read_docs", + Some(false), + Some(false), + Some(vec!["assistant"]), + ), + sample_tool( + "search_docs", + Some(true), + Some(true), + Some(vec!["assistant"]), + ), + ]; + + let surface = build_mcp_extension_surface("docs", "docs tools", &tools); + assert!(surface.deferred_loading); + assert_eq!(surface.allowed_caller.as_deref(), Some("assistant")); + 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(), "search_docs".to_string()] + ); + } + + #[test] + fn test_build_mcp_extension_surface_rejects_blank_allowed_caller() { + let tools = vec![ + sample_tool( + "search_docs", + Some(true), + Some(false), + Some(vec!["assistant"]), + ), + sample_tool("read_docs", Some(false), Some(true), Some(vec![" "])), + ]; + + let surface = build_mcp_extension_surface("docs", "docs tools", &tools); + assert_eq!(surface.allowed_caller, None); + } +} diff --git a/src-tauri/src/agent_tools/execution.rs b/src-tauri/src/agent_tools/execution.rs new file mode 100644 index 000000000..cb2486af6 --- /dev/null +++ b/src-tauri/src/agent_tools/execution.rs @@ -0,0 +1,966 @@ +use crate::agent_tools::catalog::{ + tool_catalog_entries_for_surface, tool_catalog_entry, workspace_default_allowed_tool_names, + ToolPermissionPlane, WorkspaceToolSurface, +}; +use aster::permission::{ParameterRestriction, PermissionScope, RestrictionType, ToolPermission}; +use lime_core::config::{ + ToolExecutionOverrideConfig as ConfigToolExecutionOverrideConfig, + ToolExecutionPolicyConfig as ConfigToolExecutionPolicyConfig, + ToolExecutionRestrictionProfileConfig as ConfigToolExecutionRestrictionProfileConfig, + ToolExecutionSandboxProfileConfig as ConfigToolExecutionSandboxProfileConfig, + ToolExecutionWarningPolicyConfig as ConfigToolExecutionWarningPolicyConfig, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map as JsonMap, Value as JsonValue}; +use std::collections::HashMap; + +const DURABLE_MEMORY_PATH_PATTERN: &str = r"^/memories(?:/.*)?$"; +const SAFE_HTTPS_URL_PATTERN: &str = r"^https://[^\s]+$"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolExecutionWarningPolicy { + None, + ShellCommandRisk, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolExecutionRestrictionProfile { + None, + WorkspacePathRequired, + WorkspacePathOptional, + WorkspaceAbsolutePathRequired, + WorkspaceShellCommand, + AnalyzeImageInput, + SafeHttpsUrlRequired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolExecutionSandboxProfile { + None, + WorkspaceCommand, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolExecutionPolicySource { + Default, + Persisted, + Runtime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolExecutionPolicy { + pub warning_policy: ToolExecutionWarningPolicy, + pub restriction_profile: ToolExecutionRestrictionProfile, + pub sandbox_profile: ToolExecutionSandboxProfile, +} + +impl Default for ToolExecutionPolicy { + fn default() -> Self { + Self { + warning_policy: ToolExecutionWarningPolicy::None, + restriction_profile: ToolExecutionRestrictionProfile::None, + sandbox_profile: ToolExecutionSandboxProfile::None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolExecutionPolicyResolution { + pub policy: ToolExecutionPolicy, + pub warning_policy_source: ToolExecutionPolicySource, + pub restriction_profile_source: ToolExecutionPolicySource, + pub sandbox_profile_source: ToolExecutionPolicySource, +} + +#[derive(Debug, Clone, Copy)] +pub struct WorkspaceExecutionPermissionInput<'a> { + pub surface: WorkspaceToolSurface, + pub workspace_root: &'a str, + pub auto_mode: bool, + pub execution_policy_input: ToolExecutionResolverInput<'a>, +} + +#[derive(Debug, Clone)] +struct WorkspacePermissionPatterns { + workspace_path_pattern: String, + workspace_abs_path_pattern: String, + analyze_image_path_pattern: String, + safe_https_url_pattern: String, + shell_allow_pattern: String, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct ToolExecutionResolverInput<'a> { + pub persisted_policy: Option<&'a ConfigToolExecutionPolicyConfig>, + pub request_metadata: Option<&'a JsonValue>, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct ToolExecutionPolicyOverride { + warning_policy: Option, + restriction_profile: Option, + sandbox_profile: Option, +} + +pub fn tool_execution_policy(tool_name: &str) -> ToolExecutionPolicy { + let normalized_name = tool_name.trim(); + let Some(catalog_entry) = tool_catalog_entry(normalized_name) else { + return ToolExecutionPolicy::default(); + }; + + match catalog_entry.name { + "read" | "write" | "edit" | "lsp" => ToolExecutionPolicy { + restriction_profile: ToolExecutionRestrictionProfile::WorkspacePathRequired, + ..ToolExecutionPolicy::default() + }, + "glob" | "grep" => ToolExecutionPolicy { + restriction_profile: ToolExecutionRestrictionProfile::WorkspacePathOptional, + ..ToolExecutionPolicy::default() + }, + "bash" => ToolExecutionPolicy { + warning_policy: ToolExecutionWarningPolicy::ShellCommandRisk, + restriction_profile: ToolExecutionRestrictionProfile::WorkspaceShellCommand, + sandbox_profile: ToolExecutionSandboxProfile::WorkspaceCommand, + }, + "Task" => ToolExecutionPolicy { + warning_policy: ToolExecutionWarningPolicy::ShellCommandRisk, + restriction_profile: ToolExecutionRestrictionProfile::WorkspaceShellCommand, + sandbox_profile: ToolExecutionSandboxProfile::None, + }, + "NotebookEdit" => ToolExecutionPolicy { + restriction_profile: ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired, + ..ToolExecutionPolicy::default() + }, + "analyze_image" => ToolExecutionPolicy { + restriction_profile: ToolExecutionRestrictionProfile::AnalyzeImageInput, + ..ToolExecutionPolicy::default() + }, + "WebFetch" => ToolExecutionPolicy { + restriction_profile: ToolExecutionRestrictionProfile::SafeHttpsUrlRequired, + ..ToolExecutionPolicy::default() + }, + _ => ToolExecutionPolicy::default(), + } +} + +pub fn resolve_tool_execution_policy( + tool_name: &str, + input: ToolExecutionResolverInput<'_>, +) -> ToolExecutionPolicy { + resolve_tool_execution_policy_resolution(tool_name, input).policy +} + +pub fn resolve_tool_execution_policy_resolution( + tool_name: &str, + input: ToolExecutionResolverInput<'_>, +) -> ToolExecutionPolicyResolution { + let default_policy = tool_execution_policy(tool_name); + let persisted_override = + extract_persisted_tool_execution_override(tool_name, input.persisted_policy); + let runtime_override = + extract_runtime_execution_policy_override(tool_name, input.request_metadata); + + apply_tool_execution_override( + apply_tool_execution_override( + ToolExecutionPolicyResolution { + policy: default_policy, + warning_policy_source: ToolExecutionPolicySource::Default, + restriction_profile_source: ToolExecutionPolicySource::Default, + sandbox_profile_source: ToolExecutionPolicySource::Default, + }, + persisted_override, + ToolExecutionPolicySource::Persisted, + ), + runtime_override, + ToolExecutionPolicySource::Runtime, + ) +} + +pub fn build_workspace_shell_allow_pattern( + escaped_root: &str, + allow_extended_shell_commands: bool, +) -> String { + if allow_extended_shell_commands { + return String::from(r"(?s)^\s*\S.*$"); + } + + format!( + r"^\s*(?:cd\s+({escaped_root}|\.|\./|\.\./)|pwd|ls(?:\s+[^;&|]+)?|find\s+({escaped_root}|\.|\./|\.\./)[^;&|]*|rg\b[^;&|]*|grep\b[^;&|]*|cat\s+({escaped_root}|\.|\./|\.\./)[^;&|]*)\s*$" + ) +} + +pub fn should_auto_approve_tool_warnings( + tool_name: &str, + auto_mode: bool, + input: ToolExecutionResolverInput<'_>, +) -> bool { + auto_mode + && matches!( + resolve_tool_execution_policy(tool_name, input).warning_policy, + ToolExecutionWarningPolicy::ShellCommandRisk + ) +} + +pub fn build_workspace_execution_permissions( + input: WorkspaceExecutionPermissionInput<'_>, +) -> Vec { + let patterns = build_workspace_permission_patterns(input.workspace_root, input.auto_mode); + let mut permissions = tool_catalog_entries_for_surface(input.surface) + .into_iter() + .filter_map(|entry| { + build_parameter_restricted_permission( + entry.name, + input.auto_mode, + &patterns, + input.execution_policy_input, + ) + }) + .collect::>(); + + if input.auto_mode { + permissions.push(ToolPermission { + tool: "*".to_string(), + allowed: true, + priority: 1000, + conditions: Vec::new(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some("Auto 模式:允许所有工具与参数".to_string()), + expires_at: None, + metadata: HashMap::new(), + }); + } + + for tool_name in workspace_default_allowed_tool_names(input.surface) { + permissions.push(ToolPermission { + tool: tool_name.to_string(), + allowed: true, + priority: 88, + conditions: Vec::new(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some(format!("允许默认工具: {tool_name}")), + expires_at: None, + metadata: HashMap::new(), + }); + } + + permissions.push(ToolPermission { + tool: "*".to_string(), + allowed: false, + priority: 10, + conditions: Vec::new(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some("workspace 安全策略:未显式授权的工具默认拒绝".to_string()), + expires_at: None, + metadata: HashMap::new(), + }); + + permissions +} + +fn extract_persisted_tool_execution_override( + tool_name: &str, + persisted_policy: Option<&ConfigToolExecutionPolicyConfig>, +) -> ToolExecutionPolicyOverride { + let Some(tool_override) = persisted_policy + .and_then(|policy| find_tool_override_config(&policy.tool_overrides, tool_name)) + else { + return ToolExecutionPolicyOverride::default(); + }; + + ToolExecutionPolicyOverride { + warning_policy: tool_override + .warning_policy + .map(convert_warning_policy_config), + restriction_profile: tool_override + .restriction_profile + .map(convert_restriction_profile_config), + sandbox_profile: tool_override + .sandbox_profile + .map(convert_sandbox_profile_config), + } +} + +fn extract_runtime_execution_policy_override( + tool_name: &str, + request_metadata: Option<&JsonValue>, +) -> ToolExecutionPolicyOverride { + let Some(execution_policy) = extract_runtime_execution_policy_object(request_metadata) else { + return ToolExecutionPolicyOverride::default(); + }; + + let tool_overrides = find_named_object(execution_policy, &["tool_overrides", "toolOverrides"]) + .unwrap_or(execution_policy); + let Some(tool_override) = find_case_insensitive_object(tool_overrides, tool_name) else { + return ToolExecutionPolicyOverride::default(); + }; + + ToolExecutionPolicyOverride { + warning_policy: extract_named_string(tool_override, &["warning_policy", "warningPolicy"]) + .and_then(parse_warning_policy), + restriction_profile: extract_named_string( + tool_override, + &["restriction_profile", "restrictionProfile"], + ) + .and_then(parse_restriction_profile), + sandbox_profile: extract_named_string( + tool_override, + &["sandbox_profile", "sandboxProfile"], + ) + .and_then(parse_sandbox_profile), + } +} + +fn extract_runtime_execution_policy_object( + request_metadata: Option<&JsonValue>, +) -> Option<&JsonMap> { + let harness = extract_runtime_harness_object(request_metadata)?; + find_named_object(harness, &["execution_policy", "executionPolicy"]) +} + +fn extract_runtime_harness_object( + request_metadata: Option<&JsonValue>, +) -> Option<&JsonMap> { + let metadata = request_metadata?.as_object()?; + metadata + .get("harness") + .and_then(JsonValue::as_object) + .or(Some(metadata)) +} + +fn find_named_object<'a>( + object: &'a JsonMap, + keys: &[&str], +) -> Option<&'a JsonMap> { + keys.iter() + .filter_map(|key| object.get(*key)) + .find_map(JsonValue::as_object) +} + +fn find_case_insensitive_object<'a>( + object: &'a JsonMap, + 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) + }) + }) +} + +fn find_tool_override_config<'a>( + tool_overrides: &'a HashMap, + 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) + }) + }) +} + +fn extract_named_string<'a>( + object: &'a JsonMap, + keys: &[&str], +) -> Option<&'a str> { + keys.iter() + .filter_map(|key| object.get(*key)) + .find_map(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn apply_tool_execution_override( + mut base: ToolExecutionPolicyResolution, + tool_override: ToolExecutionPolicyOverride, + source: ToolExecutionPolicySource, +) -> ToolExecutionPolicyResolution { + if let Some(value) = tool_override.warning_policy { + base.policy.warning_policy = value; + base.warning_policy_source = source; + } + if let Some(value) = tool_override.restriction_profile { + base.policy.restriction_profile = value; + base.restriction_profile_source = source; + } + if let Some(value) = tool_override.sandbox_profile { + base.policy.sandbox_profile = value; + base.sandbox_profile_source = source; + } + base +} + +fn convert_warning_policy_config( + value: ConfigToolExecutionWarningPolicyConfig, +) -> ToolExecutionWarningPolicy { + match value { + ConfigToolExecutionWarningPolicyConfig::None => ToolExecutionWarningPolicy::None, + ConfigToolExecutionWarningPolicyConfig::ShellCommandRisk => { + ToolExecutionWarningPolicy::ShellCommandRisk + } + } +} + +fn convert_restriction_profile_config( + value: ConfigToolExecutionRestrictionProfileConfig, +) -> ToolExecutionRestrictionProfile { + match value { + ConfigToolExecutionRestrictionProfileConfig::None => ToolExecutionRestrictionProfile::None, + ConfigToolExecutionRestrictionProfileConfig::WorkspacePathRequired => { + ToolExecutionRestrictionProfile::WorkspacePathRequired + } + ConfigToolExecutionRestrictionProfileConfig::WorkspacePathOptional => { + ToolExecutionRestrictionProfile::WorkspacePathOptional + } + ConfigToolExecutionRestrictionProfileConfig::WorkspaceAbsolutePathRequired => { + ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired + } + ConfigToolExecutionRestrictionProfileConfig::WorkspaceShellCommand => { + ToolExecutionRestrictionProfile::WorkspaceShellCommand + } + ConfigToolExecutionRestrictionProfileConfig::AnalyzeImageInput => { + ToolExecutionRestrictionProfile::AnalyzeImageInput + } + ConfigToolExecutionRestrictionProfileConfig::SafeHttpsUrlRequired => { + ToolExecutionRestrictionProfile::SafeHttpsUrlRequired + } + } +} + +fn convert_sandbox_profile_config( + value: ConfigToolExecutionSandboxProfileConfig, +) -> ToolExecutionSandboxProfile { + match value { + ConfigToolExecutionSandboxProfileConfig::None => ToolExecutionSandboxProfile::None, + ConfigToolExecutionSandboxProfileConfig::WorkspaceCommand => { + ToolExecutionSandboxProfile::WorkspaceCommand + } + } +} + +fn parse_warning_policy(value: &str) -> Option { + match value.trim() { + "none" => Some(ToolExecutionWarningPolicy::None), + "shell_command_risk" => Some(ToolExecutionWarningPolicy::ShellCommandRisk), + _ => None, + } +} + +fn parse_restriction_profile(value: &str) -> Option { + match value.trim() { + "none" => Some(ToolExecutionRestrictionProfile::None), + "workspace_path_required" => Some(ToolExecutionRestrictionProfile::WorkspacePathRequired), + "workspace_path_optional" => Some(ToolExecutionRestrictionProfile::WorkspacePathOptional), + "workspace_absolute_path_required" => { + Some(ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired) + } + "workspace_shell_command" => Some(ToolExecutionRestrictionProfile::WorkspaceShellCommand), + "analyze_image_input" => Some(ToolExecutionRestrictionProfile::AnalyzeImageInput), + "safe_https_url_required" => Some(ToolExecutionRestrictionProfile::SafeHttpsUrlRequired), + _ => None, + } +} + +fn parse_sandbox_profile(value: &str) -> Option { + match value.trim() { + "none" => Some(ToolExecutionSandboxProfile::None), + "workspace_command" => Some(ToolExecutionSandboxProfile::WorkspaceCommand), + _ => None, + } +} + +fn build_workspace_permission_patterns( + workspace_root: &str, + auto_mode: bool, +) -> WorkspacePermissionPatterns { + let escaped_root = regex::escape(workspace_root.trim()); + WorkspacePermissionPatterns { + workspace_path_pattern: format!( + r"^(?:({escaped_root}|\.|\./|\.\./).*$|{DURABLE_MEMORY_PATH_PATTERN})" + ), + workspace_abs_path_pattern: format!(r"^({escaped_root}).*$"), + analyze_image_path_pattern: format!( + r"^(base64:[A-Za-z0-9+/=]+|file://({escaped_root}).*|({escaped_root}|\.|\./|\.\./).*)$" + ), + safe_https_url_pattern: SAFE_HTTPS_URL_PATTERN.to_string(), + shell_allow_pattern: build_workspace_shell_allow_pattern(&escaped_root, auto_mode), + } +} + +fn build_parameter_restricted_permission( + tool_name: &str, + auto_mode: bool, + patterns: &WorkspacePermissionPatterns, + execution_policy_input: ToolExecutionResolverInput<'_>, +) -> Option { + let catalog_entry = tool_catalog_entry(tool_name)?; + if catalog_entry.permission_plane != ToolPermissionPlane::ParameterRestricted { + return None; + } + + let policy = resolve_tool_execution_policy(tool_name, execution_policy_input); + let parameter_restrictions = if auto_mode { + Vec::new() + } else { + build_parameter_restrictions(tool_name, policy.restriction_profile, patterns) + }; + + Some(ToolPermission { + tool: tool_name.to_string(), + allowed: true, + priority: permission_priority(tool_name), + conditions: Vec::new(), + parameter_restrictions, + scope: PermissionScope::Session, + reason: Some(permission_reason( + tool_name, + policy.restriction_profile, + auto_mode, + )), + expires_at: None, + metadata: HashMap::new(), + }) +} + +fn build_parameter_restrictions( + tool_name: &str, + profile: ToolExecutionRestrictionProfile, + patterns: &WorkspacePermissionPatterns, +) -> Vec { + match profile { + ToolExecutionRestrictionProfile::None => Vec::new(), + ToolExecutionRestrictionProfile::WorkspacePathRequired => { + vec![pattern_restriction( + "path", + &patterns.workspace_path_pattern, + true, + Some(format!( + "{tool_name}.path 必须在 workspace、相对路径或 `/memories/` 内" + )), + )] + } + ToolExecutionRestrictionProfile::WorkspacePathOptional => { + vec![pattern_restriction( + "path", + &patterns.workspace_path_pattern, + false, + Some(format!( + "{tool_name}.path 必须在 workspace、相对路径或 `/memories/` 内" + )), + )] + } + ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired => { + vec![pattern_restriction( + "notebook_path", + &patterns.workspace_abs_path_pattern, + true, + Some("NotebookEdit.notebook_path 必须是 workspace 内绝对路径".to_string()), + )] + } + ToolExecutionRestrictionProfile::WorkspaceShellCommand => vec![ + pattern_restriction( + "command", + &patterns.shell_allow_pattern, + false, + Some(format!("{tool_name}.command 仅允许 workspace 内安全命令")), + ), + pattern_restriction( + "cmd", + &patterns.shell_allow_pattern, + false, + Some(format!("{tool_name}.cmd 兼容参数名,规则与 command 一致")), + ), + ], + ToolExecutionRestrictionProfile::AnalyzeImageInput => { + vec![pattern_restriction( + "file_path", + &patterns.analyze_image_path_pattern, + true, + Some( + "analyze_image.file_path 仅允许 base64、workspace 内绝对路径或相对路径" + .to_string(), + ), + )] + } + ToolExecutionRestrictionProfile::SafeHttpsUrlRequired => { + vec![pattern_restriction( + "url", + &patterns.safe_https_url_pattern, + true, + Some("WebFetch.url 仅允许 https 且禁止内网/本机地址".to_string()), + )] + } + } +} + +fn pattern_restriction( + parameter: &str, + pattern: &str, + required: bool, + description: Option, +) -> ParameterRestriction { + ParameterRestriction { + parameter: parameter.to_string(), + restriction_type: RestrictionType::Pattern, + values: None, + pattern: Some(pattern.to_string()), + validator: None, + min: None, + max: None, + required, + description, + } +} + +fn permission_priority(tool_name: &str) -> i32 { + match tool_name { + "read" | "write" | "edit" | "glob" | "grep" => 100, + "bash" => 90, + _ => 88, + } +} + +fn permission_reason( + tool_name: &str, + profile: ToolExecutionRestrictionProfile, + auto_mode: bool, +) -> String { + if auto_mode { + return match profile { + ToolExecutionRestrictionProfile::WorkspaceShellCommand => { + format!("Auto 模式:允许 {tool_name} 执行任意命令") + } + ToolExecutionRestrictionProfile::SafeHttpsUrlRequired => { + format!("Auto 模式:允许 {tool_name} 访问任意 URL") + } + ToolExecutionRestrictionProfile::AnalyzeImageInput => { + format!("Auto 模式:允许 {tool_name} 分析任意图片路径或 base64") + } + ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired => { + format!("Auto 模式:允许 {tool_name} 访问任意绝对路径") + } + ToolExecutionRestrictionProfile::WorkspacePathRequired + | ToolExecutionRestrictionProfile::WorkspacePathOptional => { + format!("Auto 模式:允许 {tool_name} 访问任意路径") + } + ToolExecutionRestrictionProfile::None => format!("Auto 模式:允许工具 {tool_name}"), + }; + } + + match profile { + ToolExecutionRestrictionProfile::WorkspacePathRequired => { + format!("仅允许 {tool_name} 访问当前 workspace 或 `/memories/` 内容") + } + ToolExecutionRestrictionProfile::WorkspacePathOptional => { + format!("仅允许 {tool_name} 在当前 workspace 或 `/memories/` 搜索内容") + } + ToolExecutionRestrictionProfile::WorkspaceAbsolutePathRequired => { + format!("仅允许 {tool_name} 访问 workspace 内绝对路径") + } + ToolExecutionRestrictionProfile::WorkspaceShellCommand => { + format!("workspace 安全策略:{tool_name} 仅允许 workspace 内安全命令") + } + ToolExecutionRestrictionProfile::AnalyzeImageInput => { + "允许分析 workspace 内图片或 base64 数据".to_string() + } + ToolExecutionRestrictionProfile::SafeHttpsUrlRequired => { + "允许安全的 WebFetch 请求".to_string() + } + ToolExecutionRestrictionProfile::None => format!("允许工具 {tool_name}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lime_core::config::{ + ToolExecutionOverrideConfig as ConfigToolExecutionOverrideConfig, + ToolExecutionPolicyConfig as ConfigToolExecutionPolicyConfig, + ToolExecutionRestrictionProfileConfig as ConfigToolExecutionRestrictionProfileConfig, + ToolExecutionSandboxProfileConfig as ConfigToolExecutionSandboxProfileConfig, + ToolExecutionWarningPolicyConfig as ConfigToolExecutionWarningPolicyConfig, + }; + use serde_json::json; + + #[test] + fn test_tool_execution_policy_marks_bash_as_sandboxed_shell_risk() { + let policy = tool_execution_policy("bash"); + assert_eq!( + policy.warning_policy, + ToolExecutionWarningPolicy::ShellCommandRisk + ); + assert_eq!( + policy.restriction_profile, + ToolExecutionRestrictionProfile::WorkspaceShellCommand + ); + assert_eq!( + policy.sandbox_profile, + ToolExecutionSandboxProfile::WorkspaceCommand + ); + } + + #[test] + fn test_build_workspace_execution_permissions_strict_mode_restricts_parameter_tools() { + let permissions = + build_workspace_execution_permissions(WorkspaceExecutionPermissionInput { + surface: WorkspaceToolSurface::core(), + workspace_root: "/tmp/workspace", + auto_mode: false, + execution_policy_input: ToolExecutionResolverInput::default(), + }); + + let read = permissions + .iter() + .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] + .pattern + .as_deref() + .unwrap_or_default() + .contains("/tmp/workspace")); + + let bash = permissions + .iter() + .find(|permission| permission.tool == "bash") + .expect("bash permission should exist"); + assert_eq!(bash.parameter_restrictions.len(), 2); + assert!(permissions + .iter() + .any(|permission| permission.tool == "*" && !permission.allowed)); + assert!(!permissions + .iter() + .any(|permission| permission.tool == "*" && permission.allowed)); + } + + #[test] + fn test_build_workspace_execution_permissions_auto_mode_adds_wildcard_allow() { + let permissions = + build_workspace_execution_permissions(WorkspaceExecutionPermissionInput { + surface: WorkspaceToolSurface::core(), + workspace_root: "/tmp/workspace", + auto_mode: true, + execution_policy_input: ToolExecutionResolverInput::default(), + }); + + let bash = permissions + .iter() + .find(|permission| permission.tool == "bash") + .expect("bash permission should exist"); + assert!(bash.parameter_restrictions.is_empty()); + assert!(permissions + .iter() + .any(|permission| permission.tool == "*" && permission.allowed)); + } + + #[test] + 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("Task", true, input)); + assert!(!should_auto_approve_tool_warnings("read", true, input)); + assert!(!should_auto_approve_tool_warnings("bash", false, input)); + } + + #[test] + fn test_build_workspace_shell_allow_pattern_auto_mode_allows_multiline() { + let escaped_root = regex::escape("/tmp/workspace"); + let pattern = build_workspace_shell_allow_pattern(&escaped_root, true); + let regex = regex::Regex::new(&pattern).expect("pattern should compile"); + + assert!(regex.is_match("python3 <<'EOF'\nprint('hello')\nEOF")); + } + + #[test] + fn test_resolve_tool_execution_policy_allows_persisted_override_to_replace_default() { + let persisted_policy = ConfigToolExecutionPolicyConfig { + tool_overrides: HashMap::from([( + "bash".to_string(), + ConfigToolExecutionOverrideConfig { + warning_policy: Some(ConfigToolExecutionWarningPolicyConfig::None), + restriction_profile: Some( + ConfigToolExecutionRestrictionProfileConfig::WorkspacePathRequired, + ), + sandbox_profile: Some(ConfigToolExecutionSandboxProfileConfig::None), + }, + )]), + }; + + let policy = resolve_tool_execution_policy( + "bash", + ToolExecutionResolverInput { + persisted_policy: Some(&persisted_policy), + request_metadata: None, + }, + ); + + assert_eq!(policy.warning_policy, ToolExecutionWarningPolicy::None); + assert_eq!( + policy.restriction_profile, + ToolExecutionRestrictionProfile::WorkspacePathRequired + ); + assert_eq!(policy.sandbox_profile, ToolExecutionSandboxProfile::None); + } + + #[test] + fn test_resolve_tool_execution_policy_runtime_override_beats_persisted_policy() { + let persisted_policy = ConfigToolExecutionPolicyConfig { + tool_overrides: HashMap::from([( + "bash".to_string(), + ConfigToolExecutionOverrideConfig { + warning_policy: Some(ConfigToolExecutionWarningPolicyConfig::None), + restriction_profile: Some( + ConfigToolExecutionRestrictionProfileConfig::WorkspacePathRequired, + ), + sandbox_profile: Some(ConfigToolExecutionSandboxProfileConfig::None), + }, + )]), + }; + let request_metadata = json!({ + "harness": { + "executionPolicy": { + "toolOverrides": { + "BASH": { + "warningPolicy": "shell_command_risk", + "restrictionProfile": "workspace_shell_command", + "sandboxProfile": "workspace_command" + } + } + } + } + }); + + let policy = resolve_tool_execution_policy( + "bash", + ToolExecutionResolverInput { + persisted_policy: Some(&persisted_policy), + request_metadata: Some(&request_metadata), + }, + ); + + assert_eq!( + policy.warning_policy, + ToolExecutionWarningPolicy::ShellCommandRisk + ); + assert_eq!( + policy.restriction_profile, + ToolExecutionRestrictionProfile::WorkspaceShellCommand + ); + assert_eq!( + policy.sandbox_profile, + ToolExecutionSandboxProfile::WorkspaceCommand + ); + } + + #[test] + fn test_resolve_tool_execution_policy_resolution_tracks_mixed_sources_per_field() { + let persisted_policy = ConfigToolExecutionPolicyConfig { + tool_overrides: HashMap::from([( + "bash".to_string(), + ConfigToolExecutionOverrideConfig { + warning_policy: Some(ConfigToolExecutionWarningPolicyConfig::None), + restriction_profile: None, + sandbox_profile: None, + }, + )]), + }; + let request_metadata = json!({ + "harness": { + "executionPolicy": { + "toolOverrides": { + "bash": { + "sandboxProfile": "none" + } + } + } + } + }); + + let resolution = resolve_tool_execution_policy_resolution( + "bash", + ToolExecutionResolverInput { + persisted_policy: Some(&persisted_policy), + request_metadata: Some(&request_metadata), + }, + ); + + assert_eq!( + resolution.policy.warning_policy, + ToolExecutionWarningPolicy::None + ); + assert_eq!( + resolution.policy.restriction_profile, + ToolExecutionRestrictionProfile::WorkspaceShellCommand + ); + assert_eq!( + resolution.policy.sandbox_profile, + ToolExecutionSandboxProfile::None + ); + assert_eq!( + resolution.warning_policy_source, + ToolExecutionPolicySource::Persisted + ); + assert_eq!( + resolution.restriction_profile_source, + ToolExecutionPolicySource::Default + ); + assert_eq!( + resolution.sandbox_profile_source, + ToolExecutionPolicySource::Runtime + ); + } + + #[test] + fn test_build_workspace_execution_permissions_respects_runtime_override() { + let request_metadata = json!({ + "harness": { + "execution_policy": { + "tool_overrides": { + "bash": { + "restriction_profile": "none" + } + } + } + } + }); + + let permissions = + build_workspace_execution_permissions(WorkspaceExecutionPermissionInput { + surface: WorkspaceToolSurface::core(), + workspace_root: "/tmp/workspace", + auto_mode: false, + execution_policy_input: ToolExecutionResolverInput { + persisted_policy: None, + request_metadata: Some(&request_metadata), + }, + }); + + let bash = permissions + .iter() + .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 new file mode 100644 index 000000000..438b5f8e3 --- /dev/null +++ b/src-tauri/src/agent_tools/inventory.rs @@ -0,0 +1,1280 @@ +use crate::agent_tools::catalog::{ + tool_catalog_entries_for_surface, tool_catalog_entry, workspace_default_allowed_tool_names, + ToolCapability, ToolLifecycle, ToolPermissionPlane, ToolSourceKind, ToolSurfaceProfile, + WorkspaceToolSurface, +}; +use crate::agent_tools::execution::{ + resolve_tool_execution_policy_resolution, ToolExecutionPolicySource, + ToolExecutionResolverInput, ToolExecutionRestrictionProfile, ToolExecutionSandboxProfile, + ToolExecutionWarningPolicy, +}; +use crate::mcp::McpToolDefinition; +use aster::agents::extension::ExtensionConfig; +use aster::tools::ToolDefinition; +use lime_core::config::ToolExecutionPolicyConfig as ConfigToolExecutionPolicyConfig; +use lime_core::tool_calling::{ + extract_tool_surface_metadata, tool_matches_caller, tool_visible_in_context, +}; +use serde::Serialize; +use std::collections::{BTreeSet, HashSet}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeExtensionSourceKind { + McpBridge, + RuntimeExtension, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ToolInventorySurfaceSnapshot { + pub creator: bool, + pub browser_assist: bool, +} + +impl From for ToolInventorySurfaceSnapshot { + fn from(value: WorkspaceToolSurface) -> Self { + Self { + creator: value.creator, + browser_assist: value.browser_assist, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ToolInventoryRequestSnapshot { + pub caller: String, + pub surface: ToolInventorySurfaceSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ToolInventoryCounts { + pub catalog_total: usize, + pub catalog_current_total: usize, + pub catalog_compat_total: usize, + pub catalog_deprecated_total: usize, + pub default_allowed_total: usize, + pub registry_total: usize, + pub registry_visible_total: usize, + pub registry_catalog_unmapped_total: usize, + pub extension_surface_total: usize, + pub extension_mcp_bridge_total: usize, + pub extension_runtime_total: usize, + pub extension_tool_total: usize, + pub extension_tool_visible_total: usize, + pub mcp_server_total: usize, + pub mcp_tool_total: usize, + pub mcp_tool_visible_total: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ToolCatalogInventoryEntry { + pub name: String, + pub profiles: Vec, + pub capabilities: Vec, + pub lifecycle: ToolLifecycle, + pub source: ToolSourceKind, + pub permission_plane: ToolPermissionPlane, + pub workspace_default_allow: bool, + pub execution_warning_policy: ToolExecutionWarningPolicy, + pub execution_warning_policy_source: ToolExecutionPolicySource, + pub execution_restriction_profile: ToolExecutionRestrictionProfile, + pub execution_restriction_profile_source: ToolExecutionPolicySource, + pub execution_sandbox_profile: ToolExecutionSandboxProfile, + pub execution_sandbox_profile_source: ToolExecutionPolicySource, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeRegistryToolInventoryEntry { + pub name: String, + pub description: String, + pub catalog_entry_name: Option, + pub catalog_source: Option, + pub catalog_lifecycle: Option, + pub catalog_permission_plane: Option, + pub catalog_workspace_default_allow: Option, + pub catalog_execution_warning_policy: Option, + pub catalog_execution_warning_policy_source: Option, + pub catalog_execution_restriction_profile: Option, + pub catalog_execution_restriction_profile_source: Option, + pub catalog_execution_sandbox_profile: Option, + pub catalog_execution_sandbox_profile_source: Option, + pub deferred_loading: bool, + pub always_visible: bool, + pub allowed_callers: Vec, + pub tags: Vec, + pub input_examples_count: usize, + pub caller_allowed: bool, + pub visible_in_context: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeExtensionSurfaceInventoryEntry { + pub extension_name: String, + pub description: String, + pub source_kind: RuntimeExtensionSourceKind, + pub deferred_loading: bool, + pub allowed_caller: Option, + pub available_tools: Vec, + pub always_expose_tools: Vec, + pub loaded_tools: Vec, + pub searchable_tools: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RuntimeExtensionToolInventoryEntry { + pub name: String, + pub description: String, + pub extension_name: Option, + pub source_kind: RuntimeExtensionSourceKind, + pub deferred_loading: bool, + pub allowed_caller: Option, + pub status: String, + pub caller_allowed: bool, + pub visible_in_context: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionToolInventorySeed { + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionToolRuntimeStatus { + pub status: &'static str, + pub deferred_loading: bool, + pub extension_name: Option, + pub allowed_caller: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct McpToolInventoryEntry { + pub server_name: String, + pub name: String, + pub description: String, + pub deferred_loading: bool, + pub always_visible: bool, + pub allowed_callers: Vec, + pub tags: Vec, + pub input_examples_count: usize, + pub caller_allowed: bool, + pub visible_in_context: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AgentToolInventorySnapshot { + pub request: ToolInventoryRequestSnapshot, + pub agent_initialized: bool, + pub warnings: Vec, + pub mcp_servers: Vec, + pub default_allowed_tools: Vec, + pub counts: ToolInventoryCounts, + pub catalog_tools: Vec, + pub registry_tools: Vec, + pub extension_surfaces: Vec, + pub extension_tools: Vec, + pub mcp_tools: Vec, +} + +#[derive(Debug, Clone)] +pub struct AgentToolInventoryBuildInput { + pub surface: WorkspaceToolSurface, + pub caller: String, + pub agent_initialized: bool, + pub warnings: Vec, + pub persisted_execution_policy: Option, + pub request_metadata: Option, + pub mcp_server_names: Vec, + pub mcp_tools: Vec, + pub registry_definitions: Vec, + pub extension_configs: Vec, + pub visible_extension_tools: Vec, + pub searchable_extension_tools: Vec, +} + +pub fn build_tool_inventory(input: AgentToolInventoryBuildInput) -> AgentToolInventorySnapshot { + let AgentToolInventoryBuildInput { + surface, + caller, + agent_initialized, + warnings, + persisted_execution_policy, + request_metadata, + mcp_server_names, + mcp_tools, + registry_definitions, + extension_configs, + visible_extension_tools, + searchable_extension_tools, + } = input; + + let execution_policy_input = ToolExecutionResolverInput { + persisted_policy: persisted_execution_policy.as_ref(), + request_metadata: request_metadata.as_ref(), + }; + + let mut mcp_servers = mcp_server_names + .into_iter() + .collect::>() + .into_iter() + .collect::>(); + mcp_servers.sort(); + let mcp_server_lookup = mcp_servers.iter().cloned().collect::>(); + + let mut default_allowed_tools = workspace_default_allowed_tool_names(surface) + .into_iter() + .map(ToString::to_string) + .collect::>(); + default_allowed_tools.sort(); + + let catalog_tools = tool_catalog_entries_for_surface(surface) + .into_iter() + .map(|entry| { + let resolution = + resolve_tool_execution_policy_resolution(entry.name, execution_policy_input); + + ToolCatalogInventoryEntry { + name: entry.name.to_string(), + profiles: entry.profiles.to_vec(), + capabilities: entry.capabilities.to_vec(), + lifecycle: entry.lifecycle, + source: entry.source, + permission_plane: entry.permission_plane, + workspace_default_allow: entry.workspace_default_allow, + execution_warning_policy: resolution.policy.warning_policy, + execution_warning_policy_source: resolution.warning_policy_source, + execution_restriction_profile: resolution.policy.restriction_profile, + execution_restriction_profile_source: resolution.restriction_profile_source, + execution_sandbox_profile: resolution.policy.sandbox_profile, + execution_sandbox_profile_source: resolution.sandbox_profile_source, + } + }) + .collect::>(); + + let registry_tools = + build_registry_inventory(®istry_definitions, &caller, execution_policy_input); + let extension_surfaces = build_extension_surface_inventory( + &extension_configs, + &visible_extension_tools, + &searchable_extension_tools, + &mcp_server_lookup, + ); + let extension_tools = build_extension_tool_inventory( + &extension_configs, + &visible_extension_tools, + &searchable_extension_tools, + &caller, + &mcp_server_lookup, + ); + let mcp_tools = build_mcp_inventory(&mcp_tools, &caller); + + let counts = ToolInventoryCounts { + catalog_total: catalog_tools.len(), + catalog_current_total: catalog_tools + .iter() + .filter(|entry| entry.lifecycle == ToolLifecycle::Current) + .count(), + catalog_compat_total: catalog_tools + .iter() + .filter(|entry| entry.lifecycle == ToolLifecycle::Compat) + .count(), + catalog_deprecated_total: catalog_tools + .iter() + .filter(|entry| entry.lifecycle == ToolLifecycle::Deprecated) + .count(), + default_allowed_total: default_allowed_tools.len(), + registry_total: registry_tools.len(), + registry_visible_total: registry_tools + .iter() + .filter(|entry| entry.visible_in_context) + .count(), + registry_catalog_unmapped_total: registry_tools + .iter() + .filter(|entry| entry.catalog_entry_name.is_none()) + .count(), + extension_surface_total: extension_surfaces.len(), + extension_mcp_bridge_total: extension_surfaces + .iter() + .filter(|entry| entry.source_kind == RuntimeExtensionSourceKind::McpBridge) + .count(), + extension_runtime_total: extension_surfaces + .iter() + .filter(|entry| entry.source_kind == RuntimeExtensionSourceKind::RuntimeExtension) + .count(), + extension_tool_total: extension_tools.len(), + extension_tool_visible_total: extension_tools + .iter() + .filter(|entry| entry.visible_in_context) + .count(), + mcp_server_total: mcp_servers.len(), + mcp_tool_total: mcp_tools.len(), + mcp_tool_visible_total: mcp_tools + .iter() + .filter(|entry| entry.visible_in_context) + .count(), + }; + + AgentToolInventorySnapshot { + request: ToolInventoryRequestSnapshot { + caller, + surface: surface.into(), + }, + agent_initialized, + warnings, + mcp_servers, + default_allowed_tools, + counts, + catalog_tools, + registry_tools, + extension_surfaces, + extension_tools, + mcp_tools, + } +} + +fn build_registry_inventory( + definitions: &[ToolDefinition], + caller: &str, + execution_policy_input: ToolExecutionResolverInput<'_>, +) -> Vec { + let mut result = definitions + .iter() + .map(|definition| { + let metadata = + extract_tool_surface_metadata(&definition.name, &definition.input_schema); + let catalog_entry = tool_catalog_entry(&definition.name); + let caller_allowed = tool_matches_caller(&metadata, Some(caller)); + let visible_in_context = caller_allowed && tool_visible_in_context(&metadata, false); + let catalog_execution_policy = catalog_entry.map(|entry| { + resolve_tool_execution_policy_resolution(entry.name, execution_policy_input) + }); + + RuntimeRegistryToolInventoryEntry { + name: definition.name.clone(), + description: definition.description.clone(), + catalog_entry_name: catalog_entry.map(|entry| entry.name.to_string()), + catalog_source: catalog_entry.map(|entry| entry.source), + catalog_lifecycle: catalog_entry.map(|entry| entry.lifecycle), + catalog_permission_plane: catalog_entry.map(|entry| entry.permission_plane), + catalog_workspace_default_allow: catalog_entry + .map(|entry| entry.workspace_default_allow), + catalog_execution_warning_policy: catalog_execution_policy + .map(|resolution| resolution.policy.warning_policy), + catalog_execution_warning_policy_source: catalog_execution_policy + .map(|resolution| resolution.warning_policy_source), + catalog_execution_restriction_profile: catalog_execution_policy + .map(|resolution| resolution.policy.restriction_profile), + catalog_execution_restriction_profile_source: catalog_execution_policy + .map(|resolution| resolution.restriction_profile_source), + catalog_execution_sandbox_profile: catalog_execution_policy + .map(|resolution| resolution.policy.sandbox_profile), + catalog_execution_sandbox_profile_source: catalog_execution_policy + .map(|resolution| resolution.sandbox_profile_source), + deferred_loading: metadata.deferred_loading.unwrap_or(false), + always_visible: metadata.always_visible.unwrap_or(false), + allowed_callers: metadata.allowed_callers.unwrap_or_default(), + tags: metadata.tags.unwrap_or_default(), + input_examples_count: metadata.input_examples.len(), + caller_allowed, + visible_in_context, + } + }) + .collect::>(); + + result.sort_by(|left, right| left.name.cmp(&right.name)); + result +} + +fn build_extension_surface_inventory( + configs: &[ExtensionConfig], + visible_extension_tools: &[ExtensionToolInventorySeed], + searchable_extension_tools: &[ExtensionToolInventorySeed], + mcp_server_lookup: &HashSet, +) -> Vec { + let loaded_tool_names = visible_extension_tools + .iter() + .map(|tool| tool.name.clone()) + .collect::>(); + let searchable_tool_names = searchable_extension_tools + .iter() + .map(|tool| tool.name.clone()) + .collect::>(); + + let mut result = configs + .iter() + .map(|config| { + let extension_name = config.name(); + let available_tools = extension_available_tools(config); + let always_expose_tools = extension_always_expose_tools(config); + let loaded_tools = prefixed_tool_names( + &extension_name, + loaded_tool_names.iter().map(String::as_str), + ); + let searchable_tools = prefixed_tool_names( + &extension_name, + searchable_tool_names.iter().map(String::as_str), + ); + + RuntimeExtensionSurfaceInventoryEntry { + extension_name: extension_name.clone(), + description: extension_description(config), + source_kind: extension_source_kind(&extension_name, mcp_server_lookup), + deferred_loading: config.deferred_loading(), + allowed_caller: config.allowed_caller().map(ToString::to_string), + available_tools, + always_expose_tools, + loaded_tools, + searchable_tools, + } + }) + .collect::>(); + + result.sort_by(|left, right| left.extension_name.cmp(&right.extension_name)); + result +} + +fn build_extension_tool_inventory( + configs: &[ExtensionConfig], + visible_extension_tools: &[ExtensionToolInventorySeed], + searchable_extension_tools: &[ExtensionToolInventorySeed], + caller: &str, + mcp_server_lookup: &HashSet, +) -> Vec { + let visible_tool_names = visible_extension_tools + .iter() + .map(|tool| tool.name.clone()) + .collect::>(); + + let mut result = searchable_extension_tools + .iter() + .map(|tool| { + let runtime_status = + resolve_extension_tool_runtime_status(configs, &visible_tool_names, &tool.name); + let source_kind = runtime_status + .extension_name + .as_ref() + .map(|name| extension_source_kind(name, mcp_server_lookup)) + .unwrap_or(RuntimeExtensionSourceKind::RuntimeExtension); + let caller_allowed = runtime_status + .allowed_caller + .as_deref() + .is_none_or(|value| value == caller); + + RuntimeExtensionToolInventoryEntry { + name: tool.name.clone(), + description: tool.description.clone(), + extension_name: runtime_status.extension_name.clone(), + source_kind, + deferred_loading: runtime_status.deferred_loading, + allowed_caller: runtime_status.allowed_caller.clone(), + status: runtime_status.status.to_string(), + caller_allowed, + visible_in_context: caller_allowed && runtime_status.status != "deferred", + } + }) + .collect::>(); + + result.sort_by(|left, right| left.name.cmp(&right.name)); + result +} + +fn build_mcp_inventory(tools: &[McpToolDefinition], caller: &str) -> Vec { + let mut result = tools + .iter() + .map(|tool| { + let metadata = extract_tool_surface_metadata(&tool.name, &tool.input_schema); + let caller_allowed = tool_matches_caller(&metadata, Some(caller)); + let visible_in_context = caller_allowed && tool_visible_in_context(&metadata, false); + + McpToolInventoryEntry { + server_name: tool.server_name.clone(), + name: tool.name.clone(), + description: tool.description.clone(), + deferred_loading: metadata.deferred_loading.unwrap_or(false), + always_visible: metadata.always_visible.unwrap_or(false), + allowed_callers: metadata.allowed_callers.unwrap_or_default(), + tags: metadata.tags.unwrap_or_default(), + input_examples_count: metadata.input_examples.len(), + caller_allowed, + visible_in_context, + } + }) + .collect::>(); + + result.sort_by(|left, right| { + left.server_name + .cmp(&right.server_name) + .then_with(|| left.name.cmp(&right.name)) + }); + result +} + +fn extension_available_tools(config: &ExtensionConfig) -> Vec { + match config { + ExtensionConfig::Sse { .. } => Vec::new(), + ExtensionConfig::StreamableHttp { + available_tools, .. + } + | ExtensionConfig::Stdio { + available_tools, .. + } + | ExtensionConfig::Builtin { + available_tools, .. + } + | ExtensionConfig::Platform { + available_tools, .. + } + | ExtensionConfig::InlinePython { + available_tools, .. + } + | ExtensionConfig::Frontend { + available_tools, .. + } => { + let mut tools = available_tools.clone(); + tools.sort(); + tools.dedup(); + tools + } + } +} + +fn extension_always_expose_tools(config: &ExtensionConfig) -> Vec { + let mut tools = config.always_expose_tools().to_vec(); + tools.sort(); + tools.dedup(); + tools +} + +fn extension_description(config: &ExtensionConfig) -> String { + match config { + ExtensionConfig::Sse { description, .. } + | ExtensionConfig::StreamableHttp { description, .. } + | ExtensionConfig::Stdio { description, .. } + | ExtensionConfig::Builtin { description, .. } + | ExtensionConfig::Platform { description, .. } + | ExtensionConfig::InlinePython { description, .. } + | ExtensionConfig::Frontend { description, .. } => description.clone(), + } +} + +fn extension_source_kind( + extension_name: &str, + mcp_server_lookup: &HashSet, +) -> RuntimeExtensionSourceKind { + if mcp_server_lookup.contains(extension_name) { + RuntimeExtensionSourceKind::McpBridge + } else { + RuntimeExtensionSourceKind::RuntimeExtension + } +} + +fn prefixed_tool_names<'a>( + extension_name: &str, + tool_names: impl Iterator, +) -> Vec { + let prefix = format!("{extension_name}__"); + let mut names = tool_names + .filter(|name| name.starts_with(&prefix)) + .map(ToString::to_string) + .collect::>(); + names.sort(); + names.dedup(); + names +} + +pub fn resolve_extension_tool_runtime_status( + configs: &[ExtensionConfig], + visible_tool_names: &HashSet, + tool_name: &str, +) -> ExtensionToolRuntimeStatus { + let matched = configs + .iter() + .filter_map(|config| { + let extension_name = config.name(); + tool_name + .strip_prefix(extension_name.as_str()) + .and_then(|rest| rest.strip_prefix("__")) + .map(|inner_tool_name| (extension_name, config, inner_tool_name.to_string())) + }) + .max_by_key(|(extension_name, _, _)| extension_name.len()); + + let Some((extension_name, config, inner_tool_name)) = matched else { + return ExtensionToolRuntimeStatus { + status: "visible", + deferred_loading: false, + extension_name: None, + allowed_caller: None, + }; + }; + + if !config.deferred_loading() || config.is_tool_exposed_by_default(&inner_tool_name) { + return ExtensionToolRuntimeStatus { + status: "visible", + deferred_loading: false, + extension_name: Some(extension_name), + allowed_caller: config.allowed_caller().map(ToString::to_string), + }; + } + + if visible_tool_names.contains(tool_name) { + ExtensionToolRuntimeStatus { + status: "loaded", + deferred_loading: false, + extension_name: Some(extension_name), + allowed_caller: config.allowed_caller().map(ToString::to_string), + } + } else { + ExtensionToolRuntimeStatus { + status: "deferred", + deferred_loading: true, + extension_name: Some(extension_name), + allowed_caller: config.allowed_caller().map(ToString::to_string), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lime_core::config::{ + ToolExecutionOverrideConfig as ConfigToolExecutionOverrideConfig, + ToolExecutionPolicyConfig as ConfigToolExecutionPolicyConfig, + ToolExecutionWarningPolicyConfig as ConfigToolExecutionWarningPolicyConfig, + }; + use serde_json::json; + + fn builtin_extension( + name: &str, + available_tools: Vec<&str>, + deferred_loading: bool, + always_expose_tools: Vec<&str>, + allowed_caller: Option<&str>, + ) -> ExtensionConfig { + ExtensionConfig::Builtin { + name: name.to_string(), + display_name: Some(name.to_string()), + description: format!("{name} tools"), + timeout: None, + bundled: Some(false), + available_tools: available_tools + .into_iter() + .map(|item| item.to_string()) + .collect(), + deferred_loading, + always_expose_tools: always_expose_tools + .into_iter() + .map(|item| item.to_string()) + .collect(), + allowed_caller: allowed_caller.map(ToString::to_string), + } + } + + fn definition(name: &str, description: &str, schema: serde_json::Value) -> ToolDefinition { + ToolDefinition::new(name, description, schema) + } + + fn seed(name: &str, description: &str) -> ExtensionToolInventorySeed { + ExtensionToolInventorySeed { + name: name.to_string(), + description: description.to_string(), + } + } + + fn mcp_tool( + server_name: &str, + name: &str, + deferred_loading: bool, + always_visible: bool, + allowed_callers: Vec<&str>, + ) -> McpToolDefinition { + McpToolDefinition { + server_name: server_name.to_string(), + name: name.to_string(), + description: format!("{name} desc"), + input_schema: json!({ + "type": "object", + "x-lime": { + "deferred_loading": deferred_loading, + "always_visible": always_visible, + "allowed_callers": allowed_callers + } + }), + deferred_loading: Some(deferred_loading), + always_visible: Some(always_visible), + allowed_callers: Some( + allowed_callers + .into_iter() + .map(|item| item.to_string()) + .collect(), + ), + input_examples: None, + tags: None, + } + } + + #[test] + fn test_build_tool_inventory_marks_visibility_and_mappings() { + let inventory = build_tool_inventory(AgentToolInventoryBuildInput { + surface: WorkspaceToolSurface::core(), + caller: "assistant".to_string(), + agent_initialized: true, + warnings: Vec::new(), + persisted_execution_policy: None, + request_metadata: None, + mcp_server_names: vec!["docs".to_string()], + mcp_tools: vec![mcp_tool( + "docs", + "search_docs", + true, + false, + vec!["assistant"], + )], + registry_definitions: vec![ + definition("tool_search", "search tools", json!({ "type": "object" })), + definition( + "read", + "read file", + json!({ + "type": "object", + "x-lime": { "allowed_callers": ["assistant"] } + }), + ), + definition( + "admin_secret", + "secret", + json!({ + "type": "object", + "x-lime": { + "deferred_loading": true, + "allowed_callers": ["code_execution"] + } + }), + ), + ], + extension_configs: vec![builtin_extension( + "docs", + vec!["search_docs", "read_docs"], + true, + vec!["search_docs"], + Some("assistant"), + )], + visible_extension_tools: vec![ExtensionToolInventorySeed { + name: "docs__search_docs".to_string(), + description: "visible docs tool".to_string(), + }], + searchable_extension_tools: vec![ + ExtensionToolInventorySeed { + name: "docs__search_docs".to_string(), + description: "visible docs tool".to_string(), + }, + ExtensionToolInventorySeed { + name: "docs__read_docs".to_string(), + description: "deferred docs tool".to_string(), + }, + ], + }); + + assert_eq!(inventory.counts.catalog_total, 26); + assert_eq!(inventory.counts.registry_total, 3); + assert_eq!(inventory.counts.registry_visible_total, 2); + 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); + assert_eq!(inventory.counts.extension_tool_total, 2); + assert_eq!(inventory.counts.extension_tool_visible_total, 1); + assert_eq!(inventory.counts.mcp_tool_total, 1); + assert_eq!(inventory.counts.mcp_tool_visible_total, 0); + assert!(inventory + .default_allowed_tools + .contains(&"tool_search".to_string())); + let bash_catalog = inventory + .catalog_tools + .iter() + .find(|entry| entry.name == "bash") + .expect("bash catalog entry should exist"); + assert_eq!( + bash_catalog.execution_warning_policy, + ToolExecutionWarningPolicy::ShellCommandRisk + ); + assert_eq!( + bash_catalog.execution_sandbox_profile, + ToolExecutionSandboxProfile::WorkspaceCommand + ); + + let admin_tool = inventory + .registry_tools + .iter() + .find(|entry| entry.name == "admin_secret") + .expect("admin tool should exist"); + assert!(!admin_tool.caller_allowed); + assert!(!admin_tool.visible_in_context); + assert!(admin_tool.catalog_entry_name.is_none()); + + let docs_surface = inventory + .extension_surfaces + .iter() + .find(|entry| entry.extension_name == "docs") + .expect("docs surface should exist"); + assert_eq!( + docs_surface.source_kind, + RuntimeExtensionSourceKind::McpBridge + ); + assert_eq!( + docs_surface.loaded_tools, + vec!["docs__search_docs".to_string()] + ); + assert_eq!( + docs_surface.searchable_tools, + vec![ + "docs__read_docs".to_string(), + "docs__search_docs".to_string() + ] + ); + + let deferred_extension_tool = inventory + .extension_tools + .iter() + .find(|entry| entry.name == "docs__read_docs") + .expect("deferred extension tool should exist"); + assert_eq!(deferred_extension_tool.status, "deferred"); + assert!(!deferred_extension_tool.visible_in_context); + } + + #[test] + fn test_build_tool_inventory_defaults_unknown_caller_normalization_upstream() { + let caller = lime_core::tool_calling::normalize_tool_caller(Some(" Assistant ")) + .expect("caller should normalize upstream"); + assert_eq!(caller, "assistant"); + } + + #[test] + fn test_build_tool_inventory_uninitialized_agent_keeps_sorted_servers_and_mcp_visibility() { + let inventory = build_tool_inventory(AgentToolInventoryBuildInput { + surface: WorkspaceToolSurface::core(), + caller: "assistant".to_string(), + agent_initialized: false, + warnings: vec!["agent not initialized".to_string()], + persisted_execution_policy: None, + request_metadata: None, + mcp_server_names: vec!["docs".to_string(), "alpha".to_string(), "docs".to_string()], + mcp_tools: vec![ + mcp_tool("docs", "search_docs", true, true, vec!["assistant"]), + mcp_tool("alpha", "read_alpha", false, false, vec![]), + ], + registry_definitions: Vec::new(), + extension_configs: Vec::new(), + visible_extension_tools: Vec::new(), + searchable_extension_tools: Vec::new(), + }); + + assert!(!inventory.agent_initialized); + assert_eq!( + inventory.warnings, + vec!["agent not initialized".to_string()] + ); + assert_eq!( + inventory.mcp_servers, + vec!["alpha".to_string(), "docs".to_string()] + ); + assert_eq!(inventory.counts.registry_total, 0); + assert_eq!(inventory.counts.extension_surface_total, 0); + assert_eq!(inventory.counts.mcp_server_total, 2); + assert_eq!(inventory.counts.mcp_tool_total, 2); + assert_eq!(inventory.counts.mcp_tool_visible_total, 2); + assert_eq!(inventory.mcp_tools[0].server_name, "alpha"); + assert!(inventory.mcp_tools.iter().any(|entry| { + entry.name == "search_docs" && entry.always_visible && entry.visible_in_context + })); + } + + #[test] + fn test_build_tool_inventory_creator_with_browser_surface_keeps_small_default_allowlist() { + let inventory = build_tool_inventory(AgentToolInventoryBuildInput { + surface: WorkspaceToolSurface::creator_with_browser_assist(), + caller: "assistant".to_string(), + agent_initialized: true, + warnings: Vec::new(), + persisted_execution_policy: None, + request_metadata: None, + mcp_server_names: Vec::new(), + mcp_tools: Vec::new(), + registry_definitions: Vec::new(), + extension_configs: Vec::new(), + visible_extension_tools: Vec::new(), + searchable_extension_tools: Vec::new(), + }); + let expected_default_allowed = workspace_default_allowed_tool_names( + WorkspaceToolSurface::creator_with_browser_assist(), + ) + .into_iter() + .map(ToString::to_string) + .collect::>(); + + assert_eq!(inventory.counts.catalog_total, 35); + assert_eq!(inventory.counts.catalog_current_total, 34); + assert_eq!(inventory.counts.catalog_compat_total, 1); + assert_eq!(inventory.default_allowed_tools, expected_default_allowed); + assert_eq!( + inventory.counts.default_allowed_total, + inventory.default_allowed_tools.len() + ); + assert!(inventory + .default_allowed_tools + .contains(&"tool_search".to_string())); + assert!(inventory + .default_allowed_tools + .contains(&"social_generate_cover_image".to_string())); + assert!(!inventory + .default_allowed_tools + .iter() + .any(|name| name.starts_with("mcp__lime-browser__"))); + } + + #[test] + fn test_build_tool_inventory_uses_effective_execution_policy_provenance() { + let inventory = build_tool_inventory(AgentToolInventoryBuildInput { + surface: WorkspaceToolSurface::core(), + caller: "assistant".to_string(), + agent_initialized: true, + warnings: Vec::new(), + persisted_execution_policy: Some(ConfigToolExecutionPolicyConfig { + tool_overrides: std::collections::HashMap::from([( + "bash".to_string(), + ConfigToolExecutionOverrideConfig { + warning_policy: Some(ConfigToolExecutionWarningPolicyConfig::None), + restriction_profile: None, + sandbox_profile: None, + }, + )]), + }), + request_metadata: Some(json!({ + "harness": { + "executionPolicy": { + "toolOverrides": { + "bash": { + "sandboxProfile": "none" + } + } + } + } + })), + mcp_server_names: Vec::new(), + mcp_tools: Vec::new(), + registry_definitions: vec![definition( + "bash", + "workspace bash", + json!({ + "type": "object", + "x-lime": { "allowed_callers": ["assistant"] } + }), + )], + extension_configs: Vec::new(), + visible_extension_tools: Vec::new(), + searchable_extension_tools: Vec::new(), + }); + + let bash_catalog = inventory + .catalog_tools + .iter() + .find(|entry| entry.name == "bash") + .expect("bash catalog entry should exist"); + assert_eq!( + bash_catalog.execution_warning_policy, + ToolExecutionWarningPolicy::None + ); + assert_eq!( + bash_catalog.execution_restriction_profile, + ToolExecutionRestrictionProfile::WorkspaceShellCommand + ); + assert_eq!( + bash_catalog.execution_warning_policy_source, + ToolExecutionPolicySource::Persisted + ); + assert_eq!( + bash_catalog.execution_restriction_profile_source, + ToolExecutionPolicySource::Default + ); + assert_eq!( + bash_catalog.execution_sandbox_profile, + ToolExecutionSandboxProfile::None + ); + assert_eq!( + bash_catalog.execution_sandbox_profile_source, + ToolExecutionPolicySource::Runtime + ); + + let bash_registry = inventory + .registry_tools + .iter() + .find(|entry| entry.name == "bash") + .expect("bash registry entry should exist"); + assert_eq!( + bash_registry.catalog_execution_warning_policy, + Some(ToolExecutionWarningPolicy::None) + ); + assert_eq!( + bash_registry.catalog_execution_restriction_profile, + Some(ToolExecutionRestrictionProfile::WorkspaceShellCommand) + ); + assert_eq!( + bash_registry.catalog_execution_sandbox_profile, + Some(ToolExecutionSandboxProfile::None) + ); + assert_eq!( + bash_registry.catalog_execution_warning_policy_source, + Some(ToolExecutionPolicySource::Persisted) + ); + assert_eq!( + bash_registry.catalog_execution_restriction_profile_source, + Some(ToolExecutionPolicySource::Default) + ); + assert_eq!( + bash_registry.catalog_execution_sandbox_profile_source, + Some(ToolExecutionPolicySource::Runtime) + ); + } + + #[test] + fn test_build_tool_inventory_marks_extension_sources_and_statuses() { + let inventory = build_tool_inventory(AgentToolInventoryBuildInput { + surface: WorkspaceToolSurface::core(), + caller: "assistant".to_string(), + agent_initialized: true, + warnings: Vec::new(), + persisted_execution_policy: None, + request_metadata: None, + mcp_server_names: vec!["docs".to_string()], + mcp_tools: Vec::new(), + registry_definitions: Vec::new(), + extension_configs: vec![ + builtin_extension( + "docs", + vec!["search_docs", "read_docs"], + true, + vec!["search_docs"], + Some("assistant"), + ), + builtin_extension("fs", vec!["list"], false, vec![], Some("code_execution")), + ], + visible_extension_tools: vec![seed("docs__read_docs", "loaded docs tool")], + searchable_extension_tools: vec![ + seed("docs__search_docs", "search docs"), + seed("docs__read_docs", "loaded docs tool"), + seed("fs__list", "list files"), + ], + }); + + let docs_surface = inventory + .extension_surfaces + .iter() + .find(|entry| entry.extension_name == "docs") + .expect("docs surface should exist"); + assert_eq!( + docs_surface.source_kind, + RuntimeExtensionSourceKind::McpBridge + ); + assert_eq!( + docs_surface.loaded_tools, + vec!["docs__read_docs".to_string()] + ); + assert_eq!( + docs_surface.searchable_tools, + vec![ + "docs__read_docs".to_string(), + "docs__search_docs".to_string() + ] + ); + + let fs_surface = inventory + .extension_surfaces + .iter() + .find(|entry| entry.extension_name == "fs") + .expect("fs surface should exist"); + assert_eq!( + fs_surface.source_kind, + RuntimeExtensionSourceKind::RuntimeExtension + ); + + let visible_tool = inventory + .extension_tools + .iter() + .find(|entry| entry.name == "docs__search_docs") + .expect("visible tool should exist"); + assert_eq!(visible_tool.status, "visible"); + assert!(!visible_tool.deferred_loading); + assert!(visible_tool.visible_in_context); + + let loaded_tool = inventory + .extension_tools + .iter() + .find(|entry| entry.name == "docs__read_docs") + .expect("loaded tool should exist"); + assert_eq!(loaded_tool.status, "loaded"); + assert!(!loaded_tool.deferred_loading); + assert!(loaded_tool.visible_in_context); + + let caller_filtered_tool = inventory + .extension_tools + .iter() + .find(|entry| entry.name == "fs__list") + .expect("caller filtered tool should exist"); + assert_eq!(caller_filtered_tool.status, "visible"); + assert_eq!( + caller_filtered_tool.source_kind, + RuntimeExtensionSourceKind::RuntimeExtension + ); + assert_eq!( + caller_filtered_tool.allowed_caller.as_deref(), + Some("code_execution") + ); + assert!(!caller_filtered_tool.caller_allowed); + assert!(!caller_filtered_tool.visible_in_context); + } + + #[test] + fn test_build_tool_inventory_prefers_longest_extension_name_match() { + let inventory = build_tool_inventory(AgentToolInventoryBuildInput { + surface: WorkspaceToolSurface::core(), + caller: "assistant".to_string(), + agent_initialized: true, + warnings: Vec::new(), + persisted_execution_policy: None, + request_metadata: None, + mcp_server_names: Vec::new(), + mcp_tools: Vec::new(), + registry_definitions: Vec::new(), + extension_configs: vec![ + builtin_extension("docs", vec!["search"], true, vec![], Some("assistant")), + builtin_extension( + "docs__admin", + vec!["search"], + true, + vec![], + Some("code_execution"), + ), + ], + visible_extension_tools: Vec::new(), + searchable_extension_tools: vec![seed("docs__admin__search", "admin search")], + }); + + let tool = inventory + .extension_tools + .iter() + .find(|entry| entry.name == "docs__admin__search") + .expect("nested extension tool should exist"); + assert_eq!(tool.extension_name.as_deref(), Some("docs__admin")); + assert_eq!(tool.allowed_caller.as_deref(), Some("code_execution")); + assert_eq!(tool.status, "deferred"); + assert!(tool.deferred_loading); + assert!(!tool.caller_allowed); + } + + #[test] + fn test_resolve_extension_tool_runtime_status_defaults_unknown_tools_visible() { + let status = resolve_extension_tool_runtime_status( + &[builtin_extension( + "docs", + vec!["search"], + true, + vec![], + Some("assistant"), + )], + &HashSet::new(), + "unmapped__tool", + ); + + assert_eq!( + status, + ExtensionToolRuntimeStatus { + status: "visible", + deferred_loading: false, + extension_name: None, + allowed_caller: None, + } + ); + } + + #[test] + fn test_build_tool_inventory_registry_marks_always_visible_deferred_tools_visible() { + let inventory = build_tool_inventory(AgentToolInventoryBuildInput { + surface: WorkspaceToolSurface::core(), + caller: "assistant".to_string(), + agent_initialized: true, + warnings: Vec::new(), + persisted_execution_policy: None, + request_metadata: None, + mcp_server_names: Vec::new(), + mcp_tools: Vec::new(), + registry_definitions: vec![ + definition( + "bash", + "workspace bash", + json!({ + "type": "object", + "x-lime": { + "deferred_loading": true, + "allowed_callers": ["assistant"] + } + }), + ), + definition( + "review_docs", + "review docs", + json!({ + "type": "object", + "x-lime": { + "deferred_loading": true, + "always_visible": true, + "allowed_callers": ["assistant"], + "tags": ["docs"], + "input_examples": [{"query": "rust"}] + } + }), + ), + definition( + "admin_secret", + "admin only", + json!({ + "type": "object", + "x-lime": { + "deferred_loading": true, + "allowed_callers": ["code_execution"] + } + }), + ), + ], + extension_configs: Vec::new(), + visible_extension_tools: Vec::new(), + searchable_extension_tools: Vec::new(), + }); + + let review_docs = inventory + .registry_tools + .iter() + .find(|entry| entry.name == "review_docs") + .expect("review_docs should exist"); + assert!(review_docs.deferred_loading); + assert!(review_docs.always_visible); + assert_eq!(review_docs.tags, vec!["docs".to_string()]); + assert_eq!(review_docs.input_examples_count, 1); + assert!(review_docs.caller_allowed); + assert!(review_docs.visible_in_context); + + let admin_secret = inventory + .registry_tools + .iter() + .find(|entry| entry.name == "admin_secret") + .expect("admin_secret should exist"); + assert!(!admin_secret.caller_allowed); + assert!(!admin_secret.visible_in_context); + assert_eq!( + inventory + .registry_tools + .iter() + .find(|entry| entry.name == "bash") + .and_then(|entry| entry.catalog_execution_sandbox_profile), + Some(ToolExecutionSandboxProfile::WorkspaceCommand) + ); + assert_eq!(inventory.counts.registry_visible_total, 1); + } +} diff --git a/src-tauri/src/agent_tools/mod.rs b/src-tauri/src/agent_tools/mod.rs new file mode 100644 index 000000000..91a49e5d0 --- /dev/null +++ b/src-tauri/src/agent_tools/mod.rs @@ -0,0 +1,3 @@ +pub mod catalog; +pub mod execution; +pub mod inventory; diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index d856d0639..d8cb70759 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -191,6 +191,8 @@ pub fn run() { .manage(automation_service_state) .manage(workflow_service) .manage(progress_store) + .manage(commands::subagent_cmd::SubAgentSchedulerState::default()) + .manage(commands::websocket_cmd::WsServiceState::default()) .manage(lime_gateway::telegram::TelegramGatewayState::default()) .manage(lime_gateway::discord::DiscordGatewayState::default()) .manage(lime_gateway::feishu::FeishuGatewayState::default()) @@ -242,14 +244,6 @@ pub fn run() { crate::commands::windows_startup_cmd::maybe_show_windows_startup_notice(&app.handle()); } - // TODO: 重新实现 TerminalTool 和 TermScrollbackTool 的 AppHandle 设置 - // 当前暂时注释掉,等待适配 aster-rust 工具系统 - // crate::agent::tools::set_terminal_tool_app_handle(app.handle().clone()); - // tracing::info!("[启动] TerminalTool AppHandle 已设置"); - - // crate::agent::tools::set_term_scrollback_tool_app_handle(app.handle().clone()); - // tracing::info!("[启动] TermScrollbackTool AppHandle 已设置"); - // 初始化托盘管理器 // Requirements 1.4: 应用启动时显示停止状态图标 match TrayManager::new(app.handle()) { @@ -1345,6 +1339,8 @@ pub fn run() { commands::plugin_install_cmd::is_plugin_installed, // Plugin UI commands commands::plugin_cmd::get_plugins_with_ui, + commands::plugin_cmd::get_plugin_ui, + commands::plugin_cmd::handle_plugin_action, commands::plugin_cmd::read_plugin_manifest_cmd, commands::plugin_cmd::launch_plugin_ui, commands::plugin_cmd::frontend_debug_log, @@ -1387,9 +1383,6 @@ pub fn run() { commands::agent_cmd::agent_stop_process, commands::agent_cmd::agent_get_process_status, commands::agent_cmd::agent_generate_title, - // TODO: 重新启用这些命令,适配 aster-rust 工具系统 - // commands::agent_cmd::agent_terminal_command_response, - // commands::agent_cmd::agent_term_scrollback_response, // Aster Agent commands commands::aster_agent_cmd::aster_agent_init, commands::aster_agent_cmd::aster_agent_status, @@ -1398,10 +1391,17 @@ pub fn run() { commands::aster_agent_cmd::aster_agent_configure_from_pool, commands::aster_agent_cmd::agent_runtime_submit_turn, commands::aster_agent_cmd::agent_runtime_interrupt_turn, + commands::aster_agent_cmd::agent_runtime_promote_queued_turn, commands::aster_agent_cmd::agent_runtime_remove_queued_turn, commands::aster_agent_cmd::agent_runtime_create_session, commands::aster_agent_cmd::agent_runtime_list_sessions, commands::aster_agent_cmd::agent_runtime_get_session, + commands::aster_agent_cmd::agent_runtime_get_tool_inventory, + commands::aster_agent_cmd::agent_runtime_spawn_subagent, + commands::aster_agent_cmd::agent_runtime_send_subagent_input, + commands::aster_agent_cmd::agent_runtime_wait_subagents, + commands::aster_agent_cmd::agent_runtime_resume_subagent, + commands::aster_agent_cmd::agent_runtime_close_subagent, commands::aster_agent_cmd::agent_runtime_update_session, commands::aster_agent_cmd::agent_runtime_delete_session, commands::aster_agent_cmd::agent_runtime_respond_action, @@ -1478,6 +1478,10 @@ pub fn run() { commands::terminal_cmd::terminal_close, commands::terminal_cmd::terminal_list_sessions, commands::terminal_cmd::terminal_get_session, + // SubAgent commands + commands::subagent_cmd::init_subagent_scheduler, + commands::subagent_cmd::execute_subagent_tasks, + commands::subagent_cmd::cancel_subagent_tasks, // Connection commands commands::connection_cmd::connection_list, commands::connection_cmd::connection_add, @@ -1489,6 +1493,10 @@ pub fn run() { commands::connection_cmd::connection_save_raw_config, commands::connection_cmd::connection_test, commands::connection_cmd::connection_import_ssh_host, + // WebSocket commands + commands::websocket_cmd::get_websocket_status, + commands::websocket_cmd::get_websocket_connections, + commands::websocket_cmd::set_websocket_enabled, // Browser environment preset commands commands::browser_environment_cmd::list_browser_environment_presets_cmd, commands::browser_environment_cmd::save_browser_environment_preset_cmd, diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs index 89c98cab0..8327d584e 100644 --- a/src-tauri/src/commands/aster_agent_cmd.rs +++ b/src-tauri/src/commands/aster_agent_cmd.rs @@ -8,14 +8,32 @@ use crate::agent::aster_state::{ProviderConfig, SessionConfigBuilder}; use crate::agent::runtime_queue_service::{ clear_runtime_queue as clear_runtime_queue_service, list_runtime_queue_snapshots as list_runtime_queue_snapshots_service, + promote_runtime_queued_turn as promote_runtime_queued_turn_service, remove_runtime_queued_turn as remove_runtime_queued_turn_service, resume_persisted_runtime_queues_on_startup as resume_persisted_runtime_queues_on_startup_service, resume_runtime_queue_if_needed as resume_runtime_queue_if_needed_service, submit_runtime_turn as submit_runtime_turn_service, RuntimeQueueExecutor, }; use crate::agent::{ - AsterAgentState, AsterAgentWrapper, LimeScheduler, QueuedTurnSnapshot, QueuedTurnTask, - SessionDetail, SessionInfo, SubAgentRole, TauriAgentEvent, + AsterAgentState, AsterAgentWrapper, QueuedTurnSnapshot, QueuedTurnTask, SessionDetail, + SessionInfo, SubAgentRole, TauriAgentEvent, +}; +use crate::agent_tools::catalog::{ + browser_runtime_tool_prefix, build_mcp_extension_surface, creator_tool_names, + WorkspaceToolSurface, LIME_CREATE_BROADCAST_TASK_TOOL_NAME, LIME_CREATE_COVER_TASK_TOOL_NAME, + LIME_CREATE_IMAGE_TASK_TOOL_NAME, LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME, + LIME_CREATE_TYPESETTING_TASK_TOOL_NAME, LIME_CREATE_URL_PARSE_TASK_TOOL_NAME, + LIME_CREATE_VIDEO_TASK_TOOL_NAME, SOCIAL_IMAGE_TOOL_NAME, TOOL_SEARCH_TOOL_NAME, +}; +#[cfg(test)] +use crate::agent_tools::execution::build_workspace_shell_allow_pattern; +use crate::agent_tools::execution::{ + build_workspace_execution_permissions, should_auto_approve_tool_warnings, + ToolExecutionResolverInput, WorkspaceExecutionPermissionInput, +}; +use crate::agent_tools::inventory::{ + build_tool_inventory, resolve_extension_tool_runtime_status, AgentToolInventoryBuildInput, + ExtensionToolInventorySeed, }; use crate::commands::api_key_provider_cmd::ApiKeyProviderServiceState; use crate::commands::webview_cmd::{ @@ -36,20 +54,25 @@ use crate::services::web_search_runtime_service::apply_web_search_runtime_env; use crate::services::workspace_health_service::ensure_workspace_ready_with_auto_relocate; use crate::workspace::WorkspaceManager; use crate::LogState; -use aster::agents::extension::{Envs, ExtensionConfig}; -use aster::agents::subagent_scheduler::{SchedulerExecutionResult, SubAgentTask}; +use aster::agents::extension::ExtensionConfig; +use aster::agents::subagent_scheduler::SubAgentTask; use aster::agents::{Agent, AgentEvent}; use aster::chrome_mcp::get_chrome_mcp_tools; use aster::conversation::message::{Message, MessageContent}; use aster::permission::{ - ConditionOperator, ConditionType, ParameterRestriction, PermissionCondition, PermissionScope, - RestrictionType, ToolPermission, ToolPermissionManager, + ConditionOperator, ConditionType, PermissionCondition, PermissionScope, ToolPermission, + ToolPermissionManager, }; use aster::permission::{Permission, PermissionConfirmation, PrincipalType}; use aster::sandbox::{ detect_best_sandbox, execute_in_sandbox, ResourceLimits, SandboxConfig as ProcessSandboxConfig, }; -use aster::session::TurnContextOverride; +use aster::session::extension_data::{ExtensionData, ExtensionState}; +use aster::session::{ + list_subagent_child_sessions, require_shared_thread_runtime_store, + resolve_subagent_session_metadata, SessionManager, SessionType, SubagentSessionMetadata, + TurnContextOverride, +}; use aster::tools::task_output_tool::TaskOutputInput; use aster::tools::{ BashTool, KillShellTool, PermissionBehavior, PermissionCheckResult, TaskManager, @@ -58,6 +81,8 @@ use aster::tools::{ }; use async_trait::async_trait; use futures::{FutureExt, StreamExt}; +use lime_agent::event_converter::{TauriMessage, TauriMessageContent}; +use lime_agent::mcp_bridge::McpBridgeClient; #[cfg(test)] use lime_agent::request_tool_policy::REQUEST_TOOL_POLICY_MARKER; use lime_agent::request_tool_policy::{ @@ -65,8 +90,13 @@ use lime_agent::request_tool_policy::{ stream_message_reply_with_policy, ReplyAttemptError, RequestToolPolicy, RequestToolPolicyMode, }; use lime_agent::{ - durable_memory_permission_pattern, is_virtual_memory_path, message_suggests_news_expansion, - resolve_virtual_memory_path, virtual_memory_relative_path, TauriRuntimeStatus, + build_subagent_customization_prompt, builtin_profile_descriptor_by_id, + builtin_team_preset_descriptor_by_id, builtin_team_preset_label_by_id, is_virtual_memory_path, + list_subagent_cascade_session_ids, load_subagent_runtime_status, + message_suggests_news_expansion, read_subagent_control_state, resolve_virtual_memory_path, + summarize_builtin_skill, virtual_memory_relative_path, write_subagent_control_state, + SubagentControlState, SubagentCustomizationState, SubagentRuntimeStatus, + SubagentRuntimeStatusKind, SubagentSkillPromptBlock, SubagentSkillSummary, TauriRuntimeStatus, DURABLE_MEMORY_VIRTUAL_ROOT, }; use lime_services::api_key_provider_service::ApiKeyProviderService; @@ -75,7 +105,7 @@ use lime_services::video_generation_service::{ CreateVideoGenerationRequest, VideoGenerationService, }; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -100,19 +130,13 @@ const WORKSPACE_SANDBOX_NOTIFY_ENV_KEYS: &[&str] = &[ ]; const WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE: &str = "workspace_sandbox_fallback"; const WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE: &str = "workspace_path_auto_created"; -const SOCIAL_IMAGE_TOOL_NAME: &str = "social_generate_cover_image"; +const DEFAULT_TEAM_MAX_ACTIVE_SUBAGENTS: usize = 3; const SOCIAL_IMAGE_DEFAULT_MODEL: &str = "gemini-3-pro-image-preview"; const SOCIAL_IMAGE_DEFAULT_SIZE: &str = "1024x1024"; const SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT: &str = "url"; -const LIME_CREATE_VIDEO_TASK_TOOL_NAME: &str = "lime_create_video_generation_task"; -const LIME_CREATE_BROADCAST_TASK_TOOL_NAME: &str = "lime_create_broadcast_generation_task"; -const LIME_CREATE_COVER_TASK_TOOL_NAME: &str = "lime_create_cover_generation_task"; -const LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME: &str = "lime_create_modal_resource_search_task"; -const LIME_CREATE_IMAGE_TASK_TOOL_NAME: &str = "lime_create_image_generation_task"; -const LIME_CREATE_URL_PARSE_TASK_TOOL_NAME: &str = "lime_create_url_parse_task"; -const LIME_CREATE_TYPESETTING_TASK_TOOL_NAME: &str = "lime_create_typesetting_task"; const AUTO_CONTINUE_PROMPT_MARKER: &str = "【自动续写策略】"; const ELICITATION_CONTEXT_PROMPT_MARKER: &str = "【已收集的补充信息】"; +const TEAM_PREFERENCE_PROMPT_MARKER: &str = "【Team 协作偏好】"; const LIME_TOOL_METADATA_BEGIN: &str = "[Lime 工具元数据开始]"; const LIME_TOOL_METADATA_END: &str = "[Lime 工具元数据结束]"; const FORCE_REACT_HINT_ENV_KEYS: &[&str] = @@ -266,6 +290,19 @@ pub struct ConfigureFromPoolRequest { pub model_name: String, } +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRuntimeToolInventoryRequest { + #[serde(default)] + pub creator: bool, + #[serde(default)] + pub browser_assist: bool, + #[serde(default)] + pub caller: Option, + #[serde(default)] + pub metadata: Option, +} + /// 初始化 Aster Agent #[tauri::command] pub async fn aster_agent_init( @@ -275,7 +312,6 @@ pub async fn aster_agent_init( tracing::info!("[AsterAgent] 初始化 Agent"); state.init_agent_with_db(&db).await?; - ensure_browser_mcp_tools_registered(state.inner()).await?; ensure_tool_search_tool_registered(state.inner()).await?; let provider_config = state.get_provider_config().await; @@ -305,17 +341,31 @@ pub async fn aster_agent_configure_provider( request.model_name ); + let provider_selector = request + .provider_id + .clone() + .or_else(|| Some(request.provider_name.clone())); let config = ProviderConfig { provider_name: request.provider_name, + provider_selector, model_name: request.model_name, api_key: request.api_key, base_url: request.base_url, credential_uuid: None, + force_responses_api: false, }; state .configure_provider(config.clone(), &session_id, &db) .await?; + persist_session_provider_routing( + &session_id, + config + .provider_selector + .as_deref() + .unwrap_or(&config.provider_name), + ) + .await?; Ok(AsterAgentStatus { initialized: true, @@ -350,6 +400,7 @@ pub async fn aster_agent_configure_from_pool( &session_id, ) .await?; + persist_session_provider_routing(&session_id, &request.provider_type).await?; Ok(AsterAgentStatus { initialized: true, @@ -535,6 +586,14 @@ pub struct AgentRuntimeRemoveQueuedTurnRequest { pub queued_turn_id: String, } +#[derive(Debug, Deserialize)] +pub struct AgentRuntimePromoteQueuedTurnRequest { + #[serde(alias = "sessionId")] + pub session_id: String, + #[serde(alias = "queuedTurnId")] + pub queued_turn_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentRuntimeSessionDetail { pub id: String, @@ -550,6 +609,10 @@ pub struct AgentRuntimeSessionDetail { pub todo_items: Vec, #[serde(default)] pub queued_turns: Vec, + #[serde(default)] + pub child_subagent_sessions: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub subagent_parent_context: Option, } impl AgentRuntimeSessionDetail { @@ -566,10 +629,104 @@ impl AgentRuntimeSessionDetail { items: detail.items, todo_items: detail.todo_items, queued_turns, + child_subagent_sessions: detail.child_subagent_sessions, + subagent_parent_context: detail.subagent_parent_context, } } } +#[derive(Debug, Clone, Deserialize)] +pub struct AgentRuntimeSpawnSubagentRequest { + #[serde(alias = "parentSessionId")] + pub parent_session_id: String, + pub message: String, + #[serde(default, alias = "agentType")] + pub agent_type: Option, + #[serde(default)] + pub model: Option, + #[serde(default, alias = "reasoningEffort")] + pub reasoning_effort: Option, + #[serde(default, alias = "forkContext")] + pub fork_context: bool, + #[serde(default, alias = "profileId")] + pub profile_id: Option, + #[serde(default, alias = "profileName")] + pub profile_name: Option, + #[serde(default, alias = "roleKey")] + pub role_key: Option, + #[serde(default, alias = "skillIds")] + pub skill_ids: Vec, + #[serde(default, alias = "skillDirectories")] + pub skill_directories: Vec, + #[serde(default, alias = "teamPresetId")] + pub team_preset_id: Option, + #[serde(default)] + pub theme: Option, + #[serde(default, alias = "systemOverlay")] + pub system_overlay: Option, + #[serde(default, alias = "outputContract")] + pub output_contract: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeSpawnSubagentResponse { + #[serde(alias = "agentId")] + pub agent_id: String, + #[serde(default)] + pub nickname: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentRuntimeSendSubagentInputRequest { + pub id: String, + pub message: String, + #[serde(default)] + pub interrupt: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeSendSubagentInputResponse { + #[serde(alias = "submissionId")] + pub submission_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentRuntimeWaitSubagentsRequest { + pub ids: Vec, + #[serde(default, alias = "timeoutMs")] + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeWaitSubagentsResponse { + pub status: HashMap, + pub timed_out: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentRuntimeResumeSubagentRequest { + pub id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeResumeSubagentResponse { + pub status: SubagentRuntimeStatus, + pub cascade_session_ids: Vec, + pub changed_session_ids: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentRuntimeCloseSubagentRequest { + pub id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeCloseSubagentResponse { + pub previous_status: SubagentRuntimeStatus, + pub cascade_session_ids: Vec, + pub changed_session_ids: Vec, +} + #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AgentRuntimeActionType { @@ -593,6 +750,8 @@ pub struct AgentRuntimeRespondActionRequest { pub user_data: Option, #[serde(default)] pub metadata: Option, + #[serde(default, alias = "eventName")] + pub event_name: Option, } #[derive(Debug, Deserialize)] @@ -826,6 +985,157 @@ fn merge_system_prompt_with_elicitation_context( } } +fn build_team_preference_system_prompt( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let subagent_mode_enabled = extract_harness_bool( + request_metadata, + &["subagent_mode_enabled", "subagentModeEnabled"], + ) + .unwrap_or(false); + let preferred_team_preset_id = extract_harness_string( + request_metadata, + &["preferred_team_preset_id", "preferredTeamPresetId"], + ); + let selected_team_source = extract_harness_string( + request_metadata, + &["selected_team_source", "selectedTeamSource"], + ); + let selected_team_label = extract_harness_string( + request_metadata, + &["selected_team_label", "selectedTeamLabel"], + ); + let selected_team_summary = extract_harness_string( + request_metadata, + &["selected_team_summary", "selectedTeamSummary"], + ); + let selected_team_roles = extract_harness_array( + request_metadata, + &["selected_team_roles", "selectedTeamRoles"], + ); + + if !subagent_mode_enabled { + return None; + } + + let mut lines = vec![TEAM_PREFERENCE_PROMPT_MARKER.to_string()]; + if subagent_mode_enabled { + lines.push( + "- 当前 GUI 已开启 Team 模式,但只有在任务确实适合拆分、并行或隔离上下文时才进入 team。" + .to_string(), + ); + } + + if let Some(team_preset_id) = preferred_team_preset_id.as_deref() { + let preset_label = + builtin_team_preset_label_by_id(team_preset_id).unwrap_or(team_preset_id); + lines.push(format!( + "- 用户偏好的 Team Preset:{preset_label} ({team_preset_id})。" + )); + lines.push( + "- 当你判断当前任务适合多代理时,优先沿用该 preset 的 profile / skill 组合去调用 spawn_agent。" + .to_string(), + ); + } + + if let Some(team_label) = selected_team_label.as_deref() { + let source_suffix = selected_team_source + .as_deref() + .map(|source| format!(" / 来源:{source}")) + .unwrap_or_default(); + lines.push(format!( + "- 当前 GUI 已选 Team:{team_label}{source_suffix}。" + )); + } + + if let Some(team_summary) = selected_team_summary.as_deref() { + lines.push(format!("- Team 摘要:{team_summary}")); + } + + if let Some(role_items) = selected_team_roles { + let rendered_roles = role_items + .iter() + .filter_map(|value| { + let object = value.as_object()?; + let label = object + .get("label") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let summary = object + .get("summary") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("负责当前分工。"); + let profile_suffix = object + .get("profile_id") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!(" / profile: {value}")) + .unwrap_or_default(); + let skill_suffix = object + .get("skill_ids") + .and_then(serde_json::Value::as_array) + .map(|items| { + items + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + }) + .filter(|items| !items.is_empty()) + .map(|items| format!(" / skills: {}", items.join(", "))) + .unwrap_or_default(); + + Some(format!( + " - {label}:{summary}{profile_suffix}{skill_suffix}" + )) + }) + .collect::>(); + + if !rendered_roles.is_empty() { + lines.push("- 当前 Team 角色参考:".to_string()); + lines.extend(rendered_roles); + } + } + + lines.push( + "- spawn_agent 支持这些结构化字段:teamPresetId、profileId、profileName、roleKey、skillIds、skillDirectories、theme、systemOverlay、outputContract。" + .to_string(), + ); + lines.push( + "- 如果任务简单、强依赖当前上下文或下一步立即阻塞在结果上,不要为了套用 preset 而滥用 team。" + .to_string(), + ); + + Some(lines.join("\n")) +} + +fn merge_system_prompt_with_team_preference( + base_prompt: Option, + request_metadata: Option<&serde_json::Value>, +) -> Option { + let Some(team_prompt) = build_team_preference_system_prompt(request_metadata) else { + return base_prompt; + }; + + match base_prompt { + Some(base) => { + if base.contains(TEAM_PREFERENCE_PROMPT_MARKER) { + Some(base) + } else if base.trim().is_empty() { + Some(team_prompt) + } else { + Some(format!("{base}\n\n{team_prompt}")) + } + } + None => Some(team_prompt), + } +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] struct SocialRunArtifactDescriptor { artifact_id: String, @@ -1053,6 +1363,16 @@ fn extract_harness_bool( .find_map(serde_json::Value::as_bool) } +fn extract_harness_array<'a>( + request_metadata: Option<&'a serde_json::Value>, + keys: &[&str], +) -> Option<&'a Vec> { + let harness = extract_harness_object(request_metadata)?; + keys.iter() + .filter_map(|key| harness.get(*key)) + .find_map(serde_json::Value::as_array) +} + fn extract_harness_nested_object<'a>( request_metadata: Option<&'a serde_json::Value>, keys: &[&str], @@ -1688,6 +2008,18 @@ fn extend_map_with_harness_fields( ("runTitle", "run_title"), ("content_id", "content_id"), ("contentId", "content_id"), + ("preferred_team_preset_id", "preferred_team_preset_id"), + ("preferredTeamPresetId", "preferred_team_preset_id"), + ("selected_team_id", "selected_team_id"), + ("selectedTeamId", "selected_team_id"), + ("selected_team_source", "selected_team_source"), + ("selectedTeamSource", "selected_team_source"), + ("selected_team_label", "selected_team_label"), + ("selectedTeamLabel", "selected_team_label"), + ("selected_team_summary", "selected_team_summary"), + ("selectedTeamSummary", "selected_team_summary"), + ("selected_team_roles", "selected_team_roles"), + ("selectedTeamRoles", "selected_team_roles"), ("browser_requirement", "browser_requirement"), ("browserRequirement", "browser_requirement"), ("browser_requirement_reason", "browser_requirement_reason"), @@ -2189,6 +2521,12 @@ where if let Err(error) = app.emit(event_name, event) { tracing::error!("[AsterAgent] 发送事件失败: {}", error); } + let app = app.clone(); + let event_name = event_name.to_string(); + let event = event.clone(); + tokio::spawn(async move { + maybe_emit_subagent_status_for_runtime_event(&app, &event_name, &event).await; + }); }, ) .await @@ -3123,48 +3461,214 @@ fn build_subagent_task_definition( Ok(task) } -fn summarize_subagent_execution( +fn build_subagent_task_runtime_message( + input: &SubAgentTaskToolInput, + task: &SubAgentTask, role: SubAgentRole, - execution: &SchedulerExecutionResult, ) -> String { - let merged_summary = execution - .merged_summary + let mut sections = Vec::new(); + + if let Some(description) = input + .description .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .map(ToString::to_string) + { + sections.push(format!("任务标题:{description}")); + } + + sections.push(format!("子代理角色:{role}")); + + if let Some(task_type) = input + .task_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + sections.push(format!("任务类型:{task_type}")); + } + + if let Some(allowed_tools) = input + .allowed_tools + .as_ref() + .filter(|items| !items.is_empty()) + { + sections.push(format!( + "工具偏好:优先仅使用这些工具:{}", + allowed_tools.join(", ") + )); + } + + if let Some(denied_tools) = input + .denied_tools + .as_ref() + .filter(|items| !items.is_empty()) + { + sections.push(format!("避免使用这些工具:{}", denied_tools.join(", "))); + } + + if let Some(max_tokens) = input.max_tokens.filter(|value| *value > 0) { + sections.push(format!( + "输出控制:请尽量将最终输出控制在 {max_tokens} tokens 内。" + )); + } + + sections.push( + "协作约束:你不是唯一工作线程。请只处理当前明确分配的子任务,不要重复主线程或其他子代理的工作,不要再创建新的子代理。" + .to_string(), + ); + + sections.push("任务说明:".to_string()); + sections.push(task.prompt.clone()); + + sections.join("\n") +} + +fn collect_subagent_task_compat_warnings(input: &SubAgentTaskToolInput) -> Vec { + let mut warnings = Vec::new(); + + if input + .allowed_tools + .as_ref() + .is_some_and(|items| !items.is_empty()) + { + warnings + .push("allowedTools 已降级为对子代理的提示,不再由旧 scheduler 做硬限制".to_string()); + } + + if input + .denied_tools + .as_ref() + .is_some_and(|items| !items.is_empty()) + { + warnings + .push("deniedTools 已降级为对子代理的提示,不再由旧 scheduler 做硬限制".to_string()); + } + + if input.max_tokens.is_some_and(|value| value > 0) { + warnings.push("maxTokens 已降级为输出提示,当前 team runtime 不做强制截断".to_string()); + } + + warnings +} + +fn extract_tauri_message_text(message: &TauriMessage) -> Option { + let parts = message + .content + .iter() + .filter_map(|content| match content { + TauriMessageContent::Text { text } => { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + TauriMessageContent::ToolResponse { + output, success, .. + } if *success => { + let trimmed = output.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + _ => None, + }) + .collect::>(); + + if parts.is_empty() { + None + } else { + Some(parts.join("\n\n")) + } +} + +fn extract_runtime_subagent_result_text(detail: &SessionDetail) -> Option { + detail + .messages + .iter() + .rev() + .find(|message| message.role == "assistant") + .and_then(extract_tauri_message_text) .or_else(|| { - execution.results.iter().find_map(|result| { - result - .summary + detail.items.iter().rev().find_map(|item| { + match &item.payload { + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::TurnSummary { + text, + } + | lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Plan { text } + | lime_core::database::dao::agent_timeline::AgentThreadItemPayload::AgentMessage { + text, + .. + } + | lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Reasoning { + text, + .. + } => { + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::Error { + message, + } => { + let trimmed = message.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::SubagentActivity { + summary, + .. + } => summary .as_deref() - .or(result.output.as_deref()) .map(str::trim) .filter(|value| !value.is_empty()) - .map(ToString::to_string) + .map(ToString::to_string), + _ => None, + } }) }) + .or_else(|| { + detail + .turns + .iter() + .rev() + .find_map(|turn| turn.error_message.clone()) + .map(|message| message.trim().to_string()) + .filter(|value| !value.is_empty()) + }) +} + +fn summarize_runtime_subagent_execution( + role: SubAgentRole, + status: &SubagentRuntimeStatus, + detail: Option<&SessionDetail>, +) -> String { + let result_text = detail + .and_then(extract_runtime_subagent_result_text) .unwrap_or_else(|| "未返回摘要".to_string()); - format!( - "SubAgent({}) 完成:成功 {},失败 {},跳过 {}。{}", - role, - execution.successful_count, - execution.failed_count, - execution.skipped_count, - merged_summary - ) + match status.kind { + SubagentRuntimeStatusKind::Completed => { + format!("子代理({role}) 已通过 team runtime 完成任务。\n\n{result_text}") + } + SubagentRuntimeStatusKind::Failed | SubagentRuntimeStatusKind::Aborted => { + format!("子代理({role}) 执行失败。\n\n{result_text}") + } + SubagentRuntimeStatusKind::Closed => { + format!("子代理({role}) 已关闭。\n\n{result_text}") + } + SubagentRuntimeStatusKind::NotFound => { + format!("子代理({role}) 未找到,无法获取结果。") + } + _ => format!( + "子代理({role}) 当前状态为 {:?}。\n\n{result_text}", + status.kind + ), + } } #[derive(Debug, Clone)] struct SubAgentTaskTool { - db: DbConnection, - app_handle: AppHandle, + runtime: SubagentControlRuntime, } impl SubAgentTaskTool { - fn new(db: DbConnection, app_handle: AppHandle) -> Self { - Self { db, app_handle } + fn new(runtime: SubagentControlRuntime) -> Self { + Self { runtime } } } @@ -3175,7 +3679,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。" } fn input_schema(&self) -> serde_json::Value { @@ -3249,41 +3753,563 @@ impl Tool for SubAgentTaskTool { let role = parse_subagent_role(input.role.as_deref())?; let task = build_subagent_task_definition(&input, role)?; let task_id = task.id.clone(); + let parent_session_id = normalize_required_text(&context.session_id, "session_id") + .map_err(ToolError::invalid_params)?; + let compat_warnings = collect_subagent_task_compat_warnings(&input); + let response = agent_runtime_spawn_subagent_internal( + &self.runtime, + AgentRuntimeSpawnSubagentRequest { + parent_session_id, + message: build_subagent_task_runtime_message(&input, &task, role), + agent_type: Some(role.to_string()), + model: input.model.clone(), + reasoning_effort: None, + fork_context: false, + 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, + }, + ) + .await + .map_err(|error| { + ToolError::execution_failed(format!( + "SubAgentTask 已切到 team runtime,但创建子代理失败: {error}" + )) + })?; - let mut scheduler = - LimeScheduler::new(self.db.clone()).with_app_handle(self.app_handle.clone()); - if !context.session_id.trim().is_empty() { - scheduler = scheduler.with_event_session_id(context.session_id.clone()); - } - scheduler.init(None).await; + let timeout_ms = input + .timeout_secs + .unwrap_or(900) + .saturating_mul(1000) + .min(i64::MAX as u64) as i64; + let wait_result = agent_runtime_wait_subagents_internal( + &self.runtime, + AgentRuntimeWaitSubagentsRequest { + ids: vec![response.agent_id.clone()], + timeout_ms: Some(timeout_ms), + }, + ) + .await + .map_err(|error| { + ToolError::execution_failed(format!( + "SubAgentTask 已创建子代理,但等待结果失败: {error}" + )) + })?; - let execution = scheduler - .execute_with_role(vec![task], None, role) - .await - .map_err(|err| ToolError::execution_failed(format!("SubAgentTask 执行失败: {err}")))?; + let detail = + AsterAgentWrapper::get_runtime_session_detail(&self.runtime.db, &response.agent_id) + .await + .ok(); + let status = wait_result + .status + .get(&response.agent_id) + .cloned() + .unwrap_or(SubagentRuntimeStatus { + session_id: response.agent_id.clone(), + kind: if wait_result.timed_out { + SubagentRuntimeStatusKind::Running + } else { + SubagentRuntimeStatusKind::NotFound + }, + latest_turn_id: None, + latest_turn_status: None, + queued_turn_count: 0, + closed: false, + }); - let summary = summarize_subagent_execution(role, &execution); + let summary = if wait_result.timed_out { + format!( + "子代理({role}) 已创建,但在 {} 秒内未完成。可以继续通过 team workspace 跟踪: {}", + input.timeout_secs.unwrap_or(900), + response.agent_id + ) + } else { + summarize_runtime_subagent_execution(role, &status, detail.as_ref()) + }; let metadata = serde_json::json!({ "task_id": task_id, + "agent_id": response.agent_id, + "nickname": response.nickname, "role": role.to_string(), - "success": execution.success, - "successful_count": execution.successful_count, - "failed_count": execution.failed_count, - "skipped_count": execution.skipped_count, - "merged_summary": execution.merged_summary, - "results": execution.results, - "total_token_usage": execution.total_token_usage, + "status": status, + "timed_out": wait_result.timed_out, + "compat_mode": "subagent_task->spawn_agent", + "compat_warnings": compat_warnings, }); - if execution.success { - Ok(ToolResult::success(summary) - .with_metadata("subagent", metadata) - .with_metadata("role", serde_json::json!(role.to_string()))) + let success = !wait_result.timed_out && status.kind == SubagentRuntimeStatusKind::Completed; + let result = if success { + ToolResult::success(summary) } else { - Ok(ToolResult::error(summary) - .with_metadata("subagent", metadata) - .with_metadata("role", serde_json::json!(role.to_string()))) - } + ToolResult::error(summary) + }; + + Ok(result + .with_metadata("subagent", metadata) + .with_metadata("role", serde_json::json!(role.to_string()))) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SpawnAgentToolInput { + message: String, + agent_type: Option, + model: Option, + reasoning_effort: Option, + fork_context: Option, + profile_id: Option, + profile_name: Option, + role_key: Option, + #[serde(default)] + skill_ids: Vec, + #[serde(default)] + skill_directories: Vec, + team_preset_id: Option, + theme: Option, + system_overlay: Option, + output_contract: Option, +} + +#[derive(Debug, Clone)] +struct SpawnAgentTool { + runtime: SubagentControlRuntime, +} + +impl SpawnAgentTool { + fn new(runtime: SubagentControlRuntime) -> Self { + Self { runtime } + } +} + +#[async_trait] +impl Tool for SpawnAgentTool { + fn name(&self) -> &str { + "spawn_agent" + } + + fn description(&self) -> &str { + "仅在任务需要拆成多个独立子范围、并行评审/验证,或用户明确要求多代理时使用。先判断当前关键路径:如果下一步立即依赖结果,不要把阻塞工作委派出去;优先把可并行推进的 sidecar 子任务交给子代理,同时主线程继续做不重叠的工作。创建真实子代理会话,并异步开始执行首条任务。不要对简单任务创建子代理;多个子代理必须分工明确,避免修改同一片文件;当前 team runtime 默认不允许子代理继续创建新的子代理。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "发送给子代理的首条任务消息。应是边界清晰、可独立完成、不会与其他并发子代理写入范围重叠的子任务。" + }, + "agentType": { + "type": "string", + "description": "子代理角色提示,例如 explorer/planner/executor,也可以是 Image #1 这类展示标签" + }, + "model": { + "type": "string", + "description": "可选模型覆盖" + }, + "reasoningEffort": { + "type": "string", + "description": "保留字段,当前仅记录到 metadata" + }, + "forkContext": { + "type": "boolean", + "description": "保留字段,当前仅记录到 metadata" + }, + "profileId": { + "type": "string", + "description": "可选内置 profile id,例如 code-explorer / code-executor / code-verifier" + }, + "profileName": { + "type": "string", + "description": "可选 profile 展示名称,用于 Team Workspace 与子代理 prompt" + }, + "roleKey": { + "type": "string", + "description": "可选角色键,例如 explorer / executor / verifier / researcher" + }, + "skillIds": { + "type": "array", + "items": { "type": "string" }, + "description": "可选 builtin skill id 列表,用于附加子代理技能提示" + }, + "skillDirectories": { + "type": "array", + "items": { "type": "string" }, + "description": "可选本地已安装 skill 目录名;会读取对应 SKILL.md 注入子代理 prompt" + }, + "teamPresetId": { + "type": "string", + "description": "可选 team preset id,例如 code-triage-team / research-team / content-creation-team" + }, + "theme": { + "type": "string", + "description": "可选子代理主题标签,用于 GUI 展示与 prompt 约束" + }, + "systemOverlay": { + "type": "string", + "description": "附加给该子代理的额外系统约束" + }, + "outputContract": { + "type": "string", + "description": "要求子代理遵循的输出契约" + } + }, + "required": ["message"], + "additionalProperties": false + }) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: SpawnAgentToolInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("spawn_agent 参数无效: {error}")))?; + let response = agent_runtime_spawn_subagent_internal( + &self.runtime, + AgentRuntimeSpawnSubagentRequest { + parent_session_id: context.session_id.clone(), + message: input.message, + agent_type: input.agent_type, + model: input.model, + reasoning_effort: input.reasoning_effort, + fork_context: input.fork_context.unwrap_or(false), + profile_id: input.profile_id, + profile_name: input.profile_name, + role_key: input.role_key, + skill_ids: input.skill_ids, + skill_directories: input.skill_directories, + team_preset_id: input.team_preset_id, + theme: input.theme, + system_overlay: input.system_overlay, + output_contract: input.output_contract, + }, + ) + .await + .map_err(ToolError::execution_failed)?; + + Ok( + ToolResult::success(format!("子代理已创建: {}", response.agent_id)).with_metadata( + "spawn_agent", + serde_json::to_value(&response).unwrap_or_default(), + ), + ) + } +} + +#[derive(Debug, Clone, Deserialize)] +struct SendInputToolInput { + id: String, + message: String, + #[serde(default)] + interrupt: bool, +} + +#[derive(Debug, Clone)] +struct SendInputTool { + runtime: SubagentControlRuntime, +} + +impl SendInputTool { + fn new(runtime: SubagentControlRuntime) -> Self { + Self { runtime } + } +} + +#[async_trait] +impl Tool for SendInputTool { + fn name(&self) -> &str { + "send_input" + } + + fn description(&self) -> &str { + "向已存在的子代理追加输入。对强依赖既有上下文的后续任务,优先复用已有子代理而不是重复 spawn;interrupt=true 时会先中断当前执行并清空旧队列。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "子代理 session id" + }, + "message": { + "type": "string", + "description": "要发送给子代理的输入" + }, + "interrupt": { + "type": "boolean", + "description": "是否先中断当前执行" + } + }, + "required": ["id", "message"], + "additionalProperties": false + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _context: &ToolContext, + ) -> Result { + let input: SendInputToolInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("send_input 参数无效: {error}")))?; + let response = agent_runtime_send_subagent_input_internal( + &self.runtime, + AgentRuntimeSendSubagentInputRequest { + id: input.id, + message: input.message, + interrupt: input.interrupt, + }, + ) + .await + .map_err(ToolError::execution_failed)?; + + Ok( + ToolResult::success(format!("子代理输入已提交: {}", response.submission_id)) + .with_metadata( + "send_input", + serde_json::to_value(&response).unwrap_or_default(), + ), + ) + } +} + +#[derive(Debug, Clone, Deserialize)] +struct WaitAgentToolInput { + ids: Vec, + #[serde(default, alias = "timeoutMs")] + timeout_ms: Option, +} + +#[derive(Debug, Clone)] +struct WaitAgentTool { + runtime: SubagentControlRuntime, +} + +impl WaitAgentTool { + fn new(runtime: SubagentControlRuntime) -> Self { + Self { runtime } + } +} + +#[async_trait] +impl Tool for WaitAgentTool { + fn name(&self) -> &str { + "wait_agent" + } + + fn description(&self) -> &str { + "等待一个或多个子代理进入最终状态。只有在主线程确实被结果阻塞、下一步必须依赖这些结果时才调用;可以同时等待多个 id,任一子代理先完成就会返回。不要反复机械 wait,优先在等待前继续做不重叠的本地工作;timeout_ms 应与任务规模匹配,避免过短轮询。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { "type": "string" }, + "description": "要等待的子代理 session id 列表" + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "description": "最长等待时间(毫秒)" + } + }, + "required": ["ids"], + "additionalProperties": false + }) + } + + fn options(&self) -> ToolOptions { + ToolOptions::new() + .with_max_retries(0) + .with_base_timeout(Duration::from_secs(310)) + .with_dynamic_timeout(false) + } + + async fn execute( + &self, + params: serde_json::Value, + _context: &ToolContext, + ) -> Result { + let input: WaitAgentToolInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("wait_agent 参数无效: {error}")))?; + let response = agent_runtime_wait_subagents_internal( + &self.runtime, + AgentRuntimeWaitSubagentsRequest { + ids: input.ids, + timeout_ms: input.timeout_ms, + }, + ) + .await + .map_err(ToolError::execution_failed)?; + let summary = if response.timed_out { + "wait_agent 超时,未观测到最终状态".to_string() + } else { + format!("已观测到 {} 个子代理进入最终状态", response.status.len()) + }; + + Ok(ToolResult::success(summary).with_metadata( + "wait_agent", + serde_json::to_value(&response).unwrap_or_default(), + )) + } +} + +#[derive(Debug, Clone, Deserialize)] +struct ResumeAgentToolInput { + id: String, +} + +#[derive(Debug, Clone)] +struct ResumeAgentTool { + runtime: SubagentControlRuntime, +} + +impl ResumeAgentTool { + fn new(runtime: SubagentControlRuntime) -> Self { + Self { runtime } + } +} + +#[async_trait] +impl Tool for ResumeAgentTool { + fn name(&self) -> &str { + "resume_agent" + } + + fn description(&self) -> &str { + "恢复之前关闭的子代理;若子代理未关闭则返回当前状态" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "子代理 session id" + } + }, + "required": ["id"], + "additionalProperties": false + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _context: &ToolContext, + ) -> Result { + let input: ResumeAgentToolInput = serde_json::from_value(params).map_err(|error| { + ToolError::invalid_params(format!("resume_agent 参数无效: {error}")) + })?; + let response = agent_runtime_resume_subagent_internal( + &self.runtime, + AgentRuntimeResumeSubagentRequest { id: input.id }, + ) + .await + .map_err(ToolError::execution_failed)?; + + let changed_count = response.changed_session_ids.len(); + let success_message = if changed_count > 1 { + format!("子代理已恢复,并级联恢复 {changed_count} 个会话") + } else if changed_count == 1 { + "子代理已恢复".to_string() + } else { + format!("子代理当前状态: {:?}", response.status.kind) + }; + + Ok(ToolResult::success(success_message).with_metadata( + "resume_agent", + serde_json::to_value(&response).unwrap_or_default(), + )) + } +} + +#[derive(Debug, Clone, Deserialize)] +struct CloseAgentToolInput { + id: String, +} + +#[derive(Debug, Clone)] +struct CloseAgentTool { + runtime: SubagentControlRuntime, +} + +impl CloseAgentTool { + fn new(runtime: SubagentControlRuntime) -> Self { + Self { runtime } + } +} + +#[async_trait] +impl Tool for CloseAgentTool { + fn name(&self) -> &str { + "close_agent" + } + + fn description(&self) -> &str { + "关闭子代理并级联关闭其子树;历史保留,可后续恢复" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "子代理 session id" + } + }, + "required": ["id"], + "additionalProperties": false + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _context: &ToolContext, + ) -> Result { + let input: CloseAgentToolInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("close_agent 参数无效: {error}")))?; + let response = agent_runtime_close_subagent_internal( + &self.runtime, + AgentRuntimeCloseSubagentRequest { id: input.id }, + ) + .await + .map_err(ToolError::execution_failed)?; + + let changed_count = response.changed_session_ids.len(); + let success_message = if changed_count > 1 { + format!( + "子代理已关闭,并级联关闭 {changed_count} 个会话;关闭前状态: {:?}", + response.previous_status.kind + ) + } else { + format!( + "子代理已关闭,关闭前状态: {:?}", + response.previous_status.kind + ) + }; + + Ok(ToolResult::success(success_message).with_metadata( + "close_agent", + serde_json::to_value(&response).unwrap_or_default(), + )) } } @@ -4477,11 +5503,18 @@ impl Tool for LimeCreateVideoGenerationTaskTool { struct ToolSearchBridgeTool { registry: Arc>, + extension_manager: Option>, } impl ToolSearchBridgeTool { - fn new(registry: Arc>) -> Self { - Self { registry } + fn new( + registry: Arc>, + extension_manager: Option>, + ) -> Self { + Self { + registry, + extension_manager, + } } fn with_input_examples_in_schema( @@ -4513,6 +5546,7 @@ impl ToolSearchBridgeTool { enriched } + #[cfg(test)] fn parse_schema_metadata( tool_name: &str, schema: &serde_json::Value, @@ -4523,93 +5557,47 @@ impl ToolSearchBridgeTool { Vec, // tags Vec, // input_examples ) { - let extension = schema - .get("x-lime") - .or_else(|| schema.get("x_lime")) - .unwrap_or(schema); - - let deferred_loading = extension - .get("deferred_loading") - .or_else(|| extension.get("deferredLoading")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let always_visible = extension - .get("always_visible") - .or_else(|| extension.get("alwaysVisible")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let allowed_callers = extension - .get("allowed_callers") - .or_else(|| extension.get("allowedCallers")) - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .map(|v| v.trim().to_ascii_lowercase()) - .filter(|v| !v.is_empty()) - .collect::>() - }) - .unwrap_or_default(); - let tags = extension - .get("tags") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .map(|v| v.trim().to_ascii_lowercase()) - .filter(|v| !v.is_empty()) - .collect::>() - }) - .unwrap_or_default(); - let input_examples = - lime_core::tool_calling::resolve_tool_input_examples(tool_name, schema); + let metadata = lime_core::tool_calling::extract_tool_surface_metadata(tool_name, schema); ( - deferred_loading, - always_visible, - allowed_callers, - tags, - input_examples, + metadata.deferred_loading.unwrap_or(false), + metadata.always_visible.unwrap_or(false), + metadata.allowed_callers.unwrap_or_default(), + metadata.tags.unwrap_or_default(), + metadata.input_examples, ) } fn score_match(name: &str, description: &str, tags: &[String], query: &str) -> i32 { - if query.is_empty() { - return 1; - } - let name_lc = name.to_ascii_lowercase(); - let description_lc = description.to_ascii_lowercase(); + lime_core::tool_calling::score_tool_match(name, description, tags, query) + } - let mut score = 0; - if name_lc == query { - score += 120; - } else if name_lc.starts_with(query) { - score += 90; - } else if name_lc.contains(query) { - score += 70; - } - if description_lc.contains(query) { - score += 40; - } - for tag in tags { - if tag == query { - score += 35; - } else if tag.contains(query) { - score += 20; - } - } - score + fn extension_tool_status( + extension_configs: &[ExtensionConfig], + visible_extension_tools: &HashSet, + tool_name: &str, + ) -> (&'static str, bool, Option) { + let status = resolve_extension_tool_runtime_status( + extension_configs, + visible_extension_tools, + tool_name, + ); + ( + status.status, + status.deferred_loading, + status.extension_name, + ) } } #[async_trait] impl Tool for ToolSearchBridgeTool { fn name(&self) -> &str { - "tool_search" + TOOL_SEARCH_TOOL_NAME } fn description(&self) -> &str { - "搜索当前会话可用工具;默认会过滤 deferred_loading 工具,并按调用方做 allowed_callers 约束。" + "统一搜索当前会话工具面:包含原生 registry 工具与 extension/MCP 工具。对 deferred 工具会返回加载提示。" } fn input_schema(&self) -> serde_json::Value { @@ -4671,15 +5659,22 @@ impl Tool for ToolSearchBridgeTool { .into_iter() .filter(|d| d.name != self.name()) .filter_map(|definition| { - let (deferred_loading, always_visible, allowed_callers, tags, input_examples) = - Self::parse_schema_metadata(&definition.name, &definition.input_schema); - if deferred_loading && !always_visible && !include_deferred { + let metadata = lime_core::tool_calling::extract_tool_surface_metadata( + &definition.name, + &definition.input_schema, + ); + if !lime_core::tool_calling::tool_visible_in_context(&metadata, include_deferred) { return None; } - if !allowed_callers.is_empty() && !allowed_callers.contains(&caller) { + if !lime_core::tool_calling::tool_matches_caller(&metadata, Some(&caller)) { return None; } + let deferred_loading = metadata.deferred_loading.unwrap_or(false); + let always_visible = metadata.always_visible.unwrap_or(false); + let allowed_callers = metadata.allowed_callers.unwrap_or_default(); + let tags = metadata.tags.unwrap_or_default(); + let input_examples = metadata.input_examples; let score = Self::score_match(&definition.name, &definition.description, &tags, &query); if score <= 0 { @@ -4692,6 +5687,7 @@ impl Tool for ToolSearchBridgeTool { &input_examples, ); serde_json::json!({ + "source": "native_registry", "name": definition.name, "description": definition.description, "input_schema": enriched_schema, @@ -4703,6 +5699,7 @@ impl Tool for ToolSearchBridgeTool { }) } else { serde_json::json!({ + "source": "native_registry", "name": definition.name, "description": definition.description, "deferred_loading": deferred_loading, @@ -4716,6 +5713,77 @@ impl Tool for ToolSearchBridgeTool { }) .collect::>(); + drop(registry); + + if let Some(extension_manager) = self.extension_manager.as_ref() { + let visible_extension_tools = extension_manager + .get_prefixed_tools(None) + .await + .unwrap_or_default() + .into_iter() + .map(|tool| tool.name.to_string()) + .collect::>(); + let extension_configs = extension_manager.get_extension_configs().await; + let extension_tools = extension_manager + .get_prefixed_tools_for_search(None) + .await + .unwrap_or_default(); + + for tool in extension_tools { + if tool.name.as_ref() == self.name() { + continue; + } + + let tool_name = tool.name.to_string(); + let description = tool.description.as_deref().unwrap_or("").to_string(); + let score = Self::score_match(&tool_name, &description, &[], &query); + if score <= 0 { + continue; + } + + let (status, deferred_loading, extension_name) = Self::extension_tool_status( + &extension_configs, + &visible_extension_tools, + &tool_name, + ); + let input_schema = serde_json::Value::Object((*tool.input_schema).clone()); + let activation = if deferred_loading { + serde_json::json!({ + "tool": "extensionmanager__load_tools", + "arguments": { + "tool_names": [tool_name.clone()] + } + }) + } else { + serde_json::Value::Null + }; + + let item = if include_schema { + serde_json::json!({ + "source": "extension", + "name": tool_name, + "description": description, + "extension_name": extension_name, + "input_schema": input_schema, + "deferred_loading": deferred_loading, + "status": status, + "activation": activation + }) + } else { + serde_json::json!({ + "source": "extension", + "name": tool_name, + "description": description, + "extension_name": extension_name, + "deferred_loading": deferred_loading, + "status": status, + "activation": activation + }) + }; + scored.push((score, item)); + } + } + scored.sort_by(|(a_score, a_item), (b_score, b_item)| { b_score.cmp(a_score).then_with(|| { a_item["name"] @@ -4745,7 +5813,7 @@ impl Tool for ToolSearchBridgeTool { fn browser_mcp_tool_names() -> Vec { let mut names = Vec::new(); for tool in get_chrome_mcp_tools() { - names.push(format!("mcp__lime-browser__{}", tool.name)); + names.push(format!("{}{}", browser_runtime_tool_prefix(), tool.name)); } names } @@ -4820,11 +5888,15 @@ fn register_creation_task_tools_to_registry( fn register_tool_search_tool_to_registry( registry: &mut aster::tools::ToolRegistry, registry_arc: Arc>, + extension_manager: Option>, ) { - if registry.contains("tool_search") { + if registry.contains(TOOL_SEARCH_TOOL_NAME) { return; } - registry.register(Box::new(ToolSearchBridgeTool::new(registry_arc))); + registry.register(Box::new(ToolSearchBridgeTool::new( + registry_arc, + extension_manager, + ))); } pub async fn ensure_browser_mcp_tools_registered(state: &AsterAgentState) -> Result<(), String> { @@ -4834,11 +5906,16 @@ pub async fn ensure_browser_mcp_tools_registered(state: &AsterAgentState) -> Res .as_ref() .ok_or_else(|| "Agent not initialized".to_string())?; let registry_arc = agent.tool_registry().clone(); + let extension_manager = agent.extension_manager.clone(); drop(guard); let mut registry = registry_arc.write().await; register_browser_mcp_tools_to_registry(&mut registry); - register_tool_search_tool_to_registry(&mut registry, registry_arc.clone()); + register_tool_search_tool_to_registry( + &mut registry, + registry_arc.clone(), + Some(extension_manager), + ); Ok(()) } @@ -4890,26 +5967,56 @@ pub async fn ensure_tool_search_tool_registered(state: &AsterAgentState) -> Resu .as_ref() .ok_or_else(|| "Agent not initialized".to_string())?; let registry_arc = agent.tool_registry().clone(); + let extension_manager = agent.extension_manager.clone(); drop(guard); let mut registry = registry_arc.write().await; - register_tool_search_tool_to_registry(&mut registry, registry_arc.clone()); + register_tool_search_tool_to_registry( + &mut registry, + registry_arc.clone(), + Some(extension_manager), + ); Ok(()) } -fn build_workspace_shell_allow_pattern( - escaped_root: &str, - allow_extended_shell_commands: bool, -) -> String { - if allow_extended_shell_commands { - // Auto 模式放宽命令白名单,交由本地 sandbox 与 BashTool 安全检查兜底。 - // 这里使用 DOTALL 支持 heredoc 等多行命令(例如 python <<'EOF' ...)。 - return String::from(r"(?s)^\s*\S.*$"); +fn unregister_named_tools(registry: &mut aster::tools::ToolRegistry, tool_names: &[&str]) { + for tool_name in tool_names { + registry.unregister(tool_name); + } +} + +fn unregister_browser_mcp_tools_from_registry(registry: &mut aster::tools::ToolRegistry) { + for tool_name in browser_mcp_tool_names() { + registry.unregister(&tool_name); + } +} + +fn sync_workspace_mode_native_tool_surface( + registry: &mut aster::tools::ToolRegistry, + surface: WorkspaceToolSurface, + db: DbConnection, + api_key_provider_service: Arc, + app_handle: AppHandle, + config_manager: Arc, +) { + if surface.browser_assist { + register_browser_mcp_tools_to_registry(registry); + } else { + unregister_browser_mcp_tools_from_registry(registry); } - format!( - r"^\s*(?:cd\s+({escaped_root}|\.|\./|\.\./)|pwd|ls(?:\s+[^;&|]+)?|find\s+({escaped_root}|\.|\./|\.\./)[^;&|]*|rg\b[^;&|]*|grep\b[^;&|]*|cat\s+({escaped_root}|\.|\./|\.\./)[^;&|]*)\s*$" - ) + if surface.creator { + register_social_image_tool_to_registry(registry, config_manager); + register_creation_task_tools_to_registry( + registry, + db, + api_key_provider_service, + app_handle, + ); + } else { + let creator_tools = creator_tool_names(); + unregister_named_tools(registry, &creator_tools); + } } /// 为指定工作区生成本地 sandbox 权限模板 @@ -4918,11 +6025,14 @@ async fn apply_workspace_sandbox_permissions( config_manager: &GlobalConfigManagerState, db: &DbConnection, api_key_provider_service: &ApiKeyProviderServiceState, - _automation_state: &AutomationServiceState, + logs: &LogState, + mcp_manager: &McpManagerState, + automation_state: &AutomationServiceState, app_handle: &AppHandle, session_id: &str, request_metadata: Option<&serde_json::Value>, workspace_root: &str, + runtime_chat_mode: RuntimeChatMode, execution_strategy: AsterExecutionStrategy, ) -> Result { let workspace_root = workspace_root.trim(); @@ -4932,11 +6042,23 @@ async fn apply_workspace_sandbox_permissions( let sandbox_policy = resolve_workspace_sandbox_policy(config_manager); let auto_mode = execution_strategy == AsterExecutionStrategy::Auto; + let current_config = config_manager.config(); + let execution_policy_input = ToolExecutionResolverInput { + persisted_policy: Some(¤t_config.agent.tool_execution), + request_metadata, + }; + let tool_surface = WorkspaceToolSurface { + creator: runtime_chat_mode == RuntimeChatMode::Creator, + browser_assist: is_browser_assist_enabled(request_metadata), + }; let mut sandboxed_bash_tool: Option = None; let apply_outcome = if !sandbox_policy.enabled { WorkspaceSandboxApplyOutcome::DisabledByConfig } else { - match WorkspaceSandboxedBashTool::new(workspace_root, auto_mode) { + match WorkspaceSandboxedBashTool::new( + workspace_root, + should_auto_approve_tool_warnings("bash", auto_mode, execution_policy_input), + ) { Ok(tool) => { let sandbox_type = tool.sandbox_type().to_string(); sandboxed_bash_tool = Some(tool); @@ -4956,474 +6078,32 @@ async fn apply_workspace_sandbox_permissions( } }; - let escaped_root = regex::escape(workspace_root); - let virtual_memory_path_pattern = durable_memory_permission_pattern(); - let workspace_path_pattern = - format!(r"^(?:({escaped_root}|\.|\./|\.\./).*$|{virtual_memory_path_pattern})"); - let workspace_abs_path_pattern = format!(r"^({escaped_root}).*$"); - let analyze_image_path_pattern = format!( - r"^(base64:[A-Za-z0-9+/=]+|file://({escaped_root}).*|({escaped_root}|\.|\./|\.\./).*)$" - ); - let safe_https_url_pattern = String::from(r"^https://[^\s]+$"); - let mut permissions = vec![ - ToolPermission { - tool: "read".to_string(), - allowed: true, - priority: 100, - conditions: Vec::new(), - parameter_restrictions: if auto_mode { - Vec::new() - } else { - vec![ParameterRestriction { - parameter: "path".to_string(), - restriction_type: RestrictionType::Pattern, - values: None, - pattern: Some(workspace_path_pattern.clone()), - validator: None, - min: None, - max: None, - required: true, - description: Some( - "read.path 必须在 workspace、相对路径或 `/memories/...` 内".to_string(), - ), - }] - }, - scope: PermissionScope::Session, - reason: Some(if auto_mode { - "Auto 模式:允许读取任意路径".to_string() - } else { - "仅允许读取当前 workspace 或 `/memories/` 内容".to_string() - }), - expires_at: None, - metadata: HashMap::new(), - }, - ToolPermission { - tool: "write".to_string(), - allowed: true, - priority: 100, - conditions: Vec::new(), - parameter_restrictions: if auto_mode { - Vec::new() - } else { - vec![ParameterRestriction { - parameter: "path".to_string(), - restriction_type: RestrictionType::Pattern, - values: None, - pattern: Some(workspace_path_pattern.clone()), - validator: None, - min: None, - max: None, - required: true, - description: Some( - "write.path 必须在 workspace、相对路径或 `/memories/...` 内".to_string(), - ), - }] - }, - scope: PermissionScope::Session, - reason: Some(if auto_mode { - "Auto 模式:允许写入任意路径".to_string() - } else { - "仅允许写入当前 workspace 或 `/memories/` 内容".to_string() - }), - expires_at: None, - metadata: HashMap::new(), - }, - ToolPermission { - tool: "edit".to_string(), - allowed: true, - priority: 100, - conditions: Vec::new(), - parameter_restrictions: if auto_mode { - Vec::new() - } else { - vec![ParameterRestriction { - parameter: "path".to_string(), - restriction_type: RestrictionType::Pattern, - values: None, - pattern: Some(workspace_path_pattern.clone()), - validator: None, - min: None, - max: None, - required: true, - description: Some( - "edit.path 必须在 workspace、相对路径或 `/memories/...` 内".to_string(), - ), - }] - }, - scope: PermissionScope::Session, - reason: Some(if auto_mode { - "Auto 模式:允许编辑任意路径".to_string() - } else { - "仅允许编辑当前 workspace 或 `/memories/` 内容".to_string() - }), - expires_at: None, - metadata: HashMap::new(), - }, - ToolPermission { - tool: "glob".to_string(), - allowed: true, - priority: 100, - conditions: Vec::new(), - parameter_restrictions: if auto_mode { - Vec::new() - } else { - vec![ParameterRestriction { - parameter: "path".to_string(), - restriction_type: RestrictionType::Pattern, - values: None, - pattern: Some(workspace_path_pattern.clone()), - validator: None, - min: None, - max: None, - required: false, - description: Some( - "glob.path 必须在 workspace、相对路径或 `/memories/...` 内".to_string(), - ), - }] - }, - scope: PermissionScope::Session, - reason: Some(if auto_mode { - "Auto 模式:允许任意路径搜索文件".to_string() - } else { - "仅允许在当前 workspace 或 `/memories/` 搜索文件".to_string() - }), - expires_at: None, - metadata: HashMap::new(), - }, - ToolPermission { - tool: "grep".to_string(), - allowed: true, - priority: 100, - conditions: Vec::new(), - parameter_restrictions: if auto_mode { - Vec::new() - } else { - vec![ParameterRestriction { - parameter: "path".to_string(), - restriction_type: RestrictionType::Pattern, - values: None, - pattern: Some(workspace_path_pattern.clone()), - validator: None, - min: None, - max: None, - required: false, - description: Some( - "grep.path 必须在 workspace、相对路径或 `/memories/...` 内".to_string(), - ), - }] - }, - scope: PermissionScope::Session, - reason: Some(if auto_mode { - "Auto 模式:允许任意路径搜索内容".to_string() - } else { - "仅允许在当前 workspace 或 `/memories/` 搜索内容".to_string() - }), - expires_at: None, - metadata: HashMap::new(), - }, - ]; - - let allow_shell_pattern = build_workspace_shell_allow_pattern(&escaped_root, auto_mode); - let shell_permission_description = if auto_mode { - "Auto 模式:允许任意 bash.command" - } else { - "bash.command 仅允许 workspace 内安全读操作" - }; - let shell_permission_reason = if auto_mode { - "workspace 安全策略:Auto 模式允许任意命令(由本地 sandbox 兜底)" - } else { - "workspace 安全策略:bash 仅允许 workspace 内安全命令" - }; - - permissions.push(ToolPermission { - tool: "bash".to_string(), - allowed: true, - priority: 90, - conditions: Vec::new(), - parameter_restrictions: if auto_mode { - Vec::new() - } else { - vec![ - ParameterRestriction { - parameter: "command".to_string(), - restriction_type: RestrictionType::Pattern, - values: None, - pattern: Some(allow_shell_pattern.clone()), - validator: None, - min: None, - max: None, - required: false, - description: Some(shell_permission_description.to_string()), - }, - ParameterRestriction { - parameter: "cmd".to_string(), - restriction_type: RestrictionType::Pattern, - values: None, - pattern: Some(allow_shell_pattern.clone()), - validator: None, - min: None, - max: None, - required: false, - description: Some("bash.cmd 兼容参数名,规则与 command 一致".to_string()), - }, - ] - }, - scope: PermissionScope::Session, - reason: Some(shell_permission_reason.to_string()), - expires_at: None, - metadata: HashMap::new(), - }); - - let task_permission_description = if auto_mode { - "Auto 模式:允许任意 Task.command" - } else { - "Task.command 仅允许 workspace 内安全命令" - }; - let task_permission_reason = if auto_mode { - "workspace 安全策略:Auto 模式允许 Task 执行任意命令" - } else { - "workspace 安全策略:Task 仅允许 workspace 内安全命令" - }; - - permissions.push(ToolPermission { - tool: "Task".to_string(), - allowed: true, - priority: 88, - conditions: Vec::new(), - parameter_restrictions: if auto_mode { - Vec::new() - } else { - vec![ - ParameterRestriction { - parameter: "command".to_string(), - restriction_type: RestrictionType::Pattern, - values: None, - pattern: Some(allow_shell_pattern.clone()), - validator: None, - min: None, - max: None, - required: false, - description: Some(task_permission_description.to_string()), - }, - ParameterRestriction { - parameter: "cmd".to_string(), - restriction_type: RestrictionType::Pattern, - values: None, - pattern: Some(allow_shell_pattern.clone()), - validator: None, - min: None, - max: None, - required: false, - description: Some("Task.cmd 兼容参数名,规则与 command 一致".to_string()), - }, - ] - }, - scope: PermissionScope::Session, - reason: Some(task_permission_reason.to_string()), - expires_at: None, - metadata: HashMap::new(), - }); - - permissions.push(ToolPermission { - tool: "lsp".to_string(), - allowed: true, - priority: 88, - conditions: Vec::new(), - parameter_restrictions: if auto_mode { - Vec::new() - } else { - vec![ParameterRestriction { - parameter: "path".to_string(), - restriction_type: RestrictionType::Pattern, - values: None, - pattern: Some(workspace_path_pattern.clone()), - validator: None, - min: None, - max: None, - required: true, - description: Some("lsp.path 必须在 workspace 内或相对路径".to_string()), - }] - }, - scope: PermissionScope::Session, - reason: Some(if auto_mode { - "Auto 模式:允许任意 LSP 路径".to_string() - } else { - "允许在 workspace 内使用 LSP".to_string() - }), - expires_at: None, - metadata: HashMap::new(), - }); - - permissions.push(ToolPermission { - tool: "NotebookEdit".to_string(), - allowed: true, - priority: 88, - conditions: Vec::new(), - parameter_restrictions: if auto_mode { - Vec::new() - } else { - vec![ParameterRestriction { - parameter: "notebook_path".to_string(), - restriction_type: RestrictionType::Pattern, - values: None, - pattern: Some(workspace_abs_path_pattern.clone()), - validator: None, - min: None, - max: None, - required: true, - description: Some( - "NotebookEdit.notebook_path 必须是 workspace 内绝对路径".to_string(), - ), - }] - }, - scope: PermissionScope::Session, - reason: Some(if auto_mode { - "Auto 模式:允许编辑任意 Notebook 路径".to_string() - } else { - "允许编辑 workspace 内 Notebook".to_string() - }), - expires_at: None, - metadata: HashMap::new(), - }); - - permissions.push(ToolPermission { - tool: "analyze_image".to_string(), - allowed: true, - priority: 88, - conditions: Vec::new(), - parameter_restrictions: if auto_mode { - Vec::new() - } else { - vec![ParameterRestriction { - parameter: "file_path".to_string(), - restriction_type: RestrictionType::Pattern, - values: None, - pattern: Some(analyze_image_path_pattern), - validator: None, - min: None, - max: None, - required: true, - description: Some( - "analyze_image.file_path 仅允许 base64、workspace 内绝对路径或相对路径" - .to_string(), - ), - }] - }, - scope: PermissionScope::Session, - reason: Some(if auto_mode { - "Auto 模式:允许分析任意图片路径或 base64".to_string() - } else { - "允许分析 workspace 内图片或 base64 数据".to_string() - }), - expires_at: None, - metadata: HashMap::new(), - }); - - permissions.push(ToolPermission { - tool: "WebFetch".to_string(), - allowed: true, - priority: 88, - conditions: Vec::new(), - parameter_restrictions: if auto_mode { - Vec::new() - } else { - vec![ParameterRestriction { - parameter: "url".to_string(), - restriction_type: RestrictionType::Pattern, - values: None, - pattern: Some(safe_https_url_pattern), - validator: None, - min: None, - max: None, - required: true, - description: Some("WebFetch.url 仅允许 https 且禁止内网/本机地址".to_string()), - }] - }, - scope: PermissionScope::Session, - reason: Some(if auto_mode { - "Auto 模式:允许任意 WebFetch URL".to_string() - } else { - "允许安全的 WebFetch 请求".to_string() - }), - expires_at: None, - metadata: HashMap::new(), - }); - - if auto_mode { - permissions.push(ToolPermission { - tool: "*".to_string(), - allowed: true, - priority: 1000, - conditions: Vec::new(), - parameter_restrictions: Vec::new(), - scope: PermissionScope::Session, - reason: Some("Auto 模式:允许所有工具与参数".to_string()), - expires_at: None, - metadata: HashMap::new(), + let mut permissions = + build_workspace_execution_permissions(WorkspaceExecutionPermissionInput { + surface: tool_surface, + workspace_root, + auto_mode, + execution_policy_input, }); - } - for tool_name in [ - "Skill", - "SubAgentTask", - "TaskOutput", - "KillShell", - "TodoWrite", - "EnterPlanMode", - "ExitPlanMode", - "WebSearch", - "ask", - "tool_search", - SOCIAL_IMAGE_TOOL_NAME, - LIME_CREATE_VIDEO_TASK_TOOL_NAME, - LIME_CREATE_BROADCAST_TASK_TOOL_NAME, - LIME_CREATE_COVER_TASK_TOOL_NAME, - LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME, - LIME_CREATE_IMAGE_TASK_TOOL_NAME, - LIME_CREATE_URL_PARSE_TASK_TOOL_NAME, - LIME_CREATE_TYPESETTING_TASK_TOOL_NAME, - ] { - permissions.push(ToolPermission { - tool: tool_name.to_string(), - allowed: true, - priority: 88, - conditions: Vec::new(), - parameter_restrictions: Vec::new(), - scope: PermissionScope::Session, - reason: Some(format!("允许默认工具: {tool_name}")), - expires_at: None, - metadata: HashMap::new(), - }); - } - - for tool_name in browser_mcp_tool_names() { - permissions.push(ToolPermission { - tool: tool_name, - allowed: true, - priority: 88, - conditions: Vec::new(), - parameter_restrictions: Vec::new(), - scope: PermissionScope::Session, - reason: Some("允许浏览器 MCP 兼容工具".to_string()), - expires_at: None, - metadata: HashMap::new(), - }); + if tool_surface.browser_assist { + for tool_name in browser_mcp_tool_names() { + permissions.push(ToolPermission { + tool: tool_name, + allowed: true, + priority: 88, + conditions: Vec::new(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some("允许浏览器 MCP 兼容工具".to_string()), + expires_at: None, + metadata: HashMap::new(), + }); + } } append_browser_assist_session_permissions(&mut permissions, session_id, request_metadata); - permissions.push(ToolPermission { - tool: "*".to_string(), - allowed: false, - priority: 10, - conditions: Vec::new(), - parameter_restrictions: Vec::new(), - scope: PermissionScope::Session, - reason: Some("workspace 安全策略:未显式授权的工具默认拒绝".to_string()), - expires_at: None, - metadata: HashMap::new(), - }); - let agent_arc = state.get_agent_arc(); let guard = agent_arc.read().await; let agent = guard @@ -5434,13 +6114,6 @@ async fn apply_workspace_sandbox_permissions( let mut registry = registry_arc.write().await; let mut permission_manager = ToolPermissionManager::new(None); - if let Some(existing_manager) = registry.permission_manager() { - for permission in existing_manager.get_permissions(None) { - let scope = permission.scope; - permission_manager.add_permission(permission, scope); - } - } - for permission in permissions { permission_manager.add_permission(permission, PermissionScope::Session); } @@ -5448,13 +6121,25 @@ async fn apply_workspace_sandbox_permissions( let task_manager = shared_task_manager(); registry.register(Box::new(WorkspaceTaskTool::new( - auto_mode, + should_auto_approve_tool_warnings("Task", auto_mode, execution_policy_input), task_manager.clone(), ))); - registry.register(Box::new(SubAgentTaskTool::new( - db.clone(), + let subagent_runtime = SubagentControlRuntime::new( app_handle.clone(), - ))); + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ); + registry.register(Box::new(SubAgentTaskTool::new(subagent_runtime.clone()))); + registry.register(Box::new(SpawnAgentTool::new(subagent_runtime.clone()))); + registry.register(Box::new(SendInputTool::new(subagent_runtime.clone()))); + registry.register(Box::new(WaitAgentTool::new(subagent_runtime.clone()))); + registry.register(Box::new(ResumeAgentTool::new(subagent_runtime.clone()))); + registry.register(Box::new(CloseAgentTool::new(subagent_runtime))); registry.register(Box::new(WorkspaceTaskOutputTool::new(task_manager.clone()))); registry.register(Box::new(KillShellTool::with_task_manager(task_manager))); @@ -5462,16 +6147,14 @@ async fn apply_workspace_sandbox_permissions( registry.register(Box::new(workspace_bash_tool)); } - register_social_image_tool_to_registry(&mut registry, config_manager.0.clone()); - register_creation_task_tools_to_registry( + sync_workspace_mode_native_tool_surface( &mut registry, + tool_surface, db.clone(), api_key_provider_service.0.clone(), app_handle.clone(), + config_manager.0.clone(), ); - - // 注册浏览器 MCP 工具 - register_browser_mcp_tools_to_registry(&mut registry); wrap_registry_native_tools_for_durable_memory_fs(&mut registry); wrap_registry_native_tools_for_harness_observability(&mut registry); @@ -5521,9 +6204,7 @@ async fn execute_aster_chat_request( tracing::warn!("[AsterAgent] session_store 存在: {}", has_store); } } - ensure_browser_mcp_tools_registered(state).await?; ensure_tool_search_tool_registered(state).await?; - ensure_social_image_tool_registered(state, config_manager).await?; // 直接使用前端传递的 session_id // LimeSessionStore 会在 add_message 时自动创建不存在的 session @@ -5743,10 +6424,13 @@ async fn execute_aster_chat_request( MemoryPromptContext::with_working_dir(Path::new(&workspace_root)), ); let merged_prompt = merge_system_prompt_with_auto_continue( - merge_system_prompt_with_elicitation_context( - merge_system_prompt_with_request_tool_policy( - merge_system_prompt_with_web_search(prompt_with_memory, &runtime_config), - &request_tool_policy, + merge_system_prompt_with_team_preference( + merge_system_prompt_with_elicitation_context( + merge_system_prompt_with_request_tool_policy( + merge_system_prompt_with_web_search(prompt_with_memory, &runtime_config), + &request_tool_policy, + ), + request.metadata.as_ref(), ), request.metadata.as_ref(), ), @@ -5794,14 +6478,24 @@ async fn execute_aster_chat_request( ); let config = ProviderConfig { provider_name: provider_config.provider_name.clone(), + provider_selector: provider_config + .provider_id + .clone() + .or_else(|| Some(provider_config.provider_name.clone())), model_name: provider_config.model_name.clone(), api_key: provider_config.api_key.clone(), base_url: provider_config.base_url.clone(), credential_uuid: None, + force_responses_api: false, }; // 如果前端提供了 api_key,直接使用;否则从凭证池选择凭证 if provider_config.api_key.is_some() { state.configure_provider(config, session_id, db).await?; + let provider_selector = provider_config + .provider_id + .as_deref() + .unwrap_or(&provider_config.provider_name); + persist_session_provider_routing(session_id, provider_selector).await?; } else { // 没有 api_key,使用凭证池(优先 provider_id,其次 provider_name) let provider_selector = provider_config @@ -5816,6 +6510,7 @@ async fn execute_aster_chat_request( session_id, ) .await?; + persist_session_provider_routing(session_id, provider_selector).await?; } } @@ -5829,11 +6524,14 @@ async fn execute_aster_chat_request( config_manager, db, api_key_provider_service, + logs, + mcp_manager, automation_state, app, session_id, request.metadata.as_ref(), &workspace_root, + runtime_chat_mode, requested_strategy, ) .await @@ -6200,6 +6898,7 @@ async fn execute_aster_chat_request( if let Err(e) = app.emit(&request.event_name, &done_event) { tracing::error!("[AsterAgent] 发送完成事件失败: {}", e); } + emit_subagent_status_changed_events(app, session_id).await; } Err(e) => { complete_runtime_status_projection( @@ -6227,6 +6926,7 @@ async fn execute_aster_chat_request( if let Err(emit_err) = app.emit(&request.event_name, &error_event) { tracing::error!("[AsterAgent] 发送错误事件失败: {}", emit_err); } + emit_subagent_status_changed_events(app, session_id).await; state.remove_cancel_token(session_id).await; return Err(e); } @@ -6306,6 +7006,1040 @@ fn build_runtime_queue_executor() -> RuntimeQueueExecutor { }) } +const SUBAGENT_RUNTIME_EVENT_PREFIX: &str = "agent_subagent_stream"; +const SUBAGENT_STATUS_EVENT_PREFIX: &str = "agent_subagent_status"; +const SUBAGENT_CONTROL_CLOSE_REASON: &str = "close_agent"; +const DEFAULT_WAIT_AGENT_TIMEOUT_MS: i64 = 30_000; +const MIN_WAIT_AGENT_TIMEOUT_MS: i64 = 1_000; +const MAX_WAIT_AGENT_TIMEOUT_MS: i64 = 300_000; + +#[derive(Debug, Clone, Serialize)] +struct SubagentStatusChangedEvent { + #[serde(rename = "type")] + event_type: &'static str, + session_id: String, + root_session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + parent_session_id: Option, + status: SubagentRuntimeStatusKind, +} + +struct SubagentControlRuntime { + app_handle: AppHandle, + state: AsterAgentState, + db: DbConnection, + api_key_provider_service: ApiKeyProviderServiceState, + logs: LogState, + config_manager: GlobalConfigManagerState, + mcp_manager: McpManagerState, + automation_state: AutomationServiceState, +} + +impl std::fmt::Debug for SubagentControlRuntime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SubagentControlRuntime") + .field("app_handle", &"") + .field("state", &"") + .field("db", &"") + .field("api_key_provider_service", &"") + .field("logs", &"") + .field("config_manager", &"") + .field("mcp_manager", &"") + .field("automation_state", &"") + .finish() + } +} + +impl Clone for SubagentControlRuntime { + fn clone(&self) -> Self { + Self { + app_handle: self.app_handle.clone(), + state: self.state.clone(), + db: self.db.clone(), + api_key_provider_service: ApiKeyProviderServiceState( + self.api_key_provider_service.0.clone(), + ), + logs: self.logs.clone(), + config_manager: GlobalConfigManagerState(self.config_manager.0.clone()), + mcp_manager: self.mcp_manager.clone(), + automation_state: self.automation_state.clone(), + } + } +} + +impl SubagentControlRuntime { + fn new( + app_handle: AppHandle, + state: &AsterAgentState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + logs: &LogState, + config_manager: &GlobalConfigManagerState, + mcp_manager: &McpManagerState, + automation_state: &AutomationServiceState, + ) -> Self { + Self { + app_handle, + state: state.clone(), + db: db.clone(), + api_key_provider_service: ApiKeyProviderServiceState( + api_key_provider_service.0.clone(), + ), + logs: logs.clone(), + config_manager: GlobalConfigManagerState(config_manager.0.clone()), + mcp_manager: mcp_manager.clone(), + automation_state: automation_state.clone(), + } + } + + async fn ensure_initialized(&self) -> Result<(), String> { + self.state.init_agent_with_db(&self.db).await + } +} + +fn normalize_required_text(value: &str, field_name: &str) -> Result { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + Err(format!("{field_name} 不能为空")) + } else { + Ok(trimmed) + } +} + +fn normalize_optional_text(value: Option) -> Option { + let trimmed = value?.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn normalize_whitespace(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + let count = value.chars().count(); + if count <= max_chars { + return value.to_string(); + } + if max_chars <= 3 { + return value.chars().take(max_chars).collect(); + } + let truncated = value.chars().take(max_chars - 3).collect::(); + format!("{truncated}...") +} + +fn build_subagent_task_summary(message: &str) -> Option { + let normalized = normalize_whitespace(message); + if normalized.is_empty() { + None + } else { + Some(truncate_chars(&normalized, 120)) + } +} + +fn normalize_optional_vec(values: &[String]) -> Vec { + let mut normalized = Vec::new(); + let mut seen = HashSet::new(); + + for value in values { + let Some(item) = normalize_optional_text(Some(value.clone())) else { + continue; + }; + if seen.insert(item.clone()) { + normalized.push(item); + } + } + + normalized +} + +fn build_subagent_session_name( + message: &str, + agent_type: Option<&str>, + profile_name: Option<&str>, +) -> String { + normalize_optional_text(agent_type.map(ToString::to_string)) + .or_else(|| normalize_optional_text(profile_name.map(ToString::to_string))) + .or_else(|| build_subagent_task_summary(message)) + .unwrap_or_else(|| "子代理".to_string()) +} + +fn resolve_subagent_role_hint( + request: &AgentRuntimeSpawnSubagentRequest, + customization: Option<&SubagentCustomizationState>, +) -> Option { + normalize_optional_text(request.agent_type.clone()) + .or_else(|| customization.and_then(|state| state.profile_name.clone())) + .or_else(|| customization.and_then(|state| state.role_key.clone())) +} + +fn build_local_subagent_skill_payload( + directory: &str, +) -> Result<(SubagentSkillSummary, SubagentSkillPromptBlock), String> { + let inspection = crate::commands::skill_cmd::inspect_local_skill_for_app( + "lime".to_string(), + directory.to_string(), + ) + .map_err(|error| format!("读取本地 skill 失败 `{directory}`: {error}"))?; + let name = inspection + .metadata + .get("name") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or(directory) + .to_string(); + let description = inspection + .metadata + .get("description") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let title = format!("local skill · {name} ({directory})"); + + Ok(( + SubagentSkillSummary { + id: format!("local:{directory}"), + name, + description, + source: Some("local".to_string()), + directory: Some(directory.to_string()), + }, + SubagentSkillPromptBlock { + title, + content: inspection.content, + }, + )) +} + +fn build_subagent_customization_state( + request: &AgentRuntimeSpawnSubagentRequest, +) -> Result, String> { + let profile_id = normalize_optional_text(request.profile_id.clone()); + let profile = profile_id + .as_deref() + .and_then(builtin_profile_descriptor_by_id); + let team_preset_id = normalize_optional_text(request.team_preset_id.clone()); + let team_preset = team_preset_id + .as_deref() + .and_then(builtin_team_preset_descriptor_by_id); + let mut skill_ids = profile + .map(|descriptor| { + descriptor + .skill_ids + .iter() + .map(|skill_id| (*skill_id).to_string()) + .collect::>() + }) + .unwrap_or_default(); + skill_ids.extend(normalize_optional_vec(&request.skill_ids)); + let skill_ids = normalize_optional_vec(&skill_ids); + let skill_directories = normalize_optional_vec(&request.skill_directories); + + let mut skills = skill_ids + .iter() + .map(|skill_id| { + summarize_builtin_skill(skill_id).unwrap_or(SubagentSkillSummary { + id: skill_id.clone(), + name: skill_id.clone(), + description: None, + source: Some("requested".to_string()), + directory: None, + }) + }) + .collect::>(); + + for directory in &skill_directories { + let (summary, _) = build_local_subagent_skill_payload(directory)?; + skills.push(summary); + } + + let state = SubagentCustomizationState { + profile_id, + profile_name: normalize_optional_text(request.profile_name.clone()) + .or_else(|| profile.map(|descriptor| descriptor.name.to_string())), + role_key: normalize_optional_text(request.role_key.clone()) + .or_else(|| profile.map(|descriptor| descriptor.role_key.to_string())), + team_preset_id, + theme: normalize_optional_text(request.theme.clone()) + .or_else(|| profile.map(|descriptor| descriptor.theme.to_string())) + .or_else(|| team_preset.map(|descriptor| descriptor.theme.to_string())), + output_contract: normalize_optional_text(request.output_contract.clone()) + .or_else(|| profile.map(|descriptor| descriptor.output_contract.to_string())), + system_overlay: normalize_optional_text(request.system_overlay.clone()) + .or_else(|| profile.map(|descriptor| descriptor.system_overlay.to_string())), + skill_ids, + skills, + }; + + if state.is_empty() { + Ok(None) + } else { + Ok(Some(state)) + } +} + +fn build_subagent_customization_system_prompt( + customization: Option<&SubagentCustomizationState>, +) -> Result, String> { + let Some(customization) = customization else { + return Ok(None); + }; + + let mut local_skill_blocks = Vec::new(); + for skill in &customization.skills { + let Some(directory) = skill.directory.as_deref() else { + continue; + }; + let (_, block) = build_local_subagent_skill_payload(directory)?; + local_skill_blocks.push(block); + } + + Ok(build_subagent_customization_prompt( + customization, + &local_skill_blocks, + )) +} + +#[derive(Debug, Clone)] +struct PreparedRuntimeSubagentSession { + session: aster::session::Session, + customization: Option, + system_prompt: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct SessionProviderRoutingState { + provider_selector: String, +} + +impl ExtensionState for SessionProviderRoutingState { + const EXTENSION_NAME: &'static str = "lime_provider_routing"; + const VERSION: &'static str = "v0"; +} + +impl SessionProviderRoutingState { + fn new(provider_selector: impl Into) -> Option { + normalize_optional_text(Some(provider_selector.into())) + .map(|provider_selector| Self { provider_selector }) + } + + fn from_extension_data(extension_data: &ExtensionData) -> Option { + ::from_extension_data(extension_data) + } + + fn from_session(session: &aster::session::Session) -> Option { + Self::from_extension_data(&session.extension_data) + } + + fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<(), String> { + ::to_extension_data(self, extension_data) + .map_err(|error| error.to_string()) + } + + fn into_updated_extension_data( + self, + session: &aster::session::Session, + ) -> Result { + let mut extension_data = session.extension_data.clone(); + self.to_extension_data(&mut extension_data)?; + Ok(extension_data) + } +} + +async fn persist_session_provider_routing( + session_id: &str, + provider_selector: &str, +) -> Result<(), String> { + let Some(state) = SessionProviderRoutingState::new(provider_selector.to_string()) else { + return Ok(()); + }; + let session = SessionManager::get_session(session_id, false) + .await + .map_err(|error| format!("读取会话 provider 路由上下文失败: {error}"))?; + let extension_data = state.into_updated_extension_data(&session)?; + SessionManager::update_session(session_id) + .extension_data(extension_data) + .apply() + .await + .map_err(|error| format!("持久化会话 provider 路由上下文失败: {error}"))?; + Ok(()) +} + +fn resolve_session_provider_selector(session: &aster::session::Session) -> Option { + SessionProviderRoutingState::from_session(session).map(|state| state.provider_selector) +} + +fn build_subagent_runtime_event_name(session_id: &str) -> String { + format!("{SUBAGENT_RUNTIME_EVENT_PREFIX}:{session_id}") +} + +fn build_subagent_status_event_name(session_id: &str) -> String { + format!("{SUBAGENT_STATUS_EVENT_PREFIX}:{session_id}") +} + +fn parse_subagent_runtime_event_session_id(event_name: &str) -> Option<&str> { + event_name + .strip_prefix(SUBAGENT_RUNTIME_EVENT_PREFIX) + .and_then(|rest| rest.strip_prefix(':')) +} + +fn should_emit_subagent_status_for_runtime_event(event: &TauriAgentEvent) -> bool { + matches!( + event, + TauriAgentEvent::ThreadStarted { .. } + | TauriAgentEvent::TurnStarted { .. } + | TauriAgentEvent::TurnCompleted { .. } + | TauriAgentEvent::TurnFailed { .. } + | TauriAgentEvent::QueueAdded { .. } + | TauriAgentEvent::QueueRemoved { .. } + | TauriAgentEvent::QueueStarted { .. } + | TauriAgentEvent::QueueCleared { .. } + ) +} + +async fn list_subagent_status_scope_session_ids(session_id: &str) -> Vec { + let mut scope_ids = Vec::new(); + let mut seen = HashSet::new(); + let mut current_session_id = session_id.to_string(); + + while seen.insert(current_session_id.clone()) { + scope_ids.push(current_session_id.clone()); + + let session = match SessionManager::get_session(¤t_session_id, false).await { + Ok(session) => session, + Err(error) => { + tracing::warn!( + "[AsterAgent][Subagent] 解析 team 事件 scope 失败: session_id={}, error={}", + current_session_id, + error + ); + break; + } + }; + let Some(metadata) = resolve_subagent_session_metadata(&session.extension_data) else { + break; + }; + let Some(parent_session_id) = normalize_optional_text(Some(metadata.parent_session_id)) + else { + break; + }; + current_session_id = parent_session_id; + } + + scope_ids +} + +async fn emit_subagent_status_changed_events(app: &AppHandle, session_id: &str) { + let status = match load_subagent_runtime_status(session_id).await { + Ok(status) => status, + Err(error) => { + tracing::warn!( + "[AsterAgent][Subagent] 读取 team runtime 状态失败: session_id={}, error={}", + session_id, + error + ); + return; + } + }; + let scope_ids = list_subagent_status_scope_session_ids(session_id).await; + let root_session_id = scope_ids + .last() + .cloned() + .unwrap_or_else(|| session_id.to_string()); + let event = SubagentStatusChangedEvent { + event_type: "subagent_status_changed", + session_id: session_id.to_string(), + root_session_id, + parent_session_id: scope_ids.get(1).cloned(), + status: status.kind, + }; + + for scope_session_id in scope_ids { + if let Err(error) = app.emit(&build_subagent_status_event_name(&scope_session_id), &event) { + tracing::warn!( + "[AsterAgent][Subagent] 发送 team 状态事件失败: scope_session_id={}, session_id={}, error={}", + scope_session_id, + session_id, + error + ); + } + } +} + +async fn maybe_emit_subagent_status_for_runtime_event( + app: &AppHandle, + event_name: &str, + event: &TauriAgentEvent, +) { + let Some(session_id) = parse_subagent_runtime_event_session_id(event_name) else { + return; + }; + if !should_emit_subagent_status_for_runtime_event(event) { + return; + } + emit_subagent_status_changed_events(app, session_id).await; +} + +fn resolve_action_scope_turn_id(parent_session_id: &str) -> Option { + let scope = aster::session_context::current_action_scope()?; + if scope.session_id.as_deref() != Some(parent_session_id) { + return None; + } + normalize_optional_text(scope.turn_id) +} + +fn resolve_workspace_id_for_working_dir( + db: &DbConnection, + working_dir: &Path, +) -> Result { + let manager = WorkspaceManager::new(db.clone()); + manager + .get_by_path(working_dir) + .map_err(|error| format!("解析 workspace 失败: {error}"))? + .map(|workspace| workspace.id) + .ok_or_else(|| { + format!( + "无法根据 working_dir 解析 workspace: {}", + working_dir.to_string_lossy() + ) + }) +} + +fn normalize_wait_timeout_ms(timeout_ms: Option) -> Result { + match timeout_ms.unwrap_or(DEFAULT_WAIT_AGENT_TIMEOUT_MS) { + value if value <= 0 => Err("timeout_ms 必须大于 0".to_string()), + value => Ok(value.clamp(MIN_WAIT_AGENT_TIMEOUT_MS, MAX_WAIT_AGENT_TIMEOUT_MS)), + } +} + +async fn count_active_team_subagents(parent_session_id: &str) -> Result { + let child_sessions = list_subagent_child_sessions(parent_session_id) + .await + .map_err(|error| format!("读取 team child sessions 失败: {error}"))?; + let mut active_count = 0usize; + + for child_session in child_sessions { + let status = load_subagent_runtime_status(&child_session.id).await?; + if subagent_counts_toward_team_limit(status.kind) { + active_count += 1; + } + } + + Ok(active_count) +} + +fn subagent_counts_toward_team_limit(status: SubagentRuntimeStatusKind) -> bool { + !matches!( + status, + SubagentRuntimeStatusKind::Closed | SubagentRuntimeStatusKind::NotFound + ) +} + +async fn enforce_team_spawn_limits(parent_session_id: &str) -> Result<(), String> { + let parent_session = SessionManager::get_session(parent_session_id, false) + .await + .map_err(|error| format!("读取父会话失败: {error}"))?; + + if parent_session.session_type == SessionType::SubAgent { + return Err( + "当前子代理不允许继续创建新的子代理。请返回父会话,由主线程统一编排 team。".to_string(), + ); + } + + let active_count = count_active_team_subagents(parent_session_id).await?; + if active_count >= DEFAULT_TEAM_MAX_ACTIVE_SUBAGENTS { + return Err(format!( + "team 当前最多允许 {} 个活跃子代理并发执行;请先 close_agent 关闭已完成子代理,或复用已有子代理。", + DEFAULT_TEAM_MAX_ACTIVE_SUBAGENTS + )); + } + + Ok(()) +} + +fn merge_stashed_queued_turns( + existing: Vec, + current: Vec, +) -> Vec { + let mut seen = HashSet::new(); + let mut merged = Vec::new(); + for queued_turn in existing.into_iter().chain(current.into_iter()) { + if seen.insert(queued_turn.queued_turn_id.clone()) { + merged.push(queued_turn); + } + } + merged.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.queued_turn_id.cmp(&right.queued_turn_id)) + }); + merged +} + +async fn restore_stashed_subagent_queue( + queued_turns: Vec, +) -> Result<(), String> { + if queued_turns.is_empty() { + return Ok(()); + } + + let store = require_shared_thread_runtime_store() + .map_err(|error| format!("读取 shared runtime store 失败: {error}"))?; + for queued_turn in queued_turns { + store + .enqueue_turn(queued_turn) + .await + .map_err(|error| format!("恢复 subagent queued turn 失败: {error}"))?; + } + Ok(()) +} + +async fn inherit_subagent_provider( + runtime: &SubagentControlRuntime, + parent_session_id: &str, + child_session_id: &str, + model_override: Option<&str>, +) -> Result<(), String> { + let parent_session = SessionManager::get_session(parent_session_id, false) + .await + .map_err(|error| format!("读取父会话 provider 信息失败: {error}"))?; + let parent_provider_selector = resolve_session_provider_selector(&parent_session) + .or_else(|| normalize_optional_text(parent_session.provider_name.clone())); + + if let Some(mut provider_config) = runtime.state.get_provider_config().await { + if let Some(model_name) = normalize_optional_text(model_override.map(ToString::to_string)) { + provider_config.model_name = model_name; + } + if provider_config.provider_selector.is_none() { + provider_config.provider_selector = parent_provider_selector.clone(); + } + runtime + .state + .configure_provider(provider_config, child_session_id, &runtime.db) + .await?; + if let Some(provider_selector) = parent_provider_selector { + persist_session_provider_routing(child_session_id, &provider_selector).await?; + } + return Ok(()); + } + + let provider_selector = parent_provider_selector + .ok_or_else(|| "当前 provider 未配置,且父会话缺少 provider_name".to_string())?; + let model_name = normalize_optional_text(model_override.map(ToString::to_string)) + .or_else(|| { + parent_session + .model_config + .as_ref() + .and_then(|config| normalize_optional_text(Some(config.model_name.clone()))) + }) + .ok_or_else(|| "当前 provider 未配置,且父会话缺少 model_name".to_string())?; + + runtime + .state + .configure_provider_from_pool( + &runtime.db, + &provider_selector, + &model_name, + child_session_id, + ) + .await + .map(|_| ())?; + persist_session_provider_routing(child_session_id, &provider_selector).await?; + Ok(()) +} + +async fn create_runtime_subagent_session( + runtime: &SubagentControlRuntime, + request: &AgentRuntimeSpawnSubagentRequest, +) -> Result { + let parent_session_id = + normalize_required_text(&request.parent_session_id, "parent_session_id")?; + let message = normalize_required_text(&request.message, "message")?; + enforce_team_spawn_limits(&parent_session_id).await?; + let parent_session = SessionManager::get_session(&parent_session_id, false) + .await + .map_err(|error| format!("读取父会话失败: {error}"))?; + let customization = build_subagent_customization_state(request)?; + let system_prompt = build_subagent_customization_system_prompt(customization.as_ref())?; + let profile_name = customization + .as_ref() + .and_then(|state| state.profile_name.as_deref()); + let role_hint = resolve_subagent_role_hint(request, customization.as_ref()); + + let session = SessionManager::create_session( + parent_session.working_dir.clone(), + build_subagent_session_name(&message, request.agent_type.as_deref(), profile_name), + SessionType::SubAgent, + ) + .await + .map_err(|error| format!("创建 subagent session 失败: {error}"))?; + + if let Some(parent_metadata) = + AsterAgentWrapper::get_persisted_session_metadata_sync(&runtime.db, &parent_session_id)? + { + if let Some(execution_strategy) = + normalize_optional_text(parent_metadata.execution_strategy) + { + AsterAgentWrapper::update_session_execution_strategy_sync( + &runtime.db, + &session.id, + &execution_strategy, + )?; + } + } + + let mut metadata = SubagentSessionMetadata::new(parent_session_id.clone()) + .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(); + let mut extension_data = session.extension_data.clone(); + metadata + .to_extension_data(&mut extension_data) + .map_err(|error| format!("持久化 subagent metadata 失败: {error}"))?; + if let Some(customization_state) = customization.as_ref() { + customization_state + .to_extension_data(&mut extension_data) + .map_err(|error| format!("持久化 subagent customization 失败: {error}"))?; + } + SessionManager::update_session(&session.id) + .extension_data(extension_data) + .apply() + .await + .map_err(|error| format!("写入 subagent session metadata 失败: {error}"))?; + + inherit_subagent_provider( + runtime, + &parent_session_id, + &session.id, + request.model.as_deref(), + ) + .await?; + + Ok(PreparedRuntimeSubagentSession { + session, + customization, + system_prompt, + }) +} + +fn spawn_subagent_turn_in_background( + runtime: SubagentControlRuntime, + request: AsterChatRequest, +) -> Result { + let queued_task = build_queued_turn_task(request)?; + let submission_id = queued_task.queued_turn_id.clone(); + tokio::spawn(async move { + if let Err(error) = submit_runtime_turn_service( + runtime.app_handle.clone(), + &runtime.state, + &runtime.db, + &runtime.api_key_provider_service, + &runtime.logs, + &runtime.config_manager, + &runtime.mcp_manager, + &runtime.automation_state, + queued_task, + false, + build_runtime_queue_executor(), + ) + .await + { + tracing::warn!("[AsterAgent][Subagent] 后台启动子代理失败: {}", error); + } + }); + Ok(submission_id) +} + +async fn agent_runtime_spawn_subagent_internal( + runtime: &SubagentControlRuntime, + request: AgentRuntimeSpawnSubagentRequest, +) -> Result { + runtime.ensure_initialized().await?; + let PreparedRuntimeSubagentSession { + session: child_session, + customization, + system_prompt, + } = create_runtime_subagent_session(runtime, &request).await?; + let child_session_id = child_session.id.clone(); + let workspace_id = + resolve_workspace_id_for_working_dir(&runtime.db, child_session.working_dir.as_path())?; + let _ = spawn_subagent_turn_in_background( + runtime.clone(), + AsterChatRequest { + message: normalize_required_text(&request.message, "message")?, + session_id: child_session_id.clone(), + event_name: build_subagent_runtime_event_name(&child_session_id), + images: None, + provider_config: None, + project_id: None, + workspace_id, + web_search: None, + search_mode: None, + execution_strategy: None, + auto_continue: None, + system_prompt, + metadata: Some(serde_json::json!({ + "subagent": { + "parent_session_id": request.parent_session_id, + "agent_type": request.agent_type, + "reasoning_effort": request.reasoning_effort, + "fork_context": request.fork_context, + "origin_tool": "spawn_agent", + "profile_id": customization.as_ref().and_then(|state| state.profile_id.clone()), + "profile_name": customization.as_ref().and_then(|state| state.profile_name.clone()), + "role_key": customization.as_ref().and_then(|state| state.role_key.clone()), + "team_preset_id": customization.as_ref().and_then(|state| state.team_preset_id.clone()), + "theme": customization.as_ref().and_then(|state| state.theme.clone()), + "output_contract": customization.as_ref().and_then(|state| state.output_contract.clone()), + "skill_ids": customization.as_ref().map(|state| state.skill_ids.clone()).unwrap_or_default(), + "skills": customization.as_ref().map(|state| state.skills.clone()).unwrap_or_default(), + } + })), + turn_id: None, + queue_if_busy: Some(false), + queued_turn_id: None, + }, + )?; + emit_subagent_status_changed_events(&runtime.app_handle, &child_session_id).await; + + Ok(AgentRuntimeSpawnSubagentResponse { + agent_id: child_session_id, + nickname: normalize_optional_text(Some(child_session.name)), + }) +} + +async fn agent_runtime_send_subagent_input_internal( + runtime: &SubagentControlRuntime, + request: AgentRuntimeSendSubagentInputRequest, +) -> Result { + runtime.ensure_initialized().await?; + let session_id = normalize_required_text(&request.id, "id")?; + let message = normalize_required_text(&request.message, "message")?; + let status = load_subagent_runtime_status(&session_id).await?; + match status.kind { + SubagentRuntimeStatusKind::NotFound => { + return Err(format!("子代理不存在: {session_id}")); + } + SubagentRuntimeStatusKind::Closed => { + return Err(format!("子代理已关闭,请先恢复: {session_id}")); + } + _ => {} + } + + let (session, _) = read_subagent_control_state(&session_id).await?; + let customization = SubagentCustomizationState::from_session(&session); + let system_prompt = build_subagent_customization_system_prompt(customization.as_ref())?; + if request.interrupt { + let _ = runtime.state.cancel_session(&session_id).await; + let _ = clear_runtime_queue_service(&runtime.app_handle, &session_id).await?; + } + + let workspace_id = + resolve_workspace_id_for_working_dir(&runtime.db, session.working_dir.as_path())?; + let queued_task = build_queued_turn_task(AsterChatRequest { + message, + session_id: session_id.clone(), + event_name: build_subagent_runtime_event_name(&session_id), + images: None, + provider_config: None, + project_id: None, + workspace_id, + web_search: None, + search_mode: None, + execution_strategy: None, + auto_continue: None, + system_prompt, + metadata: Some(serde_json::json!({ + "subagent": { + "origin_tool": "send_input", + "interrupt": request.interrupt, + "profile_id": customization.as_ref().and_then(|state| state.profile_id.clone()), + "profile_name": customization.as_ref().and_then(|state| state.profile_name.clone()), + "role_key": customization.as_ref().and_then(|state| state.role_key.clone()), + "team_preset_id": customization.as_ref().and_then(|state| state.team_preset_id.clone()), + "theme": customization.as_ref().and_then(|state| state.theme.clone()), + "output_contract": customization.as_ref().and_then(|state| state.output_contract.clone()), + "skill_ids": customization.as_ref().map(|state| state.skill_ids.clone()).unwrap_or_default(), + "skills": customization.as_ref().map(|state| state.skills.clone()).unwrap_or_default(), + } + })), + turn_id: None, + queue_if_busy: Some(true), + queued_turn_id: None, + })?; + let submission_id = queued_task.queued_turn_id.clone(); + submit_runtime_turn_service( + runtime.app_handle.clone(), + &runtime.state, + &runtime.db, + &runtime.api_key_provider_service, + &runtime.logs, + &runtime.config_manager, + &runtime.mcp_manager, + &runtime.automation_state, + queued_task, + true, + build_runtime_queue_executor(), + ) + .await?; + emit_subagent_status_changed_events(&runtime.app_handle, &session_id).await; + + Ok(AgentRuntimeSendSubagentInputResponse { submission_id }) +} + +async fn agent_runtime_wait_subagents_internal( + runtime: &SubagentControlRuntime, + request: AgentRuntimeWaitSubagentsRequest, +) -> Result { + runtime.ensure_initialized().await?; + let ids = request + .ids + .into_iter() + .map(|id| normalize_required_text(&id, "ids")) + .collect::, _>>()?; + if ids.is_empty() { + return Err("ids 不能为空".to_string()); + } + + let timeout_ms = normalize_wait_timeout_ms(request.timeout_ms)?; + let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms as u64); + loop { + let mut final_statuses = HashMap::new(); + for id in &ids { + let status = load_subagent_runtime_status(id).await?; + if status.kind.is_final() { + final_statuses.insert(id.clone(), status); + } + } + if !final_statuses.is_empty() { + return Ok(AgentRuntimeWaitSubagentsResponse { + status: final_statuses, + timed_out: false, + }); + } + if tokio::time::Instant::now() >= deadline { + return Ok(AgentRuntimeWaitSubagentsResponse { + status: HashMap::new(), + timed_out: true, + }); + } + tokio::time::sleep(Duration::from_millis(250)).await; + } +} + +async fn agent_runtime_resume_subagent_internal( + runtime: &SubagentControlRuntime, + request: AgentRuntimeResumeSubagentRequest, +) -> Result { + runtime.ensure_initialized().await?; + let session_id = normalize_required_text(&request.id, "id")?; + let current_status = load_subagent_runtime_status(&session_id).await?; + if current_status.kind == SubagentRuntimeStatusKind::NotFound + || current_status.kind != SubagentRuntimeStatusKind::Closed + { + return Ok(AgentRuntimeResumeSubagentResponse { + status: current_status, + cascade_session_ids: Vec::new(), + changed_session_ids: Vec::new(), + }); + } + + let target_ids = list_subagent_cascade_session_ids(&session_id).await?; + let cascade_session_ids = target_ids.clone(); + let mut changed_ids = Vec::new(); + for target_id in target_ids { + let (session, control_state) = read_subagent_control_state(&target_id).await?; + if !control_state.closed { + continue; + } + + let stashed_queued_turns = control_state.stashed_queued_turns.clone(); + let mut next_state = control_state.opened(); + next_state.stashed_queued_turns.clear(); + write_subagent_control_state(&session, &next_state).await?; + restore_stashed_subagent_queue(stashed_queued_turns.clone()).await?; + if !stashed_queued_turns.is_empty() { + let _ = resume_runtime_queue_if_needed_service( + runtime.app_handle.clone(), + &runtime.state, + &runtime.db, + &runtime.api_key_provider_service, + &runtime.logs, + &runtime.config_manager, + &runtime.mcp_manager, + &runtime.automation_state, + target_id.clone(), + build_runtime_queue_executor(), + ) + .await?; + } + changed_ids.push(target_id); + } + + for changed_id in &changed_ids { + emit_subagent_status_changed_events(&runtime.app_handle, &changed_id).await; + } + + Ok(AgentRuntimeResumeSubagentResponse { + status: load_subagent_runtime_status(&session_id).await?, + cascade_session_ids, + changed_session_ids: changed_ids, + }) +} + +async fn agent_runtime_close_subagent_internal( + runtime: &SubagentControlRuntime, + request: AgentRuntimeCloseSubagentRequest, +) -> Result { + runtime.ensure_initialized().await?; + let session_id = normalize_required_text(&request.id, "id")?; + let previous_status = load_subagent_runtime_status(&session_id).await?; + if matches!( + previous_status.kind, + SubagentRuntimeStatusKind::NotFound | SubagentRuntimeStatusKind::Closed + ) { + return Ok(AgentRuntimeCloseSubagentResponse { + previous_status, + cascade_session_ids: Vec::new(), + changed_session_ids: Vec::new(), + }); + } + + let target_ids = list_subagent_cascade_session_ids(&session_id).await?; + let cascade_session_ids = target_ids.clone(); + let mut changed_ids = Vec::new(); + for target_id in target_ids { + let (session, control_state) = read_subagent_control_state(&target_id).await?; + if control_state.closed { + continue; + } + + let _ = runtime.state.cancel_session(&target_id).await; + let cleared_queued_turns = clear_runtime_queue_service(&runtime.app_handle, &target_id) + .await + .unwrap_or_default(); + let next_state = SubagentControlState::closed( + Some(SUBAGENT_CONTROL_CLOSE_REASON.to_string()), + merge_stashed_queued_turns(control_state.stashed_queued_turns, cleared_queued_turns), + ); + write_subagent_control_state(&session, &next_state).await?; + changed_ids.push(target_id); + } + + for changed_id in &changed_ids { + emit_subagent_status_changed_events(&runtime.app_handle, &changed_id).await; + } + + Ok(AgentRuntimeCloseSubagentResponse { + previous_status, + cascade_session_ids, + changed_session_ids: changed_ids, + }) +} + pub async fn resume_persisted_runtime_queues_on_startup( app: AppHandle, state: &AsterAgentState, @@ -6539,6 +8273,114 @@ pub async fn agent_runtime_get_session( )) } +/// 统一运行时:获取工具库存快照。 +#[tauri::command] +pub async fn agent_runtime_get_tool_inventory( + state: State<'_, AsterAgentState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + request: Option, +) -> Result { + let request = request.unwrap_or_default(); + let caller = lime_core::tool_calling::normalize_tool_caller(request.caller.as_deref()) + .unwrap_or_else(|| "assistant".to_string()); + let surface = match (request.creator, request.browser_assist) { + (true, true) => WorkspaceToolSurface::creator_with_browser_assist(), + (true, false) => WorkspaceToolSurface::creator(), + (false, true) => WorkspaceToolSurface::browser_assist(), + (false, false) => WorkspaceToolSurface::core(), + }; + + let mut warnings = Vec::new(); + + let (mcp_server_names, mcp_tools) = { + let manager = mcp_manager.lock().await; + let server_names = manager.get_running_servers().await; + let tools = match manager.list_tools().await { + Ok(tools) => tools, + Err(error) => { + warnings.push(format!("读取 MCP 工具列表失败: {error}")); + Vec::new() + } + }; + (server_names, tools) + }; + + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let Some(agent) = guard.as_ref() else { + return Ok(build_tool_inventory(AgentToolInventoryBuildInput { + surface, + caller, + agent_initialized: false, + warnings: { + warnings.push( + "Aster Agent 尚未初始化,runtime registry / extension 快照为空".to_string(), + ); + warnings + }, + persisted_execution_policy: Some(config_manager.config().agent.tool_execution), + request_metadata: request.metadata.clone(), + mcp_server_names, + mcp_tools, + registry_definitions: Vec::new(), + extension_configs: Vec::new(), + visible_extension_tools: Vec::new(), + searchable_extension_tools: Vec::new(), + })); + }; + + let registry_arc = agent.tool_registry().clone(); + let registry = registry_arc.read().await; + let registry_definitions = registry.get_definitions(); + drop(registry); + + let extension_configs = agent.get_extension_configs().await; + let extension_manager = agent.extension_manager.clone(); + let visible_extension_tools = match extension_manager.get_prefixed_tools(None).await { + Ok(tools) => tools + .into_iter() + .map(|tool| ExtensionToolInventorySeed { + name: tool.name.to_string(), + description: tool.description.clone().unwrap_or_default().to_string(), + }) + .collect(), + Err(error) => { + warnings.push(format!("读取已加载 extension tools 失败: {error}")); + Vec::new() + } + }; + let searchable_extension_tools = + match extension_manager.get_prefixed_tools_for_search(None).await { + Ok(tools) => tools + .into_iter() + .map(|tool| ExtensionToolInventorySeed { + name: tool.name.to_string(), + description: tool.description.clone().unwrap_or_default().to_string(), + }) + .collect(), + Err(error) => { + warnings.push(format!("读取 extension 搜索工具面失败: {error}")); + Vec::new() + } + }; + + Ok(build_tool_inventory(AgentToolInventoryBuildInput { + surface, + caller, + agent_initialized: true, + warnings, + persisted_execution_policy: Some(config_manager.config().agent.tool_execution), + request_metadata: request.metadata.clone(), + mcp_server_names, + mcp_tools, + registry_definitions, + extension_configs, + visible_extension_tools, + searchable_extension_tools, + })) +} + /// 统一运行时:移除单个排队 turn。 #[tauri::command] pub async fn agent_runtime_remove_queued_turn( @@ -6554,6 +8396,188 @@ pub async fn agent_runtime_remove_queued_turn( remove_runtime_queued_turn_service(&app, &session_id, &queued_turn_id).await } +/// 统一运行时:将指定排队 turn 提前到下一条执行。 +#[tauri::command] +pub async fn agent_runtime_promote_queued_turn( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + automation_state: State<'_, AutomationServiceState>, + request: AgentRuntimePromoteQueuedTurnRequest, +) -> Result { + let session_id = request.session_id.trim().to_string(); + let queued_turn_id = request.queued_turn_id.trim().to_string(); + if session_id.is_empty() || queued_turn_id.is_empty() { + return Ok(false); + } + + let promoted = promote_runtime_queued_turn_service(&session_id, &queued_turn_id).await?; + if !promoted { + return Ok(false); + } + + let _ = state.cancel_session(&session_id).await; + let _ = resume_runtime_queue_if_needed_service( + app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + session_id, + build_runtime_queue_executor(), + ) + .await?; + + Ok(true) +} + +#[tauri::command] +pub async fn agent_runtime_spawn_subagent( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + automation_state: State<'_, AutomationServiceState>, + request: AgentRuntimeSpawnSubagentRequest, +) -> Result { + agent_runtime_spawn_subagent_internal( + &SubagentControlRuntime::new( + app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + ), + request, + ) + .await +} + +#[tauri::command] +pub async fn agent_runtime_send_subagent_input( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + automation_state: State<'_, AutomationServiceState>, + request: AgentRuntimeSendSubagentInputRequest, +) -> Result { + agent_runtime_send_subagent_input_internal( + &SubagentControlRuntime::new( + app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + ), + request, + ) + .await +} + +#[tauri::command] +pub async fn agent_runtime_wait_subagents( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + automation_state: State<'_, AutomationServiceState>, + request: AgentRuntimeWaitSubagentsRequest, +) -> Result { + agent_runtime_wait_subagents_internal( + &SubagentControlRuntime::new( + app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + ), + request, + ) + .await +} + +#[tauri::command] +pub async fn agent_runtime_resume_subagent( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + automation_state: State<'_, AutomationServiceState>, + request: AgentRuntimeResumeSubagentRequest, +) -> Result { + agent_runtime_resume_subagent_internal( + &SubagentControlRuntime::new( + app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + ), + request, + ) + .await +} + +#[tauri::command] +pub async fn agent_runtime_close_subagent( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + api_key_provider_service: State<'_, ApiKeyProviderServiceState>, + logs: State<'_, LogState>, + config_manager: State<'_, GlobalConfigManagerState>, + mcp_manager: State<'_, McpManagerState>, + automation_state: State<'_, AutomationServiceState>, + request: AgentRuntimeCloseSubagentRequest, +) -> Result { + agent_runtime_close_subagent_internal( + &SubagentControlRuntime::new( + app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + ), + request, + ) + .await +} + fn rename_runtime_session_internal( db: &DbConnection, session_id: &str, @@ -6675,6 +8699,36 @@ fn validate_elicitation_submission(session_id: &str, request_id: &str) -> Result Ok(trimmed_session_id) } +fn build_action_resume_runtime_status() -> TauriRuntimeStatus { + TauriRuntimeStatus { + phase: "routing".to_string(), + title: "已提交补充信息,继续执行中".to_string(), + detail: "补充信息已回填到当前执行链路,正在恢复后续步骤。".to_string(), + checkpoints: vec![ + "补充信息已确认".to_string(), + "已唤醒当前执行链路".to_string(), + "等待下一条执行事件".to_string(), + ], + } +} + +fn emit_action_resume_runtime_status(app: &AppHandle, event_name: &str) { + if event_name.trim().is_empty() { + return; + } + + let event = TauriAgentEvent::RuntimeStatus { + status: build_action_resume_runtime_status(), + }; + if let Err(error) = app.emit(event_name, &event) { + tracing::warn!( + "[AsterAgent] 发送 action resume runtime_status 失败: event_name={}, error={}", + event_name, + error + ); + } +} + fn build_runtime_action_user_data(request: &AgentRuntimeRespondActionRequest) -> serde_json::Value { if let Some(user_data) = request.user_data.clone() { return user_data; @@ -6698,6 +8752,7 @@ fn build_runtime_action_user_data(request: &AgentRuntimeRespondActionRequest) -> /// 统一运行时:响应工具确认 / ask / elicitation。 #[tauri::command] pub async fn agent_runtime_respond_action( + app: AppHandle, state: State<'_, AsterAgentState>, request: AgentRuntimeRespondActionRequest, ) -> Result<(), String> { @@ -6715,6 +8770,7 @@ pub async fn agent_runtime_respond_action( } AgentRuntimeActionType::AskUser | AgentRuntimeActionType::Elicitation => { let user_data = build_runtime_action_user_data(&request); + let resume_event_name = normalize_optional_text(request.event_name.clone()); submit_runtime_elicitation_response_internal( state.inner(), request.session_id.clone(), @@ -6725,6 +8781,11 @@ pub async fn agent_runtime_respond_action( }, ) .await + .map(|_| { + if let Some(event_name) = resume_event_name.as_deref() { + emit_action_resume_runtime_status(&app, event_name); + } + }) } } } @@ -6868,6 +8929,32 @@ mod tests { } } + fn builtin_extension_config( + name: &str, + available_tools: Vec<&str>, + deferred_loading: bool, + always_expose_tools: Vec<&str>, + allowed_caller: Option<&str>, + ) -> ExtensionConfig { + ExtensionConfig::Builtin { + name: name.to_string(), + display_name: Some(name.to_string()), + description: format!("{name} tools"), + timeout: None, + bundled: Some(false), + available_tools: available_tools + .into_iter() + .map(|item| item.to_string()) + .collect(), + deferred_loading, + always_expose_tools: always_expose_tools + .into_iter() + .map(|item| item.to_string()) + .collect(), + allowed_caller: allowed_caller.map(ToString::to_string), + } + } + #[test] fn test_aster_chat_request_deserialize() { let json = r#"{ @@ -7363,6 +9450,7 @@ mod tests { response: Some("{\"answer\":\"A\"}".to_string()), user_data: Some(serde_json::json!({ "answer": "B" })), metadata: None, + event_name: None, }; assert_eq!( @@ -7381,6 +9469,7 @@ mod tests { response: Some("{\"answer\":\"A\"}".to_string()), user_data: None, metadata: None, + event_name: None, }; assert_eq!( @@ -7389,6 +9478,58 @@ mod tests { ); } + #[test] + fn test_build_runtime_action_user_data_returns_empty_string_when_not_confirmed() { + let request = AgentRuntimeRespondActionRequest { + session_id: "session-1".to_string(), + request_id: "req-2".to_string(), + action_type: AgentRuntimeActionType::AskUser, + confirmed: false, + response: Some("{\"answer\":\"A\"}".to_string()), + user_data: None, + metadata: None, + event_name: None, + }; + + assert_eq!( + build_runtime_action_user_data(&request), + serde_json::Value::String(String::new()) + ); + } + + #[test] + fn test_agent_runtime_respond_action_request_deserializes_event_name_alias() { + let request: AgentRuntimeRespondActionRequest = serde_json::from_value(serde_json::json!({ + "sessionId": "session-1", + "requestId": "req-1", + "actionType": "ask_user", + "confirmed": true, + "eventName": "aster_stream_session-1" + })) + .expect("request should deserialize"); + + assert_eq!(request.session_id, "session-1"); + assert_eq!(request.request_id, "req-1"); + assert_eq!(request.action_type, AgentRuntimeActionType::AskUser); + assert_eq!( + request.event_name.as_deref(), + Some("aster_stream_session-1") + ); + } + + #[test] + fn test_agent_runtime_promote_queued_turn_request_deserializes_aliases() { + let request: AgentRuntimePromoteQueuedTurnRequest = + serde_json::from_value(serde_json::json!({ + "sessionId": "session-1", + "queuedTurnId": "queued-2" + })) + .expect("request should deserialize"); + + assert_eq!(request.session_id, "session-1"); + assert_eq!(request.queued_turn_id, "queued-2"); + } + #[test] fn test_extract_artifact_path_from_tool_start_reads_write_file_path() { let path = extract_artifact_path_from_tool_start( @@ -7734,6 +9875,15 @@ mod tests { assert_eq!(result, Ok("session-1".to_string())); } + #[test] + fn test_build_action_resume_runtime_status_contains_resume_copy() { + let status = build_action_resume_runtime_status(); + assert_eq!(status.phase, "routing"); + assert_eq!(status.title, "已提交补充信息,继续执行中"); + assert!(status.detail.contains("恢复后续步骤")); + assert_eq!(status.checkpoints.len(), 3); + } + #[test] fn test_normalize_workspace_tool_permission_behavior_auto_mode_allows_warning() { let permission = PermissionCheckResult::ask("需要确认"); @@ -7775,6 +9925,151 @@ mod tests { assert!(regex.is_match("python3 <<'EOF'\nprint('hello')\nEOF")); } + #[test] + fn test_workspace_default_allowed_tool_names_include_subagent_controls() { + let tool_names = crate::agent_tools::catalog::workspace_default_allowed_tool_names( + WorkspaceToolSurface::core(), + ); + + for tool_name in [ + "spawn_agent", + "send_input", + "wait_agent", + "resume_agent", + "close_agent", + ] { + assert!( + tool_names.contains(&tool_name), + "缺少默认授权工具: {tool_name}" + ); + } + } + + #[test] + fn test_build_team_preference_system_prompt_requires_subagent_mode() { + let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({ + "harness": { + "subagent_mode_enabled": true, + "preferred_team_preset_id": "code-triage-team", + } + }))) + .expect("team prompt should exist"); + + assert!(prompt.contains(TEAM_PREFERENCE_PROMPT_MARKER)); + assert!(prompt.contains("代码排障团队")); + assert!(prompt.contains("spawn_agent")); + + let disabled = build_team_preference_system_prompt(Some(&serde_json::json!({ + "harness": { + "subagent_mode_enabled": false, + "preferred_team_preset_id": "code-triage-team", + } + }))); + assert!(disabled.is_none()); + } + + #[test] + fn test_build_team_preference_system_prompt_renders_selected_team_details() { + let prompt = build_team_preference_system_prompt(Some(&serde_json::json!({ + "harness": { + "subagent_mode_enabled": true, + "selected_team_source": "custom", + "selected_team_label": "前端联调团队", + "selected_team_summary": "分析、实现、验证三段式推进。", + "selected_team_roles": [ + { + "label": "分析", + "summary": "负责定位问题与影响范围。", + "profile_id": "code-explorer", + "skill_ids": ["repo-exploration"] + }, + { + "label": "执行", + "summary": "负责提交实现与说明改动点。" + } + ] + } + }))) + .expect("team prompt should exist"); + + assert!(prompt.contains("前端联调团队")); + assert!(prompt.contains("来源:custom")); + assert!(prompt.contains("分析、实现、验证三段式推进。")); + assert!(prompt.contains("分析:负责定位问题与影响范围。")); + assert!(prompt.contains("profile: code-explorer")); + assert!(prompt.contains("skills: repo-exploration")); + } + + #[test] + fn test_build_subagent_customization_state_applies_profile_defaults() { + let customization = build_subagent_customization_state(&AgentRuntimeSpawnSubagentRequest { + parent_session_id: "parent-1".to_string(), + message: "定位当前 team runtime 差异".to_string(), + agent_type: Some("Image #1".to_string()), + model: None, + reasoning_effort: None, + fork_context: false, + profile_id: Some("code-explorer".to_string()), + profile_name: None, + role_key: None, + skill_ids: vec!["verification-report".to_string()], + skill_directories: Vec::new(), + team_preset_id: Some("code-triage-team".to_string()), + theme: None, + system_overlay: None, + output_contract: None, + }) + .expect("build customization state") + .expect("customization should exist"); + + assert_eq!(customization.profile_name.as_deref(), Some("代码分析员")); + assert_eq!(customization.role_key.as_deref(), Some("explorer")); + assert_eq!( + customization.team_preset_id.as_deref(), + Some("code-triage-team") + ); + assert_eq!(customization.theme.as_deref(), Some("engineering")); + assert!(customization + .skill_ids + .contains(&"repo-exploration".to_string())); + assert!(customization + .skill_ids + .contains(&"source-grounding".to_string())); + assert!(customization + .skill_ids + .contains(&"verification-report".to_string())); + } + + #[test] + fn test_build_subagent_customization_system_prompt_renders_builtin_configuration() { + let prompt = + build_subagent_customization_system_prompt(Some(&SubagentCustomizationState { + profile_id: Some("code-explorer".to_string()), + profile_name: Some("代码分析员".to_string()), + role_key: Some("explorer".to_string()), + team_preset_id: Some("code-triage-team".to_string()), + theme: Some("engineering".to_string()), + output_contract: Some("输出问题定位、证据与影响面。".to_string()), + system_overlay: Some("先读事实源,再给结论。".to_string()), + skill_ids: vec!["repo-exploration".to_string()], + skills: vec![SubagentSkillSummary { + id: "repo-exploration".to_string(), + name: "仓库探索".to_string(), + description: Some("优先读事实源".to_string()), + source: Some("builtin".to_string()), + directory: None, + }], + })) + .expect("prompt build should succeed") + .expect("prompt should exist"); + + assert!(prompt.contains("【Subagent 定制配置】")); + assert!(prompt.contains("代码分析员")); + assert!(prompt.contains("代码排障团队")); + assert!(prompt.contains("仓库探索")); + assert!(prompt.contains("输出问题定位、证据与影响面。")); + } + #[test] fn test_normalize_shell_command_params_accepts_cmd_alias() { let input = serde_json::json!({ @@ -7983,6 +10278,150 @@ mod tests { assert_eq!(task.max_tokens, Some(4096)); } + #[test] + fn test_build_subagent_task_runtime_message_includes_soft_constraints() { + let input = SubAgentTaskToolInput { + prompt: "探索 team workspace 最佳实践".to_string(), + task_type: Some("explore".to_string()), + description: Some("探索 team workspace".to_string()), + role: Some("explorer".to_string()), + timeout_secs: None, + model: None, + return_summary: None, + allowed_tools: Some(vec!["read_file".to_string()]), + denied_tools: Some(vec!["write_file".to_string()]), + max_tokens: Some(1200), + }; + + let task = build_subagent_task_definition(&input, SubAgentRole::Explorer).unwrap(); + let message = build_subagent_task_runtime_message(&input, &task, SubAgentRole::Explorer); + + assert!(message.contains("任务标题:探索 team workspace")); + assert!(message.contains("子代理角色:explorer")); + assert!(message.contains("工具偏好:优先仅使用这些工具:read_file")); + assert!(message.contains("避免使用这些工具:write_file")); + assert!(message.contains("输出控制:请尽量将最终输出控制在 1200 tokens 内。")); + assert!(message.contains("不要再创建新的子代理")); + assert!(message.contains("任务说明:")); + assert!(message.contains("探索 team workspace 最佳实践")); + } + + #[test] + fn test_collect_subagent_task_compat_warnings_marks_soft_constraints() { + let input = SubAgentTaskToolInput { + prompt: "探索".to_string(), + task_type: None, + description: None, + role: None, + timeout_secs: None, + model: None, + return_summary: None, + allowed_tools: Some(vec!["read_file".to_string()]), + denied_tools: Some(vec!["write_file".to_string()]), + max_tokens: Some(512), + }; + + let warnings = collect_subagent_task_compat_warnings(&input); + assert_eq!(warnings.len(), 3); + assert!(warnings.iter().any(|item| item.contains("allowedTools"))); + assert!(warnings.iter().any(|item| item.contains("deniedTools"))); + assert!(warnings.iter().any(|item| item.contains("maxTokens"))); + } + + #[test] + fn test_subagent_counts_toward_team_limit_matches_controlled_lifecycle() { + assert!(subagent_counts_toward_team_limit( + SubagentRuntimeStatusKind::Idle + )); + assert!(subagent_counts_toward_team_limit( + SubagentRuntimeStatusKind::Queued + )); + assert!(subagent_counts_toward_team_limit( + SubagentRuntimeStatusKind::Running + )); + assert!(subagent_counts_toward_team_limit( + SubagentRuntimeStatusKind::Completed + )); + assert!(subagent_counts_toward_team_limit( + SubagentRuntimeStatusKind::Failed + )); + assert!(!subagent_counts_toward_team_limit( + SubagentRuntimeStatusKind::Closed + )); + assert!(!subagent_counts_toward_team_limit( + SubagentRuntimeStatusKind::NotFound + )); + } + + #[test] + fn test_extract_runtime_subagent_result_text_prefers_assistant_output() { + let detail = SessionDetail { + id: "child-1".to_string(), + name: "子代理".to_string(), + created_at: 0, + updated_at: 0, + thread_id: "thread-1".to_string(), + model: None, + working_dir: None, + workspace_id: None, + messages: vec![TauriMessage { + id: None, + role: "assistant".to_string(), + content: vec![TauriMessageContent::Text { + text: "子代理最终结论".to_string(), + }], + timestamp: 0, + }], + execution_strategy: None, + turns: vec![], + items: vec![], + todo_items: vec![], + child_subagent_sessions: vec![], + subagent_parent_context: None, + }; + + assert_eq!( + extract_runtime_subagent_result_text(&detail).as_deref(), + Some("子代理最终结论") + ); + } + + #[test] + fn test_extract_runtime_subagent_result_text_falls_back_to_turn_error() { + let detail = SessionDetail { + id: "child-2".to_string(), + name: "子代理".to_string(), + created_at: 0, + updated_at: 0, + thread_id: "thread-2".to_string(), + model: None, + working_dir: None, + workspace_id: None, + messages: vec![], + execution_strategy: None, + turns: vec![lime_core::database::dao::agent_timeline::AgentThreadTurn { + id: "turn-1".to_string(), + thread_id: "thread-2".to_string(), + prompt_text: "测试".to_string(), + status: lime_core::database::dao::agent_timeline::AgentThreadTurnStatus::Failed, + started_at: "2026-03-20T10:00:00Z".to_string(), + completed_at: Some("2026-03-20T10:00:01Z".to_string()), + error_message: Some("Provider 错误: Authentication failed".to_string()), + created_at: "2026-03-20T10:00:00Z".to_string(), + updated_at: "2026-03-20T10:00:01Z".to_string(), + }], + items: vec![], + todo_items: vec![], + child_subagent_sessions: vec![], + subagent_parent_context: None, + }; + + assert_eq!( + extract_runtime_subagent_result_text(&detail).as_deref(), + Some("Provider 错误: Authentication failed") + ); + } + #[test] fn test_tool_search_parse_schema_metadata() { let schema = serde_json::json!({ @@ -8038,6 +10477,53 @@ mod tests { assert!(exact > partial); } + #[test] + fn test_tool_search_extension_tool_status_marks_default_visible_and_loaded_tools() { + let configs = vec![builtin_extension_config( + "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 = ToolSearchBridgeTool::extension_tool_status( + &configs, + &visible_tool_names, + "docs__search_docs", + ); + assert_eq!(visible, ("visible", false, Some("docs".to_string()))); + + let loaded = ToolSearchBridgeTool::extension_tool_status( + &configs, + &visible_tool_names, + "docs__read_docs", + ); + assert_eq!(loaded, ("loaded", false, Some("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( + "docs__admin", + vec!["search"], + true, + vec![], + Some("code_execution"), + ), + ]; + + let status = ToolSearchBridgeTool::extension_tool_status( + &configs, + &HashSet::new(), + "docs__admin__search", + ); + assert_eq!(status, ("deferred", true, Some("docs__admin".to_string()))); + } + #[test] fn test_social_generate_cover_image_parse_non_empty_string() { let params = serde_json::json!({ @@ -8144,7 +10630,7 @@ mod tests { ))); } - let tool = ToolSearchBridgeTool::new(registry.clone()); + let tool = ToolSearchBridgeTool::new(registry.clone(), None); let context = ToolContext::new(PathBuf::from(".")); let hidden_result = tool @@ -8197,11 +10683,8 @@ mod tests { /// 将 Lime 已运行的 MCP servers 注入到 Aster Agent 作为 extensions /// -/// 获取 McpClientManager 中所有已运行的 server 配置, -/// 转换为 Aster 的 ExtensionConfig::Stdio 并注册到 Agent。 -/// -/// 关键:将当前进程的 PATH 等环境变量合并到 MCP server 的 env 中, -/// 确保 Aster 启动的子进程能找到 npx/uvx 等命令。 +/// 复用 Lime 已建立的 MCP RunningService,避免 Aster 再次启动独立子进程。 +/// 同时根据 Lime 的工具元数据推导 deferred loading / always expose surface。 /// /// 返回 (成功数, 失败数) async fn inject_mcp_extensions( @@ -8226,6 +10709,23 @@ async fn inject_mcp_extensions( } }; + let all_tools = match manager.list_tools().await { + Ok(tools) => tools, + Err(error) => { + tracing::warn!("[AsterAgent] 读取 MCP 工具列表失败,跳过注入: {}", error); + return (0, running_servers.len()); + } + }; + let mut tools_by_server: HashMap> = HashMap::new(); + for tool in all_tools { + tools_by_server + .entry(tool.server_name.clone()) + .or_default() + .push(tool); + } + + let clients_handle = manager.clients(); + let clients = clients_handle.read().await; let mut success_count = 0usize; let mut fail_count = 0usize; @@ -8238,67 +10738,72 @@ async fn inject_mcp_extensions( continue; } - if let Some(config) = manager.get_client_config(server_name).await { - // 合并当前进程的关键环境变量到 MCP server 的 env 中 - // 确保子进程能找到 npx/uvx/node 等命令 - let mut merged_env = config.env.clone(); - for key in &["PATH", "HOME", "USER", "SHELL", "NODE_PATH", "NVM_DIR"] { - if !merged_env.contains_key(*key) { - if let Ok(val) = std::env::var(key) { - merged_env.insert(key.to_string(), val); - } - } - } - - tracing::info!( - "[AsterAgent] 注入 MCP extension '{}': cmd='{}', args={:?}, env_keys={:?}", - server_name, - config.command, - config.args, - merged_env.keys().collect::>() - ); - - // 增加超时时间:npx 首次下载可能需要较长时间 - let timeout = std::cmp::max(config.timeout, 60); - - let extension = ExtensionConfig::Stdio { - name: server_name.clone(), - description: format!("MCP Server: {server_name}"), - cmd: config.command.clone(), - args: config.args.clone(), - envs: Envs::new(merged_env), - env_keys: vec![], - timeout: Some(timeout), - bundled: Some(false), - available_tools: vec![], - deferred_loading: false, - always_expose_tools: Vec::new(), - allowed_caller: None, - }; - - match agent.add_extension(extension).await { - Ok(_) => { - tracing::info!("[AsterAgent] 成功注入 MCP extension: {}", server_name); - success_count += 1; - } - Err(e) => { - tracing::error!( - "[AsterAgent] 注入 MCP extension '{}' 失败: {}。\ - cmd='{}', args={:?}。请检查命令是否在 PATH 中可用。", - server_name, - e, - config.command, - config.args - ); - fail_count += 1; - } - } - } else { - tracing::warn!("[AsterAgent] 无法获取 MCP server '{}' 的配置", server_name); + let Some(wrapper) = clients.get(server_name) else { + tracing::warn!("[AsterAgent] MCP server '{}' 无连接包装器", server_name); fail_count += 1; - } + continue; + }; + + let Some(running_service) = wrapper.running_service_arc() else { + tracing::warn!("[AsterAgent] MCP server '{}' 无运行中 service", server_name); + fail_count += 1; + continue; + }; + + let server_tools = tools_by_server + .get(server_name) + .cloned() + .unwrap_or_default(); + let surface = build_mcp_extension_surface( + server_name, + format!("Lime MCP Bridge: {server_name}"), + &server_tools, + ); + + let extension = ExtensionConfig::Builtin { + name: server_name.clone(), + display_name: Some(server_name.clone()), + description: surface.description.clone(), + timeout: None, + bundled: Some(false), + available_tools: surface.available_tools.clone(), + deferred_loading: surface.deferred_loading, + always_expose_tools: surface.always_expose_tools.clone(), + allowed_caller: surface.allowed_caller.clone(), + }; + + let bridge_client = McpBridgeClient::new( + server_name.clone(), + running_service.clone(), + wrapper.handler(), + running_service.peer_info().cloned(), + ); + let client: Arc>> = + Arc::new(tokio::sync::Mutex::new(Box::new(bridge_client))); + + agent + .extension_manager + .add_client( + server_name.clone(), + extension, + client, + running_service.peer_info().cloned(), + None, + ) + .await; + + tracing::info!( + "[AsterAgent] 已桥接 MCP extension: name={}, tool_count={}, deferred={}, always_expose={}", + server_name, + surface.available_tools.len(), + surface.deferred_loading, + surface.always_expose_tools.len() + ); + success_count += 1; } + drop(clients); + if fail_count > 0 { tracing::warn!( "[AsterAgent] MCP 注入结果: {} 成功, {} 失败", diff --git a/src-tauri/src/dev_bridge.rs b/src-tauri/src/dev_bridge.rs index 4dea04126..e2f8cdb7d 100644 --- a/src-tauri/src/dev_bridge.rs +++ b/src-tauri/src/dev_bridge.rs @@ -10,7 +10,7 @@ pub mod dispatcher; #[cfg(debug_assertions)] use axum::{ extract::State, - http::{HeaderValue, Method}, + http::{request::Parts as RequestParts, HeaderValue, Method}, response::{IntoResponse, Response}, routing::{get, post}, Json, Router, @@ -22,7 +22,7 @@ use std::sync::Arc; #[cfg(debug_assertions)] use tokio::sync::RwLock; #[cfg(debug_assertions)] -use tower_http::cors::CorsLayer; +use tower_http::cors::{AllowOrigin, CorsLayer}; #[cfg(debug_assertions)] use crate::{app, database::DbConnection}; @@ -89,6 +89,23 @@ impl Default for DevBridgeConfig { #[cfg(debug_assertions)] pub struct DevBridgeServer; +#[cfg(debug_assertions)] +fn is_allowed_loopback_origin(origin: &HeaderValue, _request_parts: &RequestParts) -> bool { + let Ok(origin) = origin.to_str() else { + return false; + }; + + let Ok(parsed) = url::Url::parse(origin) else { + return false; + }; + + matches!(parsed.scheme(), "http" | "https") + && matches!( + parsed.host_str(), + Some("localhost") | Some("127.0.0.1") | Some("[::1]") | Some("::1") + ) +} + #[cfg(debug_assertions)] impl DevBridgeServer { /// 启动开发桥接服务器 @@ -124,20 +141,13 @@ impl DevBridgeServer { shared_stats, }; - let allowed_origins = vec![ - HeaderValue::from_static("http://localhost:1420"), - HeaderValue::from_static("http://127.0.0.1:1420"), - HeaderValue::from_static("http://localhost:5173"), - HeaderValue::from_static("http://127.0.0.1:5173"), - ]; - let app = Router::new() .route("/invoke", post(invoke_command)) .route("/health", get(health_check).post(health_check)) .layer( // CORS 配置 - 允许本地开发前端访问 CorsLayer::new() - .allow_origin(allowed_origins) + .allow_origin(AllowOrigin::predicate(is_allowed_loopback_origin)) .allow_methods([Method::POST, Method::GET, Method::OPTIONS]) .allow_headers([axum::http::header::CONTENT_TYPE]), ) @@ -196,3 +206,47 @@ async fn health_check() -> impl IntoResponse { "version": "1.0.0" })) } + +#[cfg(all(test, debug_assertions))] +mod tests { + use super::is_allowed_loopback_origin; + use axum::http::{request::Parts as RequestParts, HeaderValue, Request}; + + fn empty_parts() -> RequestParts { + let request = Request::builder().uri("/invoke").body(()).unwrap(); + let (parts, _) = request.into_parts(); + parts + } + + #[test] + fn allows_loopback_dev_origins_with_any_port() { + let parts = empty_parts(); + + assert!(is_allowed_loopback_origin( + &HeaderValue::from_static("http://127.0.0.1:1421"), + &parts, + )); + assert!(is_allowed_loopback_origin( + &HeaderValue::from_static("http://localhost:5173"), + &parts, + )); + assert!(is_allowed_loopback_origin( + &HeaderValue::from_static("https://localhost:3000"), + &parts, + )); + } + + #[test] + fn rejects_non_loopback_origins() { + let parts = empty_parts(); + + assert!(!is_allowed_loopback_origin( + &HeaderValue::from_static("https://example.com"), + &parts, + )); + assert!(!is_allowed_loopback_origin( + &HeaderValue::from_static("http://192.168.1.10:1420"), + &parts, + )); + } +} diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index f667eca4b..3297dac24 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -11,10 +11,12 @@ mod memory; mod memory_runtime; mod models; mod openclaw; +mod plugins; mod project_resources; mod providers; mod runtime_queries; mod skills; +mod tray; mod workspace; use crate::dev_bridge::DevBridgeState; @@ -115,10 +117,18 @@ pub async fn handle_command( return Ok(result); } + if let Some(result) = plugins::try_handle(state, cmd, args.as_ref()).await? { + return Ok(result); + } + if let Some(result) = agent_sessions::try_handle(state, cmd, args.as_ref()).await? { return Ok(result); } + if let Some(result) = tray::try_handle(state, cmd, args.as_ref()).await? { + return Ok(result); + } + if let Some(result) = workspace::try_handle(state, cmd, args.as_ref())? { return Ok(result); } diff --git a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs index 60b8b2a7b..ee106a5af 100644 --- a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs +++ b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs @@ -1,12 +1,229 @@ +use super::{args_or_default, get_string_arg, parse_nested_arg, require_app_handle}; use crate::dev_bridge::DevBridgeState; +use serde::de::DeserializeOwned; use serde_json::Value as JsonValue; +use tauri::Manager; type DynError = Box; +fn parse_request(args: Option<&JsonValue>) -> Result { + parse_nested_arg(&args_or_default(args), "request") +} + pub(super) async fn try_handle( - _state: &DevBridgeState, - _cmd: &str, - _args: Option<&JsonValue>, + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, ) -> Result, DynError> { - Ok(None) + if !matches!( + cmd, + "agent_runtime_submit_turn" + | "agent_runtime_interrupt_turn" + | "agent_runtime_create_session" + | "agent_runtime_list_sessions" + | "agent_runtime_get_session" + | "agent_runtime_update_session" + | "agent_runtime_delete_session" + | "agent_runtime_promote_queued_turn" + | "agent_runtime_remove_queued_turn" + | "agent_runtime_respond_action" + ) { + return Ok(None); + } + + let app_handle = require_app_handle(state)?; + let result = match cmd { + "agent_runtime_submit_turn" => { + let request = parse_request::< + crate::commands::aster_agent_cmd::AgentRuntimeSubmitTurnRequest, + >(args)?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + let api_key_provider_service = + app_handle + .state::(); + let logs = app_handle.state::(); + let config_manager = app_handle.state::(); + let mcp_manager = app_handle.state::(); + let automation_state = + app_handle.state::(); + + crate::commands::aster_agent_cmd::agent_runtime_submit_turn( + app_handle.clone(), + aster_state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + request, + ) + .await?; + + JsonValue::Null + } + "agent_runtime_interrupt_turn" => { + let request = parse_request::< + crate::commands::aster_agent_cmd::AgentRuntimeInterruptTurnRequest, + >(args)?; + let aster_state = app_handle.state::(); + serde_json::to_value( + crate::commands::aster_agent_cmd::agent_runtime_interrupt_turn( + app_handle.clone(), + aster_state, + request, + ) + .await?, + )? + } + "agent_runtime_create_session" => { + let args = args_or_default(args); + let workspace_id = get_string_arg(&args, "workspaceId", "workspace_id")?; + let name = args + .get("name") + .and_then(|value| value.as_str()) + .map(ToString::to_string); + let execution_strategy = args + .get("executionStrategy") + .or_else(|| args.get("execution_strategy")) + .cloned() + .map( + serde_json::from_value::< + crate::commands::aster_agent_cmd::AsterExecutionStrategy, + >, + ) + .transpose()?; + let db = app_handle.state::(); + + serde_json::to_value( + crate::commands::aster_agent_cmd::agent_runtime_create_session( + db, + workspace_id, + name, + execution_strategy, + ) + .await?, + )? + } + "agent_runtime_list_sessions" => { + let db = app_handle.state::(); + let logs = app_handle.state::(); + + serde_json::to_value( + crate::commands::aster_agent_cmd::agent_runtime_list_sessions(db, logs).await?, + )? + } + "agent_runtime_get_session" => { + let args = args_or_default(args); + let session_id = get_string_arg(&args, "sessionId", "session_id")?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + let api_key_provider_service = + app_handle + .state::(); + let logs = app_handle.state::(); + let config_manager = app_handle.state::(); + let mcp_manager = app_handle.state::(); + let automation_state = + app_handle.state::(); + + serde_json::to_value( + crate::commands::aster_agent_cmd::agent_runtime_get_session( + app_handle.clone(), + aster_state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + session_id, + ) + .await?, + )? + } + "agent_runtime_update_session" => { + let request = parse_request::< + crate::commands::aster_agent_cmd::AgentRuntimeUpdateSessionRequest, + >(args)?; + let db = app_handle.state::(); + + crate::commands::aster_agent_cmd::agent_runtime_update_session(db, request).await?; + JsonValue::Null + } + "agent_runtime_delete_session" => { + let args = args_or_default(args); + let session_id = get_string_arg(&args, "sessionId", "session_id")?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + + crate::commands::aster_agent_cmd::agent_runtime_delete_session( + app_handle.clone(), + aster_state, + db, + session_id, + ) + .await?; + JsonValue::Null + } + "agent_runtime_remove_queued_turn" => { + let request = parse_request::< + crate::commands::aster_agent_cmd::AgentRuntimeRemoveQueuedTurnRequest, + >(args)?; + serde_json::to_value( + crate::commands::aster_agent_cmd::agent_runtime_remove_queued_turn( + app_handle.clone(), + request, + ) + .await?, + )? + } + "agent_runtime_promote_queued_turn" => { + let request = parse_request::< + crate::commands::aster_agent_cmd::AgentRuntimePromoteQueuedTurnRequest, + >(args)?; + let aster_state = app_handle.state::(); + let db = app_handle.state::(); + let api_key_provider_service = + app_handle + .state::(); + let logs = app_handle.state::(); + let config_manager = app_handle.state::(); + let mcp_manager = app_handle.state::(); + let automation_state = + app_handle.state::(); + serde_json::to_value( + crate::commands::aster_agent_cmd::agent_runtime_promote_queued_turn( + app_handle.clone(), + aster_state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + request, + ) + .await?, + )? + } + "agent_runtime_respond_action" => { + let request = parse_request::< + crate::commands::aster_agent_cmd::AgentRuntimeRespondActionRequest, + >(args)?; + let aster_state = app_handle.state::(); + + crate::commands::aster_agent_cmd::agent_runtime_respond_action( + app_handle.clone(), + aster_state, + request, + ) + .await?; + JsonValue::Null + } + _ => unreachable!("已通过前置 matches! 过滤 agent_runtime 命令"), + }; + + Ok(Some(result)) } diff --git a/src-tauri/src/dev_bridge/dispatcher/models.rs b/src-tauri/src/dev_bridge/dispatcher/models.rs index 37208ad47..eb8395353 100644 --- a/src-tauri/src/dev_bridge/dispatcher/models.rs +++ b/src-tauri/src/dev_bridge/dispatcher/models.rs @@ -60,6 +60,13 @@ pub(super) async fn try_handle( .ok_or_else(|| "模型注册服务未初始化".to_string())?; serde_json::to_value(service.get_sync_state().await)? } + "get_all_alias_configs" => { + let guard = state.model_registry.read().await; + let service = guard + .as_ref() + .ok_or_else(|| "模型注册服务未初始化".to_string())?; + serde_json::to_value(service.get_all_alias_configs().await)? + } "refresh_model_registry" => { let guard = state.model_registry.read().await; let service = guard diff --git a/src-tauri/src/dev_bridge/dispatcher/plugins.rs b/src-tauri/src/dev_bridge/dispatcher/plugins.rs new file mode 100644 index 000000000..0d98b9d35 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/plugins.rs @@ -0,0 +1,37 @@ +use super::require_app_handle; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; +use tauri::Manager; + +type DynError = Box; + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + _args: Option<&JsonValue>, +) -> Result, DynError> { + if cmd != "get_plugins_with_ui" { + return Ok(None); + } + + let app_handle = require_app_handle(state)?; + let result = match cmd { + "get_plugins_with_ui" => { + let installer_state = + app_handle.state::(); + let plugin_manager_state = + app_handle.state::(); + + serde_json::to_value( + crate::commands::plugin_cmd::get_plugins_with_ui( + installer_state, + plugin_manager_state, + ) + .await?, + )? + } + _ => unreachable!("已通过前置判断过滤插件命令"), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/dev_bridge/dispatcher/tray.rs b/src-tauri/src/dev_bridge/dispatcher/tray.rs new file mode 100644 index 000000000..54b771157 --- /dev/null +++ b/src-tauri/src/dev_bridge/dispatcher/tray.rs @@ -0,0 +1,77 @@ +use super::{args_or_default, require_app_handle}; +use crate::dev_bridge::DevBridgeState; +use serde_json::Value as JsonValue; +use tauri::Manager; + +type DynError = Box; + +pub(super) async fn try_handle( + state: &DevBridgeState, + cmd: &str, + args: Option<&JsonValue>, +) -> Result, DynError> { + if cmd != "sync_tray_model_shortcuts" { + return Ok(None); + } + + let app_handle = require_app_handle(state)?; + let result = match cmd { + "sync_tray_model_shortcuts" => { + let Some(tray_state) = app_handle.try_state::>() + else { + return Ok(Some(JsonValue::Null)); + }; + + let args = args_or_default(args); + let current_model_provider_type = args + .get("currentModelProviderType") + .or_else(|| args.get("current_model_provider_type")) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + let current_model_provider_label = args + .get("currentModelProviderLabel") + .or_else(|| args.get("current_model_provider_label")) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + let current_model = args + .get("currentModel") + .or_else(|| args.get("current_model")) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + let current_theme_label = args + .get("currentThemeLabel") + .or_else(|| args.get("current_theme_label")) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + let quick_model_groups = args + .get("quickModelGroups") + .or_else(|| args.get("quick_model_groups")) + .cloned() + .map(serde_json::from_value::>) + .transpose()? + .unwrap_or_default(); + + match crate::commands::tray_cmd::sync_tray_model_shortcuts( + tray_state, + current_model_provider_type, + current_model_provider_label, + current_model, + current_theme_label, + quick_model_groups, + ) + .await + { + Ok(()) => JsonValue::Null, + Err(error) if error.contains("托盘管理器未初始化") => JsonValue::Null, + Err(error) => return Err(error.into()), + } + } + _ => unreachable!("已通过前置判断过滤托盘命令"), + }; + + Ok(Some(result)) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e62b9a7f..5bffec4c4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -39,6 +39,7 @@ pub use lime_mcp as mcp; // 核心模块(Tauri 相关业务逻辑) pub mod agent; +pub mod agent_tools; pub mod app; pub mod plugin; pub mod screenshot; diff --git a/src-tauri/src/services/chat_history_service.rs b/src-tauri/src/services/chat_history_service.rs index 250dfa0bb..f9068858b 100644 --- a/src-tauri/src/services/chat_history_service.rs +++ b/src-tauri/src/services/chat_history_service.rs @@ -212,7 +212,8 @@ mod tests { content_json TEXT NOT NULL, timestamp TEXT NOT NULL, tool_calls_json TEXT, - tool_call_id TEXT + tool_call_id TEXT, + reasoning_content TEXT ); CREATE TABLE general_chat_sessions ( id TEXT PRIMARY KEY, diff --git a/src-tauri/src/services/conversation_statistics_service.rs b/src-tauri/src/services/conversation_statistics_service.rs index 858f67e61..82df9fa78 100644 --- a/src-tauri/src/services/conversation_statistics_service.rs +++ b/src-tauri/src/services/conversation_statistics_service.rs @@ -556,7 +556,8 @@ mod tests { content_json TEXT NOT NULL, timestamp TEXT NOT NULL, tool_calls_json TEXT, - tool_call_id TEXT + tool_call_id TEXT, + reasoning_content TEXT ); CREATE TABLE general_chat_sessions ( id TEXT PRIMARY KEY, diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 51b201e89..1fca7746b 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": "0.91.0", + "version": "0.92.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 790a4e0a9..9e4bffcec 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": "0.91.0", + "version": "0.92.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 82f389da0..4e10d1c55 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -594,12 +594,22 @@ function AppContent() { initialUserPrompt={ (pageParams as AgentPageParams).initialUserPrompt } + initialUserImages={ + (pageParams as AgentPageParams).initialUserImages + } + initialCreationMode={ + (pageParams as AgentPageParams).initialCreationMode + } initialSessionName={ (pageParams as AgentPageParams).initialSessionName } entryBannerMessage={ (pageParams as AgentPageParams).entryBannerMessage } + immersiveHome={(pageParams as AgentPageParams).immersiveHome} + openBrowserAssistOnMount={ + (pageParams as AgentPageParams).openBrowserAssistOnMount + } theme={(pageParams as AgentPageParams).theme} lockTheme={(pageParams as AgentPageParams).lockTheme} fromResources={(pageParams as AgentPageParams).fromResources} diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx new file mode 100644 index 000000000..99ded535d --- /dev/null +++ b/src/components/AppSidebar.test.tsx @@ -0,0 +1,105 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentPageParams, PageParams } from "@/types/page"; +import { AppSidebar } from "./AppSidebar"; + +const { mockGetConfig, mockGetPluginsForSurface } = vi.hoisted(() => ({ + mockGetConfig: vi.fn(), + mockGetPluginsForSurface: vi.fn(), +})); + +vi.mock("@/lib/api/appConfig", () => ({ + getConfig: mockGetConfig, +})); + +vi.mock("@/lib/api/pluginUI", () => ({ + getPluginsForSurface: mockGetPluginsForSurface, +})); + +interface MountedSidebar { + container: HTMLDivElement; + root: Root; +} + +const mountedSidebars: MountedSidebar[] = []; +const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed"; + +function mountSidebar( + currentPageParams?: PageParams, +): MountedSidebar["container"] { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedSidebars.push({ container, root }); + return container; +} + +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + }); +} + +describe("AppSidebar", () => { + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + localStorage.clear(); + mockGetConfig.mockResolvedValue({}); + mockGetPluginsForSurface.mockResolvedValue([]); + }); + + afterEach(() => { + while (mountedSidebars.length > 0) { + const mounted = mountedSidebars.pop(); + if (!mounted) { + continue; + } + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it("进入 Claw 任务中心时应自动折叠导航栏", async () => { + localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "false"); + + const container = mountSidebar({ + agentEntry: "claw", + } as AgentPageParams); + await flushEffects(); + + expect( + container.querySelector('button[aria-label="展开导航栏"]'), + ).not.toBeNull(); + expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("true"); + }); + + it("新建任务页应自动展开导航栏,不沿用上一个页面的折叠状态", async () => { + localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "true"); + + const container = mountSidebar({ + agentEntry: "new-task", + } as AgentPageParams); + await flushEffects(); + + expect( + container.querySelector('button[aria-label="折叠导航栏"]'), + ).not.toBeNull(); + expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("false"); + }); +}); diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index ec36888dc..e44392c5b 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -392,6 +392,9 @@ export function AppSidebar({ currentPageParams, onNavigate, }: AppSidebarProps) { + const agentEntry = (currentPageParams as AgentPageParams | undefined)?.agentEntry; + const isClawTaskCenter = currentPage === "agent" && agentEntry === "claw"; + const isNewTaskHome = currentPage === "agent" && agentEntry === "new-task"; const [collapsed, setCollapsed] = useState(() => { if (typeof window === "undefined") { return false; @@ -545,6 +548,19 @@ export function AppSidebar({ ); }, [collapsed]); + useEffect(() => { + if (isNewTaskHome) { + setCollapsed(false); + return; + } + + if (!isClawTaskCenter) { + return; + } + + setCollapsed(true); + }, [isClawTaskCenter, isNewTaskHome]); + useEffect(() => { if (isThemeWorkspacePage(currentPage)) { setActiveThemeKey(currentPage); diff --git a/src/components/agent/chat/AgentChatHomeShell.test.tsx b/src/components/agent/chat/AgentChatHomeShell.test.tsx new file mode 100644 index 000000000..8bb3d4c29 --- /dev/null +++ b/src/components/agent/chat/AgentChatHomeShell.test.tsx @@ -0,0 +1,244 @@ +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 { AgentChatHomeShell } from "./AgentChatHomeShell"; + +const { mockBuildClawAgentParams, mockSaveChatToolPreferences } = vi.hoisted(() => ({ + mockBuildClawAgentParams: vi.fn((overrides?: Record) => ({ + agentEntry: "claw", + ...(overrides || {}), + })), + mockSaveChatToolPreferences: vi.fn(), +})); + +vi.mock("./components/EmptyState", () => ({ + EmptyState: ({ + onSend, + onRecommendationClick, + }: { + onSend: ( + value: string, + executionStrategy?: unknown, + images?: Array<{ data: string; mediaType: string }>, + ) => void; + onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; + }) => ( + <> + + + + ), +})); + +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(() => ""), + savePersisted: vi.fn(), +})); + +vi.mock("./hooks/agentChatCoreUtils", () => ({ + normalizeExecutionStrategy: vi.fn((value: string) => value || "react"), +})); + +vi.mock("./utils/chatToolPreferences", () => ({ + loadChatToolPreferences: vi.fn(() => ({ + webSearch: false, + thinking: false, + task: false, + subagent: false, + })), + saveChatToolPreferences: mockSaveChatToolPreferences, +})); + +vi.mock("@/lib/workspace/navigation", () => ({ + buildClawAgentParams: mockBuildClawAgentParams, +})); + +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 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("点击 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), + }), + ); + }); +}); diff --git a/src/components/agent/chat/AgentChatHomeShell.tsx b/src/components/agent/chat/AgentChatHomeShell.tsx new file mode 100644 index 000000000..fa83cb8fa --- /dev/null +++ b/src/components/agent/chat/AgentChatHomeShell.tsx @@ -0,0 +1,750 @@ +import { useCallback, useEffect, useState } from "react"; +import styled from "styled-components"; +import { toast } from "sonner"; +import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime"; +import { getProjectMemory, type ProjectMemory } from "@/lib/api/memory"; +import { logAgentDebug } from "@/lib/agentDebug"; +import { skillsApi, type Skill } from "@/lib/api/skills"; +import type { Page, PageParams } from "@/types/page"; +import { SettingsTabs } from "@/types/settings"; +import type { ThemeType } from "@/components/content-creator/types"; +import { EmptyState } from "./components/EmptyState"; +import type { CreationMode } from "./components/types"; +import { buildClawAgentParams } from "@/lib/workspace/navigation"; +import { + DEFAULT_AGENT_MODEL, + DEFAULT_AGENT_PROVIDER, + GLOBAL_MODEL_PREF_KEY, + GLOBAL_PROVIDER_PREF_KEY, + getAgentPreferenceKeys, + loadPersisted, + loadPersistedString, + savePersisted, +} from "./hooks/agentChatStorage"; +import { normalizeExecutionStrategy } from "./hooks/agentChatCoreUtils"; +import type { MessageImage } from "./types"; +import { + loadChatToolPreferences, + saveChatToolPreferences, + type ChatToolPreferences, +} from "./utils/chatToolPreferences"; +import { isTeamRuntimeRecommendation } from "./utils/contextualRecommendations"; +import { normalizeProjectId } from "./utils/topicProjectResolution"; +import { + createTeamDefinitionFromPreset, + listBuiltinTeamDefinitions, + type TeamDefinition, +} from "./utils/teamDefinitions"; +import { + loadCustomTeams, + loadSelectedTeamReference, + persistSelectedTeam, +} from "./utils/teamStorage"; + +const SUPPORTED_ENTRY_THEMES: ThemeType[] = [ + "general", + "social-media", + "poster", + "music", + "knowledge", + "planning", + "document", + "video", + "novel", +]; + +const HOME_ENHANCEMENT_IDLE_TIMEOUT_MS = 1_500; +const HOME_ENHANCEMENT_FALLBACK_DELAY_MS = 180; +const LAST_PROJECT_ID_KEY = "agent_last_project_id"; + +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); + backdrop-filter: blur(18px); +`; + +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 normalizeInitialTheme(value?: string): ThemeType { + if (!value) return "general"; + if (SUPPORTED_ENTRY_THEMES.includes(value as ThemeType)) { + return value as ThemeType; + } + return "general"; +} + +function resolvePersistedSelectedTeam(theme?: string | null): TeamDefinition | null { + const selection = loadSelectedTeamReference(theme); + if (!selection) { + return null; + } + + if (selection.source === "builtin") { + return ( + listBuiltinTeamDefinitions().find((team) => team.id === selection.id) || + null + ); + } + + return loadCustomTeams().find((team) => team.id === selection.id) || null; +} + +function scheduleDeferredHomeEnhancement(task: () => void): () => void { + if (typeof window === "undefined") { + return () => undefined; + } + + if (typeof window.requestIdleCallback === "function") { + const idleId = window.requestIdleCallback(() => task(), { + timeout: HOME_ENHANCEMENT_IDLE_TIMEOUT_MS, + }); + return () => { + if (typeof window.cancelIdleCallback === "function") { + window.cancelIdleCallback(idleId); + } + }; + } + + const timeoutId = window.setTimeout(task, HOME_ENHANCEMENT_FALLBACK_DELAY_MS); + return () => { + window.clearTimeout(timeoutId); + }; +} + +function loadPersistedProjectId(key: string): string | null { + try { + const stored = localStorage.getItem(key); + if (!stored) { + return null; + } + + try { + const parsed = JSON.parse(stored); + return normalizeProjectId(typeof parsed === "string" ? parsed : stored); + } catch { + return normalizeProjectId(stored); + } + } catch { + return null; + } +} + +function savePersistedProjectId(key: string, projectId: string) { + const normalized = normalizeProjectId(projectId); + if (!normalized) { + return; + } + + try { + localStorage.setItem(key, JSON.stringify(normalized)); + } catch { + // ignore write errors + } +} + +function resolveExecutionStrategyStorageKey( + projectId?: string | null, +): string | null { + const normalizedProjectId = normalizeProjectId(projectId); + if (!normalizedProjectId) { + return null; + } + + return `aster_execution_strategy_${normalizedProjectId}`; +} + +function resolvePersistedProviderModel(projectId?: string | null): { + providerType: string; + model: string; +} { + const { providerKey, modelKey } = getAgentPreferenceKeys(projectId); + return { + providerType: + loadPersistedString(providerKey) || + loadPersistedString(GLOBAL_PROVIDER_PREF_KEY) || + DEFAULT_AGENT_PROVIDER, + model: + loadPersistedString(modelKey) || + loadPersistedString(GLOBAL_MODEL_PREF_KEY) || + DEFAULT_AGENT_MODEL, + }; +} + +function resolvePersistedExecutionStrategy( + projectId?: string | null, +): AsterExecutionStrategy { + const storageKey = resolveExecutionStrategyStorageKey(projectId); + if (!storageKey) { + return "react"; + } + + return normalizeExecutionStrategy(loadPersisted(storageKey, "react")); +} + +export interface AgentChatWorkspaceBootstrap { + projectId?: string; + initialUserPrompt?: string; + initialUserImages?: MessageImage[]; + theme?: string; + initialCreationMode?: CreationMode; + openBrowserAssistOnMount?: boolean; + newChatAt?: number; +} + +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 [selectedTeam, setSelectedTeam] = useState(() => + resolvePersistedSelectedTeam(initialTheme), + ); + const [chatToolPreferences, setChatToolPreferences] = + useState(() => + loadChatToolPreferences(normalizedEntryTheme), + ); + const [chatToolPreferencesTheme, setChatToolPreferencesTheme] = + useState(normalizedEntryTheme); + const [currentProjectId, setCurrentProjectId] = useState( + () => + normalizeProjectId(externalProjectId) ?? + loadPersistedProjectId(LAST_PROJECT_ID_KEY), + ); + const initialProviderModel = resolvePersistedProviderModel(currentProjectId); + const [providerType, setProviderTypeState] = useState( + initialProviderModel.providerType, + ); + const [model, setModelState] = useState(initialProviderModel.model); + const [executionStrategy, setExecutionStrategyState] = + useState(() => + resolvePersistedExecutionStrategy(currentProjectId), + ); + const [projectMemory, setProjectMemory] = useState(null); + const [skills, setSkills] = useState([]); + const [skillsLoading, setSkillsLoading] = useState(false); + const [browserAssistLoading, setBrowserAssistLoading] = useState(false); + + useEffect(() => { + setActiveTheme(normalizeInitialTheme(initialTheme)); + }, [initialTheme]); + + useEffect(() => { + if (!initialCreationMode) { + return; + } + setCreationMode(initialCreationMode); + }, [initialCreationMode]); + + useEffect(() => { + setCurrentProjectId( + normalizeProjectId(externalProjectId) ?? + loadPersistedProjectId(LAST_PROJECT_ID_KEY), + ); + }, [externalProjectId]); + + useEffect(() => { + if (chatToolPreferencesTheme === activeTheme) { + return; + } + + setChatToolPreferences(loadChatToolPreferences(activeTheme)); + setChatToolPreferencesTheme(activeTheme); + }, [activeTheme, chatToolPreferencesTheme]); + + useEffect(() => { + if (chatToolPreferencesTheme !== activeTheme) { + return; + } + + saveChatToolPreferences(chatToolPreferences, activeTheme); + }, [activeTheme, chatToolPreferences, chatToolPreferencesTheme]); + + useEffect(() => { + setSelectedTeam(resolvePersistedSelectedTeam(activeTheme)); + }, [activeTheme]); + + useEffect(() => { + persistSelectedTeam(selectedTeam, activeTheme); + }, [activeTheme, selectedTeam]); + + useEffect(() => { + const nextPreferences = resolvePersistedProviderModel(currentProjectId); + setProviderTypeState(nextPreferences.providerType); + setModelState(nextPreferences.model); + setExecutionStrategyState(resolvePersistedExecutionStrategy(currentProjectId)); + }, [currentProjectId]); + + useEffect(() => { + const normalizedProjectId = normalizeProjectId(currentProjectId); + if (!normalizedProjectId) { + setProjectMemory(null); + return; + } + + let cancelled = false; + const startedAt = Date.now(); + logAgentDebug("AgentChatHomeShell", "loadProjectMemory.start", { + projectId: normalizedProjectId, + }); + + void getProjectMemory(normalizedProjectId) + .then((memory) => { + if (cancelled) { + return; + } + setProjectMemory(memory); + logAgentDebug("AgentChatHomeShell", "loadProjectMemory.success", { + durationMs: Date.now() - startedAt, + projectId: normalizedProjectId, + charactersCount: memory.characters.length, + }); + }) + .catch((error) => { + if (cancelled) { + return; + } + setProjectMemory(null); + logAgentDebug( + "AgentChatHomeShell", + "loadProjectMemory.error", + { + durationMs: Date.now() - startedAt, + error, + projectId: normalizedProjectId, + }, + { level: "warn" }, + ); + }); + + return () => { + cancelled = true; + }; + }, [currentProjectId]); + + const loadSkills = useCallback( + async (includeRemote = false): Promise => { + const startedAt = Date.now(); + logAgentDebug("AgentChatHomeShell", "loadSkills.start", { + includeRemote, + }); + setSkillsLoading(true); + try { + const loadedSkills = includeRemote + ? await skillsApi.getAll("lime") + : await skillsApi.getLocal("lime"); + setSkills(loadedSkills); + logAgentDebug("AgentChatHomeShell", "loadSkills.success", { + durationMs: Date.now() - startedAt, + includeRemote, + skillsCount: loadedSkills.length, + }); + return loadedSkills; + } catch (error) { + setSkills([]); + logAgentDebug( + "AgentChatHomeShell", + "loadSkills.error", + { + durationMs: Date.now() - startedAt, + error, + includeRemote, + }, + { level: "warn" }, + ); + return []; + } finally { + setSkillsLoading(false); + } + }, + [], + ); + + useEffect(() => { + return scheduleDeferredHomeEnhancement(() => { + void loadSkills(false); + }); + }, [loadSkills]); + + const setProviderType = useCallback( + (nextProviderType: string) => { + setProviderTypeState(nextProviderType); + const { providerKey } = getAgentPreferenceKeys(currentProjectId); + savePersisted(providerKey, nextProviderType); + }, + [currentProjectId], + ); + + const setModel = useCallback( + (nextModel: string) => { + setModelState(nextModel); + const { modelKey } = getAgentPreferenceKeys(currentProjectId); + savePersisted(modelKey, nextModel); + }, + [currentProjectId], + ); + + const setExecutionStrategy = useCallback( + (nextExecutionStrategy: AsterExecutionStrategy) => { + const normalized = normalizeExecutionStrategy(nextExecutionStrategy); + setExecutionStrategyState(normalized); + const storageKey = resolveExecutionStrategyStorageKey(currentProjectId); + if (!storageKey) { + return; + } + savePersisted(storageKey, normalized); + }, + [currentProjectId], + ); + + const handleRefreshSkills = useCallback(async () => { + await loadSkills(true); + }, [loadSkills]); + + const handleProjectChange = useCallback( + (nextProjectId: string) => { + if (externalProjectId) { + return; + } + + const normalizedProjectId = normalizeProjectId(nextProjectId); + setCurrentProjectId(normalizedProjectId); + if (normalizedProjectId) { + savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId); + } + }, + [externalProjectId], + ); + + const handleEnterWorkspace = useCallback( + (payload: { + prompt?: string; + images?: MessageImage[]; + openBrowserAssistOnMount?: boolean; + toolPreferences?: ChatToolPreferences; + }) => { + const normalizedProjectId = normalizeProjectId(currentProjectId); + const hasPrompt = Boolean(payload.prompt?.trim()); + const hasImages = Boolean(payload.images?.length); + const effectiveToolPreferences = + payload.toolPreferences ?? chatToolPreferences; + + if (!payload.openBrowserAssistOnMount && !normalizedProjectId) { + toast.error("缺少项目工作区,请先选择项目后再使用 Agent"); + return; + } + + if (!payload.openBrowserAssistOnMount && !hasPrompt && !hasImages) { + return; + } + + if (normalizedProjectId) { + savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId); + } + saveChatToolPreferences(effectiveToolPreferences, activeTheme); + const nextNewChatAt = Date.now(); + + if (onNavigate) { + onNavigate( + "agent", + buildClawAgentParams({ + projectId: normalizedProjectId ?? undefined, + theme: activeTheme, + initialCreationMode: creationMode, + initialUserPrompt: payload.prompt, + initialUserImages: payload.images, + openBrowserAssistOnMount: payload.openBrowserAssistOnMount, + newChatAt: nextNewChatAt, + }), + ); + return; + } + + onEnterWorkspace({ + projectId: normalizedProjectId ?? undefined, + initialUserPrompt: payload.prompt, + initialUserImages: payload.images, + theme: activeTheme, + initialCreationMode: creationMode, + openBrowserAssistOnMount: payload.openBrowserAssistOnMount, + newChatAt: nextNewChatAt, + }); + }, + [ + activeTheme, + chatToolPreferences, + creationMode, + currentProjectId, + onEnterWorkspace, + onNavigate, + ], + ); + + const handleRecommendationClick = useCallback( + (shortLabel: string, fullPrompt: string) => { + setInput(fullPrompt); + + if ( + activeTheme !== "general" || + !isTeamRuntimeRecommendation(shortLabel, fullPrompt) + ) { + return; + } + + const nextToolPreferences = chatToolPreferences.subagent + ? chatToolPreferences + : { + ...chatToolPreferences, + subagent: true, + }; + + if (!chatToolPreferences.subagent) { + setChatToolPreferences(nextToolPreferences); + } + saveChatToolPreferences(nextToolPreferences, activeTheme); + handleEnterWorkspace({ + prompt: fullPrompt, + toolPreferences: nextToolPreferences, + }); + }, + [activeTheme, chatToolPreferences, handleEnterWorkspace], + ); + + const handleEnableSuggestedTeam = useCallback((suggestedPresetId?: string) => { + const resolvedPresetId = suggestedPresetId?.trim(); + if (!resolvedPresetId) { + return; + } + + const suggestedTeam = createTeamDefinitionFromPreset(resolvedPresetId); + if (suggestedTeam) { + persistSelectedTeam(suggestedTeam, activeTheme); + setSelectedTeam(suggestedTeam); + } + }, [activeTheme]); + + const handleSelectTeam = useCallback( + (team: TeamDefinition | null) => { + persistSelectedTeam(team, activeTheme); + setSelectedTeam(team); + }, + [activeTheme], + ); + + return ( + + + + + + { + if (sendExecutionStrategy) { + setExecutionStrategy(sendExecutionStrategy); + } + handleEnterWorkspace({ + prompt: value, + images, + }); + }} + providerType={providerType} + setProviderType={setProviderType} + model={model} + setModel={setModel} + modelSelectorBackgroundPreload="idle" + executionStrategy={executionStrategy} + setExecutionStrategy={setExecutionStrategy} + onManageProviders={() => { + onNavigate?.("settings", { + tab: SettingsTabs.Providers, + }); + }} + webSearchEnabled={chatToolPreferences.webSearch} + onWebSearchEnabledChange={(enabled) => + setChatToolPreferences((previous) => ({ + ...previous, + webSearch: enabled, + })) + } + thinkingEnabled={chatToolPreferences.thinking} + onThinkingEnabledChange={(enabled) => + setChatToolPreferences((previous) => ({ + ...previous, + thinking: enabled, + })) + } + taskEnabled={chatToolPreferences.task} + onTaskEnabledChange={(enabled) => + setChatToolPreferences((previous) => ({ + ...previous, + task: enabled, + })) + } + subagentEnabled={chatToolPreferences.subagent} + onSubagentEnabledChange={(enabled) => + setChatToolPreferences((previous) => ({ + ...previous, + subagent: enabled, + })) + } + selectedTeam={selectedTeam} + onSelectTeam={handleSelectTeam} + onEnableSuggestedTeam={handleEnableSuggestedTeam} + creationMode={creationMode} + onCreationModeChange={setCreationMode} + activeTheme={activeTheme} + onThemeChange={(theme) => { + if (!lockTheme) { + setActiveTheme(theme); + } + }} + showThemeTabs={false} + hasCanvasContent={false} + hasContentId={false} + selectedText="" + onRecommendationClick={handleRecommendationClick} + characters={projectMemory?.characters || []} + skills={skills} + isSkillsLoading={skillsLoading} + onNavigateToSettings={() => { + onNavigate?.("settings", { + tab: SettingsTabs.Skills, + }); + }} + onRefreshSkills={handleRefreshSkills} + onLaunchBrowserAssist={() => { + if (activeTheme !== "general") { + return; + } + setBrowserAssistLoading(true); + handleEnterWorkspace({ + prompt: input, + openBrowserAssistOnMount: true, + }); + }} + browserAssistLoading={browserAssistLoading} + projectId={currentProjectId} + onProjectChange={handleProjectChange} + skipProjectSelectorWorkspaceReadyCheck + deferProjectSelectorListLoad + configLoadStrategy="idle" + onOpenSettings={() => { + onNavigate?.("settings", { + tab: SettingsTabs.Appearance, + }); + }} + /> + + + + + + ); +} diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx new file mode 100644 index 000000000..91859bb32 --- /dev/null +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -0,0 +1,10856 @@ +/** + * AI Agent 聊天页面 + * + * 包含聊天区域和侧边栏(任务列表) + * 支持内容创作模式下的布局过渡和步骤引导 + * 当主题为 general 时,使用 GeneralChat 组件实现 + */ + +import { + startTransition, + useState, + useCallback, + useMemo, + useEffect, + useRef, + memo, + type ReactNode, +} from "react"; +import { toast } from "sonner"; +import styled from "styled-components"; +import { + AlertTriangle, + CheckCircle2, + Info, + Loader2, + type LucideIcon, + PanelLeftOpen, +} from "lucide-react"; +import { open as openDialog } from "@tauri-apps/plugin-dialog"; +import { safeListen } from "@/lib/dev-bridge"; +import { readFilePreview } from "@/lib/api/fileBrowser"; +import { + openPathWithDefaultApp, + revealPathInFinder, +} from "@/lib/api/fileSystem"; +import { + uploadImageToSession, + importDocument, + resolveFilePath as resolveSessionFilePath, +} from "@/lib/api/session-files"; +import { + useAgentChatUnified, + useArtifactAutoPreviewSync, + useCompatSubagentRuntime, + useTeamWorkspaceRuntime, + useThemeContextWorkspace, + useTopicBranchBoard, +} from "./hooks"; +import { + buildLiveTaskSnapshot, + type TaskStatusReason, +} from "./hooks/agentChatShared"; +import { + settleLiveArtifactAfterStreamStops, + useArtifactDisplayState, +} from "./hooks/useArtifactDisplayState"; +import type { SidebarActivityLog } from "./hooks/useThemeContextWorkspace"; +import type { TopicBranchStatus } from "./hooks/useTopicBranchBoard"; +import { useSessionFiles } from "./hooks/useSessionFiles"; +import { useContentSync, type SyncStatus } from "./hooks/useContentSync"; +import { getDefaultGuidePromptByTheme } from "./utils/defaultGuidePrompt"; +import { useTrayModelShortcuts } from "./hooks/useTrayModelShortcuts"; +import { + isTeamWorkspaceTerminalStatus, + resolveTeamWorkspaceRuntimeStatusLabel, + type TeamWorkspaceControlSummary, + type TeamWorkspaceWaitSummary, +} from "./teamWorkspaceRuntime"; +import { ChatNavbar } from "./components/ChatNavbar"; +import { ChatSidebar } from "./components/ChatSidebar"; +import { ThemeWorkbenchSidebar } from "./components/ThemeWorkbenchSidebar"; +import type { ThemeWorkbenchCreationTaskEvent } from "./components/themeWorkbenchWorkflowData"; +import { AgentRuntimeStrip } from "./components/AgentRuntimeStrip"; +import { HarnessStatusPanel } from "./components/HarnessStatusPanel"; +import { SocialMediaHarnessCard } from "./components/SocialMediaHarnessCard"; +import { TeamWorkspaceDock } from "./components/TeamWorkspaceDock"; +import { MessageList } from "./components/MessageList"; +import { Inputbar } from "./components/Inputbar"; +import { RuntimeStyleControlBar } from "./components/RuntimeStyleControlBar"; +import { EmptyState } from "./components/EmptyState"; +import { + CanvasWorkbenchLayout, + type CanvasWorkbenchDefaultPreview, + type CanvasWorkbenchLayoutMode, + type CanvasWorkbenchPreviewTarget, +} from "./components/CanvasWorkbenchLayout"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import type { CreationMode } from "./components/types"; +import { type TaskFile } from "./components/TaskFiles"; +import { LayoutTransition } from "@/components/content-creator/core/LayoutTransition/LayoutTransition"; +import { StepProgress } from "@/components/content-creator/core/StepGuide/StepProgress"; +import { useWorkflow } from "@/components/content-creator/hooks/useWorkflow"; +import { CanvasFactory } from "@/components/content-creator/canvas/CanvasFactory"; +import { + createInitialCanvasState, + type CanvasStateUnion, +} from "@/components/content-creator/canvas/canvasUtils"; +import { createInitialDocumentState } from "@/components/content-creator/canvas/document"; +import { + COVER_IMAGE_REPLACED_EVENT, + type CoverImageReplacedDetail, +} from "@/components/content-creator/canvas/document/platforms/CoverImagePlaceholder"; +import type { + AutoContinueRunPayload, + ContentReviewRunPayload, + DocumentVersion, + TextStylizeRunPayload, +} from "@/components/content-creator/canvas/document/types"; +import { parseAIResponse } from "@/components/content-creator/a2ui/parser"; +import { + buildActionRequestA2UI, + buildActionRequestSubmissionPayload, + isActionRequestA2UICompatible, + summarizeActionRequestSubmission, +} from "./utils/actionRequestA2UI"; +import { + buildLegacyQuestionnaireSubmissionPayload, + buildLegacyQuestionnaireA2UI, +} from "./utils/legacyQuestionnaireA2UI"; +import { CanvasPanel as GeneralCanvasPanel } from "@/components/general-chat/bridge"; +import { + type CanvasState as GeneralCanvasState, + DEFAULT_CANVAS_STATE, +} from "@/components/general-chat/bridge"; +import { + artifactsAtom, + selectedArtifactAtom, + selectedArtifactIdAtom, +} from "@/lib/artifact/store"; +import { + ArtifactCanvasOverlay, + ArtifactRenderer, + ArtifactToolbar, +} from "@/components/artifact"; +import type { Artifact } from "@/lib/artifact/types"; +import { useAtomValue, useSetAtom } from "jotai"; +import { createInitialMusicState } from "@/components/content-creator/canvas/music/types"; +import { + createInitialNovelState, + countWords as countNovelWords, +} from "@/components/content-creator/canvas/novel/types"; +import { parseLyrics } from "@/components/content-creator/canvas/music/utils/lyricsParser"; +import { + generateContentCreationPrompt, + isContentCreationTheme, +} from "@/components/content-creator/utils/systemPrompt"; +import { activityLogger } from "@/components/content-creator/utils/activityLogger"; +import { generateProjectMemoryPrompt } from "@/components/content-creator/utils/projectPrompt"; +import { resolveSocialMediaArtifactDescriptor } from "@/components/content-creator/utils/socialMediaHarness"; +import { + getProject, + getDefaultProject, + getOrCreateDefaultProject, + getContent, + getThemeWorkbenchDocumentState, + ensureWorkspaceReady, + updateProject as updateProjectById, + updateContent, + type Project, + type ProjectType, + type ThemeWorkbenchDocumentState, +} from "@/lib/api/project"; +import { + getProjectMemory, + type ProjectMemory, + type Character, +} from "@/lib/api/memory"; +import { logAgentDebug } from "@/lib/agentDebug"; +import { browserExecuteAction, launchBrowserSession } from "@/lib/webview-api"; +import type { Page, PageParams } from "@/types/page"; +import { SettingsTabs } from "@/types/settings"; +import { skillsApi, type Skill } from "@/lib/api/skills"; +import { buildHomeAgentParams } from "@/lib/workspace/navigation"; +import { loadConfiguredProviders } from "@/hooks/useConfiguredProviders"; +import { + executionRunGet, + executionRunGetThemeWorkbenchState, + executionRunListThemeWorkbenchHistory, + type AgentRun, + type ThemeWorkbenchRunTodoItem, + type ThemeWorkbenchRunTerminalItem, + type ThemeWorkbenchRunState as BackendThemeWorkbenchRunState, +} from "@/lib/api/executionRun"; +import { setActiveContentTarget } from "@/lib/activeContentTarget"; +import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; +import { listMaterials, uploadMaterial } from "@/lib/api/materials"; +import { setStoredResourceProjectId } from "@/lib/resourceProjectSelection"; +import { resolveProviderModelCompatibility } from "./utils/providerModelCompatibility"; +import { loadProviderModels } from "@/hooks/useProviderModels"; +import { + isReasoningModel, + resolveBaseModelOnThinkingOff, + resolveThinkingModel, +} from "@/lib/model/thinkingModelResolver"; +import { resolveVisionModel } from "@/lib/model/visionModelResolver"; +import { + loadRememberedBaseModel, + saveRememberedBaseModel, +} from "@/lib/model/thinkingBaseModelMemory"; +import type { + AgentRuntimeToolInventory, + AsterSubagentSessionInfo, + AutoContinueRequestPayload, +} from "@/lib/api/agentRuntime"; +import { + closeAgentRuntimeSubagent, + getAgentRuntimeToolInventory, + resumeAgentRuntimeSubagent, + sendAgentRuntimeSubagentInput, + waitAgentRuntimeSubagents, +} from "@/lib/api/agentRuntime"; +import type { ToolCallState } from "@/lib/api/agentStream"; +import { + skillExecutionApi, + type SkillDetailInfo, +} from "@/lib/api/skill-execution"; + +import type { + BrowserPreflightState, + BrowserAssistSessionState, + BrowserTaskRequirement, + Message, + MessageImage, + WriteArtifactContext, +} from "./types"; +import type { + ThemeType, + LayoutMode, + StepStatus, +} from "@/components/content-creator/types"; +import type { A2UIFormData } from "@/components/content-creator/a2ui/types"; +import { getFileToStepMap } from "./utils/workflowMapping"; +import { normalizeProjectId } from "./utils/topicProjectResolution"; +import { + buildGeneralChatResourceDescription, + buildGeneralChatResourceHash, + buildGeneralChatResourceTags, + extractGeneralChatResourceHash, + inferGeneralChatResourceMaterialType, +} from "./utils/generalResourceSync"; +import { + extractStyleActionContent, + resolveStyleActionFileName, +} from "./utils/styleRuntime"; +import { resolveTopicSwitchProject } from "./utils/topicProjectSwitch"; +import { + loadChatToolPreferences, + saveChatToolPreferences, + type ChatToolPreferences, +} from "./utils/chatToolPreferences"; +import { + buildHarnessRequestMetadata, + extractExistingHarnessMetadata, +} from "./utils/harnessRequestMetadata"; +import { isTeamRuntimeRecommendation } from "./utils/contextualRecommendations"; +import { deriveHarnessSessionState } from "./utils/harnessState"; +import { + buildArtifactFromWrite, + mergeArtifacts, + resolveDefaultArtifactViewMode, +} from "./utils/messageArtifacts"; +import { + buildRealSubagentTimelineItems, + buildSyntheticSubagentTimelineItems, +} from "./utils/subagentTimeline"; +import { resolveThemeWorkbenchLayoutBottomSpacing } from "./utils/themeWorkbenchLayout"; +import { + resolveCanvasTaskFileTarget, + shouldDeferCanvasSyncWhileEditing, +} from "./utils/taskFileCanvasSync"; +import { parseSkillSlashCommand } from "./hooks/skillCommand"; +import { + buildGeneralAgentSystemPrompt, + resolveAgentChatMode, +} from "./utils/generalAgentPrompt"; +import { + buildTeamDefinitionLabel, + buildTeamDefinitionSummary, + createTeamDefinitionFromPreset, + listBuiltinTeamDefinitions, + type TeamDefinition, +} from "./utils/teamDefinitions"; +import { + loadCustomTeams, + loadSelectedTeamReference, + persistSelectedTeam, +} from "./utils/teamStorage"; +import { + areBrowserAssistSessionStatesEqual, + clearBrowserAssistSessionState, + createBrowserAssistSessionState, + extractBrowserAssistSessionFromArtifact, + findLatestBrowserAssistSessionInMessages, + loadBrowserAssistSessionState, + mergeBrowserAssistSessionStates, + resolveBrowserAssistSessionScopeKey, + saveBrowserAssistSessionState, +} from "./utils/browserAssistSession"; +import { + extractExplicitUrlFromText, + resolveBrowserAssistLaunchUrl, +} from "./utils/browserAssistIntent"; +import { preheatBrowserAssistInBackground } from "./utils/browserAssistPreheat"; +import { detectBrowserTaskRequirement } from "./utils/browserTaskRequirement"; +import { mergeThreadItems } from "./utils/threadTimelineView"; +import { subscribeDocumentEditorFocus } from "@/lib/documentEditorFocusEvents"; +import { + DEFAULT_STYLE_PROFILE, + buildRuntimeStyleOverridePrompt, + buildStyleAuditPrompt, + buildStyleRewritePrompt, + getStyleProfileFromGuide, + type RuntimeStyleSelection, +} from "@/lib/style-guide"; +import { useWorkbenchStore } from "@/stores/useWorkbenchStore"; +import { collectConversationSkillNames } from "./utils/harnessSkills"; + +const SUPPORTED_ENTRY_THEMES: ThemeType[] = [ + "general", + "social-media", + "poster", + "music", + "knowledge", + "planning", + "document", + "video", + "novel", +]; + +const GENERAL_BROWSER_ASSIST_PROFILE_KEY = "general_browser_assist"; +const GENERAL_BROWSER_ASSIST_ARTIFACT_ID = "browser-assist:general"; + +function isResumableBrowserTaskReason( + statusReason?: TaskStatusReason, +): boolean { + return ( + statusReason === "browser_launching" || + statusReason === "browser_awaiting_user" || + statusReason === "browser_failed" + ); +} + +interface HarnessFilePreviewResult { + path: string; + content: string | null; + isBinary: boolean; + size: number; + error: string | null; +} + +function extractFileNameFromPath(path: string): string { + const normalized = path.replace(/\\/g, "/"); + const segments = normalized.split("/"); + return segments[segments.length - 1] || path; +} + +function normalizeInitialTheme(value?: string): ThemeType { + if (!value) return "general"; + if (SUPPORTED_ENTRY_THEMES.includes(value as ThemeType)) { + return value as ThemeType; + } + return "general"; +} + +function resolvePersistedSelectedTeam(theme?: string | null): TeamDefinition | null { + const selection = loadSelectedTeamReference(theme); + if (!selection) { + return null; + } + + if (selection.source === "builtin") { + return ( + listBuiltinTeamDefinitions().find((team) => team.id === selection.id) || + null + ); + } + + return loadCustomTeams().find((team) => team.id === selection.id) || null; +} + +function shouldPreserveGeneralArtifact(artifact: Artifact): boolean { + return artifact.meta.persistOutsideMessages === true; +} + +function deriveCurrentSessionRuntimeStatus(params: { + isSending: boolean; + queuedTurnCount: number; + turns: Array<{ status: string }>; +}): AsterSubagentSessionInfo["runtime_status"] | undefined { + if ( + params.isSending || + params.turns.some((turn) => turn.status === "running") + ) { + return "running"; + } + if (params.queuedTurnCount > 0) { + return "queued"; + } + + const latestStatus = params.turns[params.turns.length - 1]?.status; + switch (latestStatus) { + case "completed": + return "completed"; + case "failed": + return "failed"; + case "aborted": + return "aborted"; + default: + return undefined; + } +} + +function deriveLatestTurnRuntimeStatus( + turns: Array<{ status: string }>, +): AsterSubagentSessionInfo["runtime_status"] | undefined { + switch (turns[turns.length - 1]?.status) { + case "queued": + return "queued"; + case "running": + return "running"; + case "completed": + return "completed"; + case "failed": + return "failed"; + case "aborted": + return "aborted"; + default: + return undefined; + } +} + +function normalizeUniqueSessionIds(ids: string[]): string[] { + return Array.from( + new Set(ids.map((sessionId) => sessionId.trim()).filter(Boolean)), + ); +} + +function buildTeamControlSummary(params: { + action: TeamWorkspaceControlSummary["action"]; + requestedSessionIds: string[]; + cascadeSessionIds?: string[]; + affectedSessionIds?: string[]; +}): TeamWorkspaceControlSummary { + return { + action: params.action, + requestedSessionIds: normalizeUniqueSessionIds(params.requestedSessionIds), + cascadeSessionIds: normalizeUniqueSessionIds( + params.cascadeSessionIds ?? [], + ), + affectedSessionIds: normalizeUniqueSessionIds( + params.affectedSessionIds ?? [], + ), + updatedAt: Date.now(), + }; +} + +function buildBrowserAssistArtifact(params: { + scopeKey: string; + profileKey: string; + browserSessionId: string; + url: string; + title?: string; + targetId?: string; + transportKind?: string; + lifecycleState?: string; + controlMode?: string; +}): Artifact { + const now = Date.now(); + + return { + id: GENERAL_BROWSER_ASSIST_ARTIFACT_ID, + type: "browser_assist", + title: params.title?.trim() || "浏览器协助", + content: "", + status: "complete", + error: undefined, + meta: { + persistOutsideMessages: true, + browserAssistScopeKey: params.scopeKey, + profileKey: params.profileKey, + sessionId: params.browserSessionId, + url: params.url, + launchState: "ready", + launchHint: undefined, + launchError: undefined, + ...(params.targetId ? { targetId: params.targetId } : {}), + ...(params.transportKind ? { transportKind: params.transportKind } : {}), + ...(params.lifecycleState + ? { lifecycleState: params.lifecycleState } + : {}), + ...(params.controlMode ? { controlMode: params.controlMode } : {}), + }, + position: { start: 0, end: 0 }, + createdAt: now, + updatedAt: now, + }; +} + +function buildPendingBrowserAssistArtifact(params: { + scopeKey: string; + profileKey: string; + url: string; + title?: string; +}): Artifact { + const now = Date.now(); + + return { + id: GENERAL_BROWSER_ASSIST_ARTIFACT_ID, + type: "browser_assist", + title: params.title?.trim() || "浏览器协助", + content: "", + status: "pending", + error: undefined, + meta: { + persistOutsideMessages: true, + browserAssistScopeKey: params.scopeKey, + profileKey: params.profileKey, + url: params.url, + launchState: "launching", + launchHint: + "正在启动 Chrome、连接调试通道并等待首帧画面,通常需要 3–8 秒。", + launchError: undefined, + }, + position: { start: 0, end: 0 }, + createdAt: now, + updatedAt: now, + }; +} + +function buildFailedBrowserAssistArtifact(params: { + scopeKey: string; + profileKey: string; + url: string; + title?: string; + error: string; +}): Artifact { + const now = Date.now(); + + return { + id: GENERAL_BROWSER_ASSIST_ARTIFACT_ID, + type: "browser_assist", + title: params.title?.trim() || "浏览器协助", + content: "", + status: "error", + error: params.error, + meta: { + persistOutsideMessages: true, + browserAssistScopeKey: params.scopeKey, + profileKey: params.profileKey, + url: params.url, + launchState: "failed", + launchHint: undefined, + launchError: params.error, + }, + position: { start: 0, end: 0 }, + createdAt: now, + updatedAt: now, + }; +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + return value as Record; +} + +function readFirstString( + 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 resolveBrowserAssistArtifactScopeKey( + artifact: Pick | null | undefined, +): string | null { + if (!artifact || artifact.type !== "browser_assist") { + return null; + } + + const meta = asRecord(artifact.meta); + return ( + readFirstString(meta ? [meta] : [], [ + "browserAssistScopeKey", + "browser_assist_scope_key", + ]) || null + ); +} +function resolveArtifactFilePath( + artifact: Pick, +): string { + if ( + typeof artifact.meta.filePath === "string" && + artifact.meta.filePath.trim() + ) { + return artifact.meta.filePath.trim(); + } + if ( + typeof artifact.meta.filename === "string" && + artifact.meta.filename.trim() + ) { + return artifact.meta.filename.trim(); + } + return artifact.title; +} + +function resolveAbsoluteWorkspacePath( + workspaceRoot: string | null | undefined, + filePath: string | null | undefined, +): string | undefined { + const normalizedFilePath = filePath?.trim(); + if (!normalizedFilePath) { + return undefined; + } + + if ( + normalizedFilePath.startsWith("/") || + normalizedFilePath.startsWith("~/") || + normalizedFilePath.startsWith("\\\\") || + /^[A-Za-z]:[\\/]/.test(normalizedFilePath) + ) { + return normalizedFilePath; + } + + const normalizedWorkspaceRoot = workspaceRoot?.trim(); + if (!normalizedWorkspaceRoot) { + return normalizedFilePath; + } + + return `${normalizedWorkspaceRoot.replace(/[\\/]+$/, "")}/${normalizedFilePath.replace(/^[\\/]+/, "")}`; +} + +function resolvePreviousDocumentVersionContent( + version: DocumentVersion | null | undefined, + versions: DocumentVersion[], +): string | null { + if (!version) { + return null; + } + + const parentVersionId = version.metadata?.parentVersionId?.trim(); + if (parentVersionId) { + const parentVersion = versions.find((item) => item.id === parentVersionId); + if (parentVersion) { + return parentVersion.content; + } + } + + const currentIndex = versions.findIndex((item) => item.id === version.id); + if (currentIndex > 0) { + return versions[currentIndex - 1]?.content || null; + } + + return null; +} + +function wrapPreviewWithWorkbenchTrigger( + preview: ReactNode, + stackedWorkbenchTrigger?: ReactNode, +) { + if (!stackedWorkbenchTrigger) { + return preview; + } + + return ( +
+ {preview} +
+
{stackedWorkbenchTrigger}
+
+
+ ); +} + +function mergeMessageArtifactsIntoStore( + messageArtifacts: Artifact[], + currentArtifacts: Artifact[], + browserAssistScopeKey: string | null, +): Artifact[] { + const preservedArtifacts = currentArtifacts.filter( + (artifact) => + shouldPreserveGeneralArtifact(artifact) && + (artifact.type !== "browser_assist" || + resolveBrowserAssistArtifactScopeKey(artifact) === + browserAssistScopeKey), + ); + + if (messageArtifacts.length === 0) { + return mergeArtifacts(preservedArtifacts); + } + + const currentArtifactsById = new Map( + currentArtifacts.map((artifact) => [artifact.id, artifact]), + ); + + return mergeArtifacts([ + ...messageArtifacts.map((artifact) => { + const existing = currentArtifactsById.get(artifact.id); + if (!existing) { + return artifact; + } + + const shouldReuseExistingContent = + existing.content.length > 0 && + (artifact.content.length === 0 || + (artifact.status === "streaming" && + artifact.content.length < existing.content.length && + existing.content.startsWith(artifact.content))); + + return { + ...existing, + ...artifact, + content: shouldReuseExistingContent + ? existing.content + : artifact.content, + meta: { + ...existing.meta, + ...artifact.meta, + }, + createdAt: Math.min(existing.createdAt, artifact.createdAt), + updatedAt: Math.max(existing.updatedAt, artifact.updatedAt), + }; + }), + ...preservedArtifacts, + ]); +} + +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); + backdrop-filter: blur(18px); +`; + +function resolveContentSyncTone(status: SyncStatus): { + text: string; + background: string; + border: string; +} { + switch (status) { + case "syncing": + return { + text: "#475569", + background: + "linear-gradient(180deg, rgba(255,255,255,0.94) 0%, rgba(248,250,252,0.92) 100%)", + border: "rgba(226, 232, 240, 0.9)", + }; + case "success": + return { + text: "#047857", + background: + "linear-gradient(180deg, rgba(236,253,245,0.98) 0%, rgba(220,252,231,0.92) 100%)", + border: "rgba(167, 243, 208, 0.95)", + }; + case "error": + return { + text: "#be123c", + background: + "linear-gradient(180deg, rgba(255,241,242,0.98) 0%, rgba(255,228,230,0.92) 100%)", + border: "rgba(254, 205, 211, 0.95)", + }; + case "idle": + default: + return { + text: "#475569", + background: + "linear-gradient(180deg, rgba(255,255,255,0.94) 0%, rgba(248,250,252,0.9) 100%)", + border: "rgba(226, 232, 240, 0.88)", + }; + } +} + +const ContentSyncNotice = styled.div<{ $status: SyncStatus }>` + ${({ $status }) => { + const tone = resolveContentSyncTone($status); + return ` + display: flex; + align-items: center; + gap: 8px; + margin: -2px 14px 10px; + padding: 8px 12px; + border: 1px solid ${tone.border}; + border-radius: 14px; + background: ${tone.background}; + color: ${tone.text}; + box-shadow: 0 10px 24px hsl(var(--foreground) / 0.03); + `; + }} +`; + +const ContentSyncNoticeText = styled.span` + font-size: 12px; + font-weight: 500; + line-height: 1.4; +`; + +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 EntryBanner = styled.div` + display: flex; + align-items: center; + gap: 8px; + margin: 8px 12px 0; + padding: 10px 12px; + border-radius: 18px; + border: 1px solid rgba(191, 219, 254, 0.9); + background: linear-gradient( + 180deg, + rgba(239, 246, 255, 0.96) 0%, + rgba(248, 250, 252, 0.92) 100% + ); + color: #0f172a; + font-size: 13px; + box-shadow: 0 10px 22px -20px rgba(15, 23, 42, 0.16); +`; + +const EntryBannerClose = styled.button` + margin-left: auto; + border: none; + background: transparent; + color: #64748b; + cursor: pointer; + font-size: 13px; +`; + +const ChatContent = styled.div<{ $compact?: boolean }>` + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + padding: ${({ $compact }) => ($compact ? "0 6px 6px" : "0 10px 10px")}; + overflow: hidden; + height: 100%; + position: relative; +`; + +const MessageViewport = styled.div<{ $bottomPadding?: string }>` + flex: 1; + min-height: 0; + overflow: hidden; + padding-bottom: ${({ $bottomPadding }) => $bottomPadding || "128px"}; +`; + +const ThemeWorkbenchInputOverlay = styled.div<{ + $hasPendingA2UIForm?: boolean; +}>` + position: absolute; + left: 24px; + right: 24px; + bottom: 20px; + z-index: 25; + pointer-events: none; + display: flex; + justify-content: center; + box-sizing: border-box; + + > * { + pointer-events: auto; + width: ${({ $hasPendingA2UIForm }) => + $hasPendingA2UIForm + ? "min(calc(100% - 24px), 880px)" + : "min(calc(100% - 16px), 480px)"}; + max-width: 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; +`; + +const ThemeWorkbenchCanvasHost = styled.div` + flex: 1; + min-height: 0; + + > * { + height: 100%; + } +`; + +interface LayoutTransitionRenderGateProps { + mode: LayoutMode; + chatContent: ReactNode; + canvasContent: ReactNode; +} + +const LayoutTransitionRenderGate = memo( + ({ mode, chatContent, canvasContent }: LayoutTransitionRenderGateProps) => ( + + + + ), + (previous, next) => + previous.mode === next.mode && + previous.chatContent === next.chatContent && + previous.canvasContent === next.canvasContent, +); +LayoutTransitionRenderGate.displayName = "LayoutTransitionRenderGate"; + +interface HandleSendObserver { + onComplete?: (content: string) => void; + onError?: (message: string) => void; +} + +interface HandleSendOptions { + skipThemeSkillPrefix?: boolean; + purpose?: "content_review" | "text_stylize" | "style_rewrite" | "style_audit"; + observer?: HandleSendObserver; + requestMetadata?: Record; + browserPreflightConfirmed?: boolean; + toolPreferencesOverride?: ChatToolPreferences; +} + +interface BrowserTaskPreflight { + requestId: string; + createdAt: number; + sourceText: string; + images: MessageImage[]; + webSearch?: boolean; + thinking?: boolean; + sendExecutionStrategy?: "react" | "code_orchestrated" | "auto"; + autoContinuePayload?: AutoContinueRequestPayload; + sendOptions?: HandleSendOptions; + requirement: BrowserTaskRequirement; + reason: string; + phase: BrowserPreflightState; + launchUrl: string; + platformLabel?: string; + detail?: string; +} + +const ThemeWorkbenchLeftExpandButton = styled.button` + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 78px; + border: 1px solid rgba(226, 232, 240, 0.92); + border-radius: 14px; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.94) 0%, + rgba(248, 250, 252, 0.9) 100% + ); + color: #64748b; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 30; + box-shadow: 0 14px 28px -24px rgba(15, 23, 42, 0.2); + + &:hover { + color: #0f172a; + border-color: rgba(148, 163, 184, 0.84); + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.98) 0%, + rgba(241, 245, 249, 0.92) 100% + ); + } +`; + +function resolveContentSyncNotice(status: Exclude): { + label: string; + Icon: LucideIcon; + animated?: boolean; +} { + switch (status) { + case "syncing": + return { + label: "正在同步到当前内容…", + Icon: Loader2, + animated: true, + }; + case "success": + return { + label: "内容已同步", + Icon: CheckCircle2, + }; + case "error": + default: + return { + label: "同步失败,将自动重试", + Icon: AlertTriangle, + }; + } +} + +/** + * 将 ProjectType 转换为 ThemeType + * 由于类型已统一,大部分情况下直接返回即可 + */ +function projectTypeToTheme(projectType: ProjectType): ThemeType { + // ProjectType 和 ThemeType 现在是统一的 + // 系统类型 persistent/temporary 映射到 general + if (projectType === "persistent" || projectType === "temporary") { + return "general"; + } + return projectType as ThemeType; +} + +const LAST_PROJECT_ID_KEY = "agent_last_project_id"; +const TOPIC_PROJECT_KEY_PREFIX = "agent_session_workspace_"; +const THEME_WORKBENCH_DOCUMENT_META_KEY = "theme_workbench_document_v1"; +const MAX_PERSISTED_DOCUMENT_VERSIONS = 40; +const SOCIAL_ARTICLE_SKILL_KEY = "social_post_with_cover"; +const THEME_WORKBENCH_CREATION_TASK_EVENT_NAME = + "lime://creation_task_submitted"; +const MAX_THEME_WORKBENCH_CREATION_TASK_EVENTS = 120; +const BROWSER_PREFLIGHT_REQUEST_PREFIX = "browser-preflight:"; + +interface CreationTaskSubmittedPayload { + task_id?: string; + task_type?: string; + path?: string; + absolute_path?: string; +} + +function normalizeThemeWorkbenchCreationTaskEvent( + payload: CreationTaskSubmittedPayload, +): ThemeWorkbenchCreationTaskEvent | null { + const taskId = payload.task_id?.trim(); + const taskType = payload.task_type?.trim(); + const path = payload.path?.trim(); + if (!taskId || !taskType || !path) { + return null; + } + const createdAt = Date.now(); + return { + taskId, + taskType, + path, + absolutePath: payload.absolute_path?.trim() || undefined, + createdAt, + timeLabel: new Date(createdAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }), + }; +} + +function hasActiveBrowserAssistSession( + sessionState: BrowserAssistSessionState | null, +): boolean { + if (!sessionState) { + return false; + } + + if (!sessionState.sessionId && !sessionState.profileKey) { + return false; + } + + const lifecycleState = sessionState.lifecycleState?.trim().toLowerCase(); + return !["failed", "closed", "terminated"].includes(lifecycleState || ""); +} + +function buildBrowserPreflightMessages( + preflight: BrowserTaskPreflight, +): Message[] { + const timestamp = new Date(preflight.createdAt); + const actionRequired = { + requestId: preflight.requestId, + actionType: "ask_user" as const, + uiKind: "browser_preflight" as const, + browserRequirement: preflight.requirement, + browserPrepState: preflight.phase, + prompt: preflight.reason, + detail: preflight.detail, + allowCapabilityFallback: false, + }; + + return [ + { + id: `${preflight.requestId}:user`, + role: "user", + content: preflight.sourceText, + images: preflight.images.length > 0 ? preflight.images : undefined, + timestamp, + }, + { + id: `${preflight.requestId}:assistant`, + role: "assistant", + content: "", + timestamp: new Date(preflight.createdAt + 1), + actionRequests: [actionRequired], + contentParts: [{ type: "action_required", actionRequired }], + }, + ]; +} + +function buildInitialDispatchPreviewMessages( + dispatchKey: string, + prompt?: string, + images?: MessageImage[], +): Message[] { + const normalizedPrompt = (prompt || "").trim(); + const normalizedImages = images || []; + + if (!normalizedPrompt && normalizedImages.length === 0) { + return []; + } + + const timestamp = new Date(); + + return [ + { + id: `initial-dispatch:${dispatchKey}:user`, + role: "user", + content: normalizedPrompt, + images: normalizedImages.length > 0 ? normalizedImages : undefined, + timestamp, + }, + { + id: `initial-dispatch:${dispatchKey}:assistant`, + role: "assistant", + content: "正在开始处理任务…", + timestamp: new Date(timestamp.getTime() + 1), + isThinking: true, + }, + ]; +} + +interface InitialDispatchPreviewSnapshot { + key: string; + prompt?: string; + images: MessageImage[]; +} + +function isLegacyQuestionnaireSummaryMessage(message?: Message): boolean { + return ( + message?.role === "user" && message.content.trim().startsWith("我的选择:") + ); +} + +function collapseLegacyQuestionnaireMessages(messages: Message[]): Message[] { + let mutated = false; + const collapsedMessages = messages.map((message, index) => { + if (message.role !== "assistant") { + return message; + } + + if ((message.actionRequests || []).length > 0) { + return message; + } + + const legacyForm = buildLegacyQuestionnaireA2UI(message.content || ""); + if (!legacyForm) { + return message; + } + + const nextMessage = messages[index + 1]; + const isPendingQuestionnaire = index === messages.length - 1; + const hasSubmittedSummary = + isLegacyQuestionnaireSummaryMessage(nextMessage); + + if (!isPendingQuestionnaire && !hasSubmittedSummary) { + return message; + } + + mutated = true; + return { + ...message, + content: hasSubmittedSummary + ? "补充信息表单已提交。" + : "已整理为补充信息表单,请在输入区完成填写。", + }; + }); + + return mutated ? collapsedMessages : messages; +} + +function resolveThemeWorkbenchRunStepStatus( + status: "queued" | "running" | "success" | "error" | "canceled" | "timeout", +): StepStatus { + if (status === "running") { + return "active"; + } + if (status === "queued") { + return "pending"; + } + if (status === "success") { + return "completed"; + } + return "error"; +} + +function parseThemeWorkbenchToolArguments( + argumentsJson?: string, +): Record { + if (!argumentsJson) { + return {}; + } + + try { + const parsed = JSON.parse(argumentsJson); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function truncateThemeWorkbenchLabel(value: string, limit = 28): string { + return value.length > limit ? `${value.slice(0, limit)}…` : value; +} + +function resolveThemeWorkbenchTextArg( + args: Record, + keys: string[], +): string { + for (const key of keys) { + const value = args[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + if (Array.isArray(value)) { + const firstString = value.find( + (item): item is string => + typeof item === "string" && item.trim().length > 0, + ); + if (firstString) { + return firstString.trim(); + } + } + } + return ""; +} + +function getThemeWorkbenchFileLabel(pathValue: string): string { + const normalized = pathValue.trim(); + if (!normalized) { + return "主稿文件"; + } + const segments = normalized.split(/[/\\]/).filter(Boolean); + if (segments.length >= 2) { + return `${segments[segments.length - 2]}/${segments[segments.length - 1]}`; + } + return segments[0] || normalized; +} + +function resolveThemeWorkbenchToolTaskTitle(toolCall: ToolCallState): string { + const normalized = toolCall.name.trim().toLowerCase(); + const args = parseThemeWorkbenchToolArguments(toolCall.arguments); + const queryValue = resolveThemeWorkbenchTextArg(args, [ + "query", + "q", + "keyword", + "pattern", + "text", + ]); + const urlValue = resolveThemeWorkbenchTextArg(args, ["url", "href"]); + const elementValue = resolveThemeWorkbenchTextArg(args, [ + "element", + "name", + "label", + "ref", + ]); + + if (normalized.includes("social_generate_cover_image")) { + const size = resolveThemeWorkbenchTextArg(args, ["size"]); + return size ? `生成封面图(${size})` : "生成封面图"; + } + if (normalized.includes("write_file") || normalized.includes("create_file")) { + const pathValue = resolveThemeWorkbenchTextArg(args, [ + "path", + "file_path", + "filePath", + ]); + return pathValue + ? `写入 ${getThemeWorkbenchFileLabel(pathValue)}` + : "写入主稿文件"; + } + if (normalized.includes("websearch")) { + return queryValue + ? `检索 ${truncateThemeWorkbenchLabel(queryValue)}` + : "检索参考资料"; + } + if ( + normalized.includes("browser_navigate") || + (normalized.includes("navigate") && urlValue) + ) { + return urlValue + ? `打开 ${truncateThemeWorkbenchLabel(urlValue, 36)}` + : "打开网页"; + } + if (normalized.includes("browser_click") || normalized === "click") { + return elementValue + ? `点击「${truncateThemeWorkbenchLabel(elementValue, 20)}」` + : "点击页面元素"; + } + if (normalized.includes("browser_hover") || normalized === "hover") { + return elementValue + ? `定位「${truncateThemeWorkbenchLabel(elementValue, 20)}」` + : "定位页面元素"; + } + if (normalized.includes("browser_type") || normalized === "type") { + return elementValue + ? `填写「${truncateThemeWorkbenchLabel(elementValue, 20)}」` + : queryValue + ? `填写 ${truncateThemeWorkbenchLabel(queryValue, 18)}` + : "填写页面内容"; + } + if ( + normalized.includes("browser_select_option") || + normalized.includes("select_option") + ) { + const value = resolveThemeWorkbenchTextArg(args, [ + "value", + "values", + "option", + ]); + return value + ? `选择 ${truncateThemeWorkbenchLabel(value, 20)}` + : elementValue + ? `选择「${truncateThemeWorkbenchLabel(elementValue, 20)}」` + : "选择页面选项"; + } + if ( + normalized.includes("browser_press_key") || + normalized.includes("press_key") + ) { + const keyValue = resolveThemeWorkbenchTextArg(args, ["key"]); + return keyValue ? `触发按键 ${keyValue}` : "触发页面快捷键"; + } + if (normalized.includes("browser_drag") || normalized.includes("drag")) { + const endValue = resolveThemeWorkbenchTextArg(args, [ + "endElement", + "endRef", + ]); + return endValue + ? `拖拽到「${truncateThemeWorkbenchLabel(endValue, 18)}」` + : "拖拽页面元素"; + } + if ( + normalized.includes("browser_snapshot") || + normalized.includes("screenshot") + ) { + return elementValue + ? `分析页面区域:${truncateThemeWorkbenchLabel(elementValue, 20)}` + : urlValue + ? `分析页面 ${truncateThemeWorkbenchLabel(urlValue, 30)}` + : "分析页面内容"; + } + if (normalized.includes("bash") || normalized.includes("shell")) { + const commandValue = resolveThemeWorkbenchTextArg(args, ["command", "cmd"]); + const commandProbe = commandValue.toLowerCase(); + if (commandProbe.includes("ffmpeg")) { + return "处理音视频素材"; + } + if (commandProbe.includes("curl") || commandProbe.includes("wget")) { + return "下载远程资源"; + } + if ( + commandProbe.includes("python") || + commandProbe.includes("node") || + commandProbe.includes("tsx") || + commandProbe.includes("npm") + ) { + return "执行自动化脚本"; + } + return commandValue + ? `执行命令:${truncateThemeWorkbenchLabel(commandValue, 22)}` + : "执行终端命令"; + } + if (normalized.includes("browser")) { + return urlValue + ? `采集 ${truncateThemeWorkbenchLabel(urlValue, 36)}` + : elementValue + ? `处理页面元素:${truncateThemeWorkbenchLabel(elementValue, 20)}` + : "采集网页信息"; + } + return toolCall.name.replace(/[_-]+/g, " ").trim() || "执行工具"; +} + +function resolveThemeWorkbenchPrimaryTaskTitle( + skillName: string, + detail?: SkillDetailInfo | null, +): string { + if (skillName === SOCIAL_ARTICLE_SKILL_KEY) { + return "生成社媒主稿"; + } + + const displayName = detail?.display_name?.trim(); + if (displayName) { + return displayName; + } + + return skillName.replace(/[_-]+/g, " ").trim() || "执行任务"; +} + +function extractThemeWorkbenchWorkflowMarkerIndex( + content: string, +): number | null { + const matches = [...content.matchAll(/\*\*步骤\s+(\d+)\/(\d+):/g)]; + if (matches.length === 0) { + return null; + } + const last = matches[matches.length - 1]; + const value = Number(last[1]); + if (!Number.isFinite(value) || value <= 0) { + return null; + } + return value - 1; +} + +function findLatestThemeWorkbenchExecution(messages: Message[]): { + assistantMessage: Message; + skillName: string | null; +} | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role !== "assistant") { + continue; + } + + const hasToolCalls = (message.toolCalls?.length || 0) > 0; + const hasPendingAction = + message.actionRequests?.some( + (request) => request.status !== "submitted", + ) || false; + if (!message.isThinking && !hasToolCalls && !hasPendingAction) { + continue; + } + + let skillName: string | null = null; + for (let userIndex = index - 1; userIndex >= 0; userIndex -= 1) { + const candidate = messages[userIndex]; + if (candidate.role !== "user") { + continue; + } + skillName = parseSkillSlashCommand(candidate.content)?.skillName || null; + break; + } + + return { + assistantMessage: message, + skillName, + }; + } + + return null; +} + +function buildThemeWorkbenchLiveWorkflowSteps( + messages: Message[], + skillDetailMap: Record, + isSending: boolean, +): Array<{ id: string; title: string; status: StepStatus }> { + const activeExecution = findLatestThemeWorkbenchExecution(messages); + if (!activeExecution) { + return []; + } + + const { assistantMessage, skillName } = activeExecution; + if (!skillName) { + return []; + } + + const skillDetail = skillDetailMap[skillName] || null; + const workflowSteps = skillDetail?.workflow_steps || []; + if (workflowSteps.length > 0) { + const latestAssistantContent = + messages + .slice() + .reverse() + .find((m) => m.role === "assistant")?.content || ""; + const activeIndex = + extractThemeWorkbenchWorkflowMarkerIndex(latestAssistantContent) ?? 0; + return workflowSteps.map((step, index) => ({ + id: step.id, + title: step.name, + status: + index < activeIndex + ? ("completed" as StepStatus) + : index == activeIndex + ? ("active" as StepStatus) + : ("pending" as StepStatus), + })); + } + + const toolCalls = assistantMessage.toolCalls || []; + const steps: Array<{ id: string; title: string; status: StepStatus }> = []; + const primaryTaskTitle = resolveThemeWorkbenchPrimaryTaskTitle( + skillName, + skillDetail, + ); + const hasRunningTool = toolCalls.some( + (toolCall) => toolCall.status === "running", + ); + const hasFailedTool = toolCalls.some( + (toolCall) => toolCall.status === "failed", + ); + const hasCompletedPrimaryWrite = toolCalls.some((toolCall) => { + if (toolCall.status !== "completed") { + return false; + } + const normalizedName = toolCall.name.trim().toLowerCase(); + return ( + normalizedName.includes("write_file") || + normalizedName.includes("create_file") + ); + }); + + steps.push({ + id: `${skillName}:primary`, + title: primaryTaskTitle, + status: hasCompletedPrimaryWrite + ? ("completed" as StepStatus) + : hasFailedTool + ? ("error" as StepStatus) + : toolCalls.length > 0 + ? ("completed" as StepStatus) + : assistantMessage.isThinking || isSending + ? ("active" as StepStatus) + : ("pending" as StepStatus), + }); + + toolCalls.forEach((toolCall, index) => { + steps.push({ + id: toolCall.id || `${skillName}:tool:${index}`, + title: resolveThemeWorkbenchToolTaskTitle(toolCall), + status: + toolCall.status === "running" + ? ("active" as StepStatus) + : toolCall.status === "completed" + ? ("completed" as StepStatus) + : ("error" as StepStatus), + }); + }); + + if (isSending && toolCalls.length > 0 && !hasRunningTool) { + steps.push({ + id: `${skillName}:finalize`, + title: "整理最终结果", + status: "active", + }); + } + + return steps; +} + +function resolveThemeWorkbenchQueueItemTitle( + item: ThemeWorkbenchRunTodoItem, + skillDetailMap: Record, +): string { + const sourceRef = resolveThemeWorkbenchSkillSourceRef(item); + if (sourceRef) { + return resolveThemeWorkbenchPrimaryTaskTitle( + sourceRef, + skillDetailMap[sourceRef], + ); + } + return item.title?.trim() || "执行任务"; +} +const THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS = 45 * 1000; +const THEME_WORKBENCH_HISTORY_PAGE_SIZE = 20; + +function resolveThemeWorkbenchSkillSourceRef( + item: + | ThemeWorkbenchRunTodoItem + | ThemeWorkbenchRunTerminalItem + | { source?: string | null; source_ref?: string | null }, +): string | null { + if ((item.source || "").trim() !== "skill") { + return null; + } + const sourceRef = item.source_ref?.trim(); + return sourceRef || null; +} + +interface PersistedThemeWorkbenchDocument { + versions: DocumentVersion[]; + currentVersionId: string; + versionStatusMap: Record; +} + +function isTopicBranchStatus(value: unknown): value is TopicBranchStatus { + return ( + value === "in_progress" || + value === "pending" || + value === "merged" || + value === "candidate" + ); +} + +function normalizeDocumentVersion(value: unknown): DocumentVersion | null { + if (!value || typeof value !== "object") { + return null; + } + const candidate = value as Record; + const id = typeof candidate.id === "string" ? candidate.id.trim() : ""; + const content = + typeof candidate.content === "string" ? candidate.content : ""; + const createdAt = + typeof candidate.createdAt === "number" + ? candidate.createdAt + : typeof candidate.created_at === "number" + ? candidate.created_at + : NaN; + const description = + typeof candidate.description === "string" + ? candidate.description + : undefined; + const metadata = + candidate.metadata && typeof candidate.metadata === "object" + ? (candidate.metadata as DocumentVersion["metadata"]) + : undefined; + + if (!id || Number.isNaN(createdAt)) { + return null; + } + + return { + id, + content, + createdAt, + description, + metadata, + }; +} + +function buildPersistedThemeWorkbenchDocument( + state: CanvasStateUnion, + statusMap: Record, +): PersistedThemeWorkbenchDocument | null { + if (state.type !== "document" || state.versions.length === 0) { + return null; + } + + const normalizedVersions = state.versions + .map((version) => normalizeDocumentVersion(version)) + .filter((version): version is DocumentVersion => !!version); + + if (normalizedVersions.length === 0) { + return null; + } + + const latestVersions = normalizedVersions.slice( + -MAX_PERSISTED_DOCUMENT_VERSIONS, + ); + const versionIdSet = new Set(latestVersions.map((version) => version.id)); + let currentVersionId = state.currentVersionId; + + if (!versionIdSet.has(currentVersionId)) { + currentVersionId = + latestVersions[latestVersions.length - 1]?.id || latestVersions[0].id; + } + + const persistedVersions = latestVersions.map((version) => + version.id === currentVersionId ? { ...version, content: "" } : version, + ); + + const versionStatusMap = Object.fromEntries( + Object.entries(statusMap).filter( + ([versionId, status]) => + versionIdSet.has(versionId) && isTopicBranchStatus(status), + ), + ) as Record; + + return { + versions: persistedVersions, + currentVersionId, + versionStatusMap, + }; +} + +function readPersistedThemeWorkbenchDocument( + metadata?: Record, +): PersistedThemeWorkbenchDocument | null { + const raw = metadata?.[THEME_WORKBENCH_DOCUMENT_META_KEY]; + if (!raw || typeof raw !== "object") { + return null; + } + const candidate = raw as Record; + const versionsRaw = Array.isArray(candidate.versions) + ? candidate.versions + : []; + const versions = versionsRaw + .map((version) => normalizeDocumentVersion(version)) + .filter((version): version is DocumentVersion => !!version) + .slice(-MAX_PERSISTED_DOCUMENT_VERSIONS); + if (versions.length === 0) { + return null; + } + + const versionIdSet = new Set(versions.map((version) => version.id)); + const currentVersionIdRaw = candidate.currentVersionId; + const currentVersionId = + typeof currentVersionIdRaw === "string" && + versionIdSet.has(currentVersionIdRaw) + ? currentVersionIdRaw + : versions[versions.length - 1]?.id || versions[0].id; + + const statusRaw = candidate.versionStatusMap; + const statusEntries = + statusRaw && typeof statusRaw === "object" ? statusRaw : {}; + const versionStatusMap = Object.fromEntries( + Object.entries(statusEntries).filter( + ([versionId, status]) => + versionIdSet.has(versionId) && isTopicBranchStatus(status), + ), + ) as Record; + + return { + versions, + currentVersionId, + versionStatusMap, + }; +} + +function applyBackendThemeWorkbenchDocumentState( + state: CanvasStateUnion, + backendState: ThemeWorkbenchDocumentState, + currentBody: string, +): { + state: CanvasStateUnion; + statusMap: Record; +} | null { + if (state.type !== "document" || backendState.versions.length === 0) { + return null; + } + + const versions = backendState.versions + .map((version, index) => ({ + id: version.id, + content: version.is_current ? currentBody : "", + createdAt: version.created_at, + description: version.description?.trim() || `版本 ${index + 1}`, + })) + .slice(-MAX_PERSISTED_DOCUMENT_VERSIONS); + + if (versions.length === 0) { + return null; + } + + const currentVersion = + versions.find( + (version) => version.id === backendState.current_version_id, + ) || versions[versions.length - 1]; + + const statusMap = Object.fromEntries( + backendState.versions + .filter( + ( + version, + ): version is ThemeWorkbenchDocumentState["versions"][number] & { + status: TopicBranchStatus; + } => isTopicBranchStatus(version.status), + ) + .map((version) => [version.id, version.status]), + ) as Record; + + return { + state: { + ...state, + versions, + currentVersionId: currentVersion.id, + content: currentVersion.content, + }, + statusMap, + }; +} + +function inferThemeWorkbenchGateFromQueueItem( + queueItem: ThemeWorkbenchRunTodoItem | null, +): { + key: "topic_select" | "write_mode" | "publish_confirm"; + title: string; + description: string; +} { + const gateKey = queueItem?.gate_key; + if (gateKey === "publish_confirm") { + return { + key: "publish_confirm", + title: "发布闸门", + description: queueItem?.title || "正在准备发布前检查与平台适配结果。", + }; + } + if (gateKey === "topic_select") { + return { + key: "topic_select", + title: "选题闸门", + description: queueItem?.title || "正在整理选题方向并生成可确认方案。", + }; + } + if (gateKey === "write_mode") { + return { + key: "write_mode", + title: "写作闸门", + description: queueItem?.title || "正在执行主稿写作与插图生成流程。", + }; + } + + if (!queueItem) { + return { + key: "topic_select", + title: "选题闸门", + description: "正在整理选题方向并生成可确认方案。", + }; + } + + const probe = + `${queueItem.title} ${queueItem.source_ref || ""} ${queueItem.source}`.toLowerCase(); + const looksLikePublish = + /publish|adapt|distribution|release|发布|分发|平台适配/.test(probe); + if (looksLikePublish) { + return { + key: "publish_confirm", + title: "发布闸门", + description: queueItem.title || "正在准备发布前检查与平台适配结果。", + }; + } + + const looksLikeTopic = /topic|research|trend|idea|选题|方向|调研|洞察/.test( + probe, + ); + if (looksLikeTopic) { + return { + key: "topic_select", + title: "选题闸门", + description: queueItem.title || "正在整理选题方向并生成可确认方案。", + }; + } + + return { + key: "write_mode", + title: "写作闸门", + description: queueItem.title || "正在执行主稿写作与插图生成流程。", + }; +} + +function resolveThemeWorkbenchGateByKey( + gateKey: "topic_select" | "write_mode" | "publish_confirm", + fallbackTitle?: string, +): { + key: "topic_select" | "write_mode" | "publish_confirm"; + title: string; + description: string; +} { + if (gateKey === "publish_confirm") { + return { + key: "publish_confirm", + title: "发布闸门", + description: fallbackTitle || "正在准备发布前检查与平台适配结果。", + }; + } + if (gateKey === "topic_select") { + return { + key: "topic_select", + title: "选题闸门", + description: fallbackTitle || "正在整理选题方向并生成可确认方案。", + }; + } + return { + key: "write_mode", + title: "写作闸门", + description: fallbackTitle || "正在执行主稿写作与插图生成流程。", + }; +} + +function formatThemeWorkbenchRunTimeLabel( + raw: string | null | undefined, +): string { + if (!raw) { + return "--:--"; + } + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) { + return "--:--"; + } + return parsed.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function formatThemeWorkbenchRunDurationLabel( + startedAt: string | null | undefined, + finishedAt: string | null | undefined, +): string | undefined { + if (!startedAt || !finishedAt) { + return undefined; + } + + const started = new Date(startedAt); + const finished = new Date(finishedAt); + if (Number.isNaN(started.getTime()) || Number.isNaN(finished.getTime())) { + return undefined; + } + + const durationMs = finished.getTime() - started.getTime(); + if (durationMs < 0) { + return undefined; + } + if (durationMs < 1000) { + return `${durationMs}ms`; + } + if (durationMs < 60000) { + return `${(durationMs / 1000).toFixed(1)}s`; + } + return `${Math.floor(durationMs / 60000)}m${Math.round( + (durationMs % 60000) / 1000, + )}s`; +} + +function resolveThemeWorkbenchApplyTargetByGateKey( + gateKey: "topic_select" | "write_mode" | "publish_confirm" | "idle", +): string { + if (gateKey === "topic_select") { + return "选题池"; + } + if (gateKey === "publish_confirm") { + return "发布产物"; + } + if (gateKey === "write_mode") { + return "版本主稿"; + } + return "主稿内容"; +} + +function extractExecutionIdFromSocialToolId(toolCallId: string): string | null { + const normalized = toolCallId.trim(); + if (!normalized.startsWith("social-write-")) { + return null; + } + const match = normalized.match(/^social-write-(.+)-[0-9a-f]{8}$/i); + const executionId = match?.[1]?.trim(); + if (!executionId) { + return null; + } + return executionId; +} + +function resolveExecutionIdCandidatesForActivityLog( + log: SidebarActivityLog, +): string[] { + const candidates: string[] = []; + const pushCandidate = (value?: string | null) => { + const normalized = value?.trim(); + if (!normalized) { + return; + } + if (!candidates.includes(normalized)) { + candidates.push(normalized); + } + }; + + pushCandidate(log.executionId); + pushCandidate(log.messageId); + + const normalizedLogId = log.id.trim(); + if (normalizedLogId) { + let toolCallIdProbe = normalizedLogId; + if (log.messageId) { + const messagePrefix = `${log.messageId}-`; + if (normalizedLogId.startsWith(messagePrefix)) { + toolCallIdProbe = normalizedLogId.slice(messagePrefix.length); + } + } + pushCandidate(extractExecutionIdFromSocialToolId(toolCallIdProbe)); + } + + return candidates; +} + +function isThemeWorkbenchPrimaryDocumentArtifact(fileName: string): boolean { + const normalized = fileName.trim().toLowerCase(); + if (!normalized) { + return false; + } + return normalized.endsWith(".md") || normalized.endsWith(".markdown"); +} + +function inferTaskFileType(fileName: string): TaskFile["type"] { + const normalized = fileName.trim().toLowerCase(); + const extension = normalized.split(".").pop() || ""; + + if (extension === "md" || extension === "markdown" || extension === "txt") { + return "document"; + } + if ( + ["png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "ico"].includes( + extension, + ) + ) { + return "image"; + } + if ( + ["mp3", "wav", "aac", "flac", "m4a", "ogg", "mid", "midi"].includes( + extension, + ) + ) { + return "audio"; + } + if (["mp4", "mov", "avi", "mkv", "webm"].includes(extension)) { + return "video"; + } + return "other"; +} + +function looksLikeSocialPublishPayload(content: string): boolean { + const trimmed = content.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) { + return false; + } + + try { + const parsed = JSON.parse(trimmed) as Record; + return ( + typeof parsed.article_path === "string" || + typeof parsed.cover_meta_path === "string" || + Array.isArray(parsed.pipeline) || + Array.isArray(parsed.recommended_channels) + ); + } catch { + return false; + } +} + +function looksLikeThemeWorkbenchErrorPayload(content: string): boolean { + const normalized = content.trim().toLowerCase(); + if (!normalized) { + return false; + } + + return ( + normalized.startsWith("ran into this error:") || + normalized.startsWith("request failed:") || + normalized.includes( + "please retry if you think this is a transient or recoverable error.", + ) || + normalized.includes("api key not valid") + ); +} + +function isCorruptedThemeWorkbenchDocumentContent( + content?: string | null, +): boolean { + if (typeof content !== "string") { + return false; + } + + return ( + looksLikeSocialPublishPayload(content) || + looksLikeThemeWorkbenchErrorPayload(content) + ); +} + +function resolveTaskFileType( + fileName: string, + content?: string | null, +): TaskFile["type"] { + const inferredType = inferTaskFileType(fileName); + if ( + inferredType === "document" && + isCorruptedThemeWorkbenchDocumentContent(content) + ) { + return "other"; + } + return inferredType; +} + +function normalizeSessionTaskFileType( + fileType: string, + fileName: string, + content?: string | null, +): TaskFile["type"] { + const normalized = fileType.trim().toLowerCase(); + if ( + normalized === "document" || + normalized === "image" || + normalized === "audio" || + normalized === "video" || + normalized === "other" + ) { + const resolvedByContent = resolveTaskFileType(fileName, content); + if (normalized === "document" && resolvedByContent !== "document") { + return resolvedByContent; + } + return normalized; + } + return resolveTaskFileType(fileName, content); +} + +function isRenderableTaskFile( + file: Pick, + isThemeWorkbench: boolean, +): boolean { + if (file.type !== "document") { + return false; + } + if (!isThemeWorkbench) { + return true; + } + return isThemeWorkbenchPrimaryDocumentArtifact(file.name); +} + +function buildThemeWorkbenchWorkflowSteps( + messages: Message[], + backendRunState: BackendThemeWorkbenchRunState | null, + isSending: boolean, + skillDetailMap: Record, +): Array<{ id: string; title: string; status: StepStatus }> { + const liveSteps = buildThemeWorkbenchLiveWorkflowSteps( + messages, + skillDetailMap, + isSending, + ); + if (liveSteps.length > 0) { + return liveSteps; + } + + const queueItems = backendRunState?.queue_items || []; + if (queueItems.length > 0) { + if (queueItems.length === 1) { + const item = queueItems[0]; + const sourceRef = resolveThemeWorkbenchSkillSourceRef(item); + const workflowSteps = sourceRef + ? skillDetailMap[sourceRef]?.workflow_steps || [] + : []; + if (workflowSteps.length > 0) { + const latestAssistantContent = + messages + .slice() + .reverse() + .find((m) => m.role === "assistant")?.content || ""; + const activeIndex = + extractThemeWorkbenchWorkflowMarkerIndex(latestAssistantContent) ?? 0; + return workflowSteps.map((step, index) => ({ + id: `${item.run_id}-${step.id}`, + title: step.name, + status: + index < activeIndex + ? ("completed" as StepStatus) + : index === activeIndex + ? ("active" as StepStatus) + : ("pending" as StepStatus), + })); + } + } + return queueItems.map((item) => ({ + id: item.run_id, + title: resolveThemeWorkbenchQueueItemTitle(item, skillDetailMap), + status: resolveThemeWorkbenchRunStepStatus(item.status), + })); + } + + const latestTerminal = backendRunState?.latest_terminal; + if (latestTerminal && backendRunState?.run_state !== "auto_running") { + return [ + { + id: latestTerminal.run_id, + title: resolveThemeWorkbenchQueueItemTitle( + latestTerminal, + skillDetailMap, + ), + status: resolveThemeWorkbenchRunStepStatus(latestTerminal.status), + }, + ]; + } + + return []; +} + +function loadPersistedProjectId(key: string): string | null { + try { + const stored = localStorage.getItem(key); + if (!stored) { + return null; + } + + try { + const parsed = JSON.parse(stored); + return normalizeProjectId(typeof parsed === "string" ? parsed : stored); + } catch { + return normalizeProjectId(stored); + } + } catch { + return null; + } +} + +function savePersistedProjectId(key: string, projectId: string) { + const normalized = normalizeProjectId(projectId); + if (!normalized) { + return; + } + + try { + localStorage.setItem(key, JSON.stringify(normalized)); + } catch { + // ignore write errors + } +} + +function loadPersistedBoolean(key: string, fallback = false): boolean { + try { + const stored = localStorage.getItem(key); + if (stored == null) { + return fallback; + } + + try { + const parsed = JSON.parse(stored); + return typeof parsed === "boolean" ? parsed : fallback; + } catch { + return stored === "true"; + } + } catch { + return fallback; + } +} + +function savePersistedBoolean(key: string, value: boolean) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // ignore write errors + } +} + +function buildInitialDispatchKey( + prompt?: string, + images?: MessageImage[], +): string | null { + const normalizedPrompt = (prompt || "").trim(); + const normalizedImages = images || []; + + if (!normalizedPrompt && normalizedImages.length === 0) { + return null; + } + + const imageSignature = normalizedImages + .map( + (image, index) => + `${index}:${image.mediaType}:${image.data.length}:${image.data.slice(0, 16)}`, + ) + .join("|"); + + return `${normalizedPrompt}::${imageSignature}`; +} + +export interface WorkflowProgressSnapshot { + steps: Array<{ + id: string; + title: string; + status: StepStatus; + }>; + currentIndex: number; +} + +export interface AgentChatWorkspaceProps { + onNavigate?: (page: Page, params?: PageParams) => void; + projectId?: string; + contentId?: string; + agentEntry?: "new-task" | "claw"; + immersiveHome?: boolean; + theme?: string; + initialCreationMode?: CreationMode; + lockTheme?: boolean; + fromResources?: boolean; + hideHistoryToggle?: boolean; + showChatPanel?: boolean; + hideTopBar?: boolean; + topBarChrome?: "full" | "workspace-compact"; + onBackToProjectManagement?: () => void; + hideInlineStepProgress?: boolean; + onWorkflowProgressChange?: ( + snapshot: WorkflowProgressSnapshot | null, + ) => void; + initialUserPrompt?: string; + initialUserImages?: MessageImage[]; + initialSessionName?: string; + entryBannerMessage?: string; + onInitialUserPromptConsumed?: () => void; + newChatAt?: number; + onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; + onHasMessagesChange?: (hasMessages: boolean) => void; + onSessionChange?: (sessionId: string | null) => void; + preferContentReviewInRightRail?: boolean; + openBrowserAssistOnMount?: boolean; +} + +/** + * 判断画布状态是否为空 + * 用于决定是否自动触发 AI 引导 + */ +const HARNESS_PANEL_VISIBILITY_KEY = "lime.chat.harness-panel.visible.v1"; + +function isCanvasStateEmpty(state: CanvasStateUnion | null): boolean { + if (!state) return true; + + switch (state.type) { + case "document": + // 文档画布:检查 content 是否为空 + return !state.content || state.content.trim() === ""; + case "novel": + // 小说画布:检查第一章内容是否为空 + return ( + state.chapters.length === 0 || + !state.chapters[0].content || + state.chapters[0].content.trim() === "" + ); + case "script": + // 剧本画布:检查场景是否有实际内容 + return ( + state.scenes.length === 0 || + (state.scenes.length === 1 && + state.scenes[0].dialogues.length === 0 && + !state.scenes[0].description) + ); + case "music": + // 音乐画布:检查 sections 是否为空 + return !state.sections || state.sections.length === 0; + case "poster": + // 海报画布:检查页面中是否有图层 + return ( + state.pages.length === 0 || + (state.pages.length === 1 && state.pages[0].layers.length === 0) + ); + default: + return true; + } +} + +function serializeCanvasStateForSync(state: CanvasStateUnion): string { + switch (state.type) { + case "document": + return state.content || ""; + case "novel": + return JSON.stringify(state.chapters); + case "script": + return JSON.stringify(state.scenes); + case "music": + return JSON.stringify(state.sections); + case "poster": + return JSON.stringify(state.pages); + default: + return JSON.stringify(state); + } +} + +function isSyncContentEmpty(content: string): boolean { + return !content || content === "[]" || content === "{}"; +} + +function resolveThemeWorkbenchRecentTerminals( + state: BackendThemeWorkbenchRunState | null, +): ThemeWorkbenchRunTerminalItem[] { + if (!state) { + return []; + } + + const rawTerminals = + Array.isArray(state.recent_terminals) && state.recent_terminals.length > 0 + ? state.recent_terminals + : state.latest_terminal + ? [state.latest_terminal] + : []; + + const seenRunIds = new Set(); + return rawTerminals.filter((item) => { + const runId = item.run_id?.trim(); + if (!runId || seenRunIds.has(runId)) { + return false; + } + seenRunIds.add(runId); + return true; + }); +} + +function mergeThemeWorkbenchTerminalItems( + ...groups: ThemeWorkbenchRunTerminalItem[][] +): ThemeWorkbenchRunTerminalItem[] { + const merged: ThemeWorkbenchRunTerminalItem[] = []; + const seenRunIds = new Set(); + + groups.forEach((items) => { + items.forEach((item) => { + const runId = item.run_id?.trim(); + if (!runId || seenRunIds.has(runId)) { + return; + } + seenRunIds.add(runId); + merged.push(item); + }); + }); + + return merged; +} + +function buildThemeWorkbenchRunStateSignature( + state: BackendThemeWorkbenchRunState | null, +): string { + if (!state) { + return "null"; + } + + const queueSignature = (state.queue_items || []) + .map((item) => + [ + item.run_id, + item.execution_id || "", + item.status, + item.gate_key || "", + item.source || "", + item.source_ref || "", + ].join(":"), + ) + .join("|"); + + const terminalSignature = resolveThemeWorkbenchRecentTerminals(state) + .map((item) => + [ + item.run_id, + item.execution_id || "", + item.status, + item.gate_key || "", + item.source || "", + item.source_ref || "", + ].join(":"), + ) + .join("|"); + + return [ + state.run_state, + state.current_gate_key || "", + queueSignature, + terminalSignature, + ].join("||"); +} + +export function AgentChatWorkspace({ + onNavigate: _onNavigate, + projectId: externalProjectId, + contentId, + agentEntry = "claw", + theme: initialTheme, + initialCreationMode, + lockTheme = false, + fromResources = false, + hideHistoryToggle = false, + showChatPanel = true, + hideTopBar = false, + topBarChrome = "full", + onBackToProjectManagement, + hideInlineStepProgress = false, + onWorkflowProgressChange, + initialUserPrompt, + initialUserImages, + initialSessionName, + entryBannerMessage, + onInitialUserPromptConsumed, + newChatAt, + onRecommendationClick: _onRecommendationClick, + onHasMessagesChange, + onSessionChange, + preferContentReviewInRightRail = false, + openBrowserAssistOnMount = false, +}: AgentChatWorkspaceProps) { + const normalizedEntryTheme = normalizeInitialTheme(initialTheme); + const shouldAutoCollapseClassicClawSidebar = + agentEntry === "claw" && !lockTheme && normalizedEntryTheme === "general"; + const defaultTopicSidebarVisible = + showChatPanel && !shouldAutoCollapseClassicClawSidebar; + const [showSidebar, setShowSidebar] = useState( + () => defaultTopicSidebarVisible, + ); + const [themeWorkbenchSidebarCollapsed, setThemeWorkbenchSidebarCollapsed] = + useState(false); + const [input, setInput] = useState(""); + const [selectedText, setSelectedText] = useState(""); + const [entryBannerVisible, setEntryBannerVisible] = useState( + Boolean(entryBannerMessage), + ); + const [chatToolPreferences, setChatToolPreferences] = + useState(() => + loadChatToolPreferences(normalizedEntryTheme), + ); + const [chatToolPreferencesTheme, setChatToolPreferencesTheme] = + useState(normalizedEntryTheme); + const shouldBootstrapCanvasOnEntry = + Boolean(contentId) && isContentCreationTheme(normalizedEntryTheme); + const initialDispatchKey = useMemo( + () => buildInitialDispatchKey(initialUserPrompt, initialUserImages), + [initialUserImages, initialUserPrompt], + ); + const [bootstrapDispatchSnapshot, setBootstrapDispatchSnapshot] = + useState(null); + + // 内容创作相关状态 + const [activeTheme, setActiveTheme] = useState(normalizedEntryTheme); + const [selectedTeam, setSelectedTeam] = useState(() => + resolvePersistedSelectedTeam(normalizedEntryTheme), + ); + const [creationMode, setCreationMode] = useState( + initialCreationMode ?? "guided", + ); + const [layoutMode, setLayoutMode] = useState( + shouldBootstrapCanvasOnEntry ? "canvas" : "chat", + ); + const [isInitialContentLoading, setIsInitialContentLoading] = useState( + shouldBootstrapCanvasOnEntry, + ); + const [initialContentLoadError, setInitialContentLoadError] = useState< + string | null + >(null); + + useEffect(() => { + if (!initialTheme) return; + setActiveTheme(normalizeInitialTheme(initialTheme)); + }, [initialTheme]); + + useEffect(() => { + if (!initialCreationMode) return; + setCreationMode(initialCreationMode); + }, [initialCreationMode]); + + useEffect(() => { + setEntryBannerVisible(Boolean(entryBannerMessage)); + }, [entryBannerMessage]); + + useEffect(() => { + if (chatToolPreferencesTheme === activeTheme) { + return; + } + + setChatToolPreferences(loadChatToolPreferences(activeTheme)); + setChatToolPreferencesTheme(activeTheme); + }, [activeTheme, chatToolPreferencesTheme]); + + useEffect(() => { + if (chatToolPreferencesTheme !== activeTheme) { + return; + } + + saveChatToolPreferences(chatToolPreferences, activeTheme); + }, [activeTheme, chatToolPreferences, chatToolPreferencesTheme]); + + // 内部 projectId 状态(当外部未提供时使用) + const [internalProjectId, setInternalProjectId] = useState( + null, + ); + const handledNewChatRequestRef = useRef(null); + const openBrowserAssistOnMountHandledRef = useRef(false); + + const incomingNewChatRequestKey = + typeof newChatAt === "number" ? String(newChatAt) : null; + const shouldDisableSessionRestore = incomingNewChatRequestKey !== null; + const shouldResetToFreshHomeContext = + !externalProjectId && + incomingNewChatRequestKey !== null && + handledNewChatRequestRef.current !== incomingNewChatRequestKey; + + // 使用外部或内部的 projectId + const projectId = + externalProjectId ?? + (shouldResetToFreshHomeContext ? undefined : internalProjectId) ?? + undefined; + const pageMountedAtRef = useRef(Date.now()); + + useEffect(() => { + const mountedAt = pageMountedAtRef.current; + logAgentDebug("AgentChatPage", "mount", { + agentEntry, + contentId: contentId ?? null, + externalProjectId: externalProjectId ?? null, + initialCreationMode: initialCreationMode ?? null, + initialTheme: initialTheme ?? null, + lockTheme, + }); + + return () => { + logAgentDebug( + "AgentChatPage", + "unmount", + { + contentId: contentId ?? null, + externalProjectId: externalProjectId ?? null, + lifetimeMs: Date.now() - mountedAt, + }, + { consoleOnly: true }, + ); + }; + }, [ + agentEntry, + contentId, + externalProjectId, + initialCreationMode, + initialTheme, + lockTheme, + ]); + + // 画布状态(支持多种画布类型) + const [canvasState, setCanvasState] = useState( + () => { + if (!shouldBootstrapCanvasOnEntry) { + return null; + } + + return ( + createInitialCanvasState(normalizedEntryTheme, "") || + createInitialDocumentState("") + ); + }, + ); + const [documentVersionStatusMap, setDocumentVersionStatusMap] = useState< + Record + >({}); + const contentMetadataRef = useRef>({}); + const persistedWorkbenchSnapshotRef = useRef(""); + const lastCanvasSyncRequestRef = useRef<{ + contentId: string; + body: string; + } | null>(null); + const themeWorkbenchRunStateSignatureRef = useRef(""); + const [novelChapterListCollapsed, setNovelChapterListCollapsed] = + useState(false); + const [themeWorkbenchBackendRunState, setThemeWorkbenchBackendRunState] = + useState(null); + const [themeWorkbenchHistoryTerminals, setThemeWorkbenchHistoryTerminals] = + useState([]); + const [themeWorkbenchHistoryHasMore, setThemeWorkbenchHistoryHasMore] = + useState(false); + const [themeWorkbenchHistoryNextOffset, setThemeWorkbenchHistoryNextOffset] = + useState(null); + const [themeWorkbenchHistoryLoading, setThemeWorkbenchHistoryLoading] = + useState(false); + const [themeWorkbenchSkillDetailMap, setThemeWorkbenchSkillDetailMap] = + useState>({}); + const [selectedThemeWorkbenchRunId, setSelectedThemeWorkbenchRunId] = + useState(null); + const themeWorkbenchHistoryLoadingRef = useRef(false); + const [selectedThemeWorkbenchRunDetail, setSelectedThemeWorkbenchRunDetail] = + useState(null); + const [themeWorkbenchRunDetailLoading, setThemeWorkbenchRunDetailLoading] = + useState(false); + const [ + themeWorkbenchCreationTaskEvents, + setThemeWorkbenchCreationTaskEvents, + ] = useState([]); + const documentEditorFocusedRef = useRef(false); + + useEffect(() => { + setActiveContentTarget(projectId, contentId, canvasState?.type ?? null); + }, [canvasState?.type, contentId, projectId]); + + useEffect(() => { + persistedWorkbenchSnapshotRef.current = ""; + contentMetadataRef.current = {}; + lastCanvasSyncRequestRef.current = null; + if (!contentId) { + setDocumentVersionStatusMap({}); + } + }, [contentId]); + + // General 主题专用画布状态 + const [generalCanvasState, setGeneralCanvasState] = + useState(DEFAULT_CANVAS_STATE); + + // 任务文件状态 + const [taskFiles, setTaskFiles] = useState([]); + const [taskFilesExpanded, setTaskFilesExpanded] = useState(false); + const [selectedFileId, setSelectedFileId] = useState(); + const taskFilesRef = useRef([]); + const socialStageLogRef = useRef>({}); + const generalResourceHashesRef = useRef>>(new Map()); + const generalResourceSyncInFlightRef = useRef>(new Set()); + + // 项目上下文状态 + const [project, setProject] = useState(null); + const [projectMemory, setProjectMemory] = useState( + null, + ); + const [runtimeStyleSelection, setRuntimeStyleSelection] = + useState({ + presetId: "project-default", + strength: DEFAULT_STYLE_PROFILE.simulationStrength, + customNotes: "", + source: "project-default", + sourceLabel: undefined, + sourceProfile: null, + }); + + useEffect(() => { + taskFilesRef.current = taskFiles; + }, [taskFiles]); + + useEffect(() => { + setRuntimeStyleSelection((previous) => { + if ( + previous.presetId !== "project-default" || + previous.customNotes.trim() + ) { + return previous; + } + + const nextStrength = + getStyleProfileFromGuide(projectMemory?.style_guide) + ?.simulationStrength || DEFAULT_STYLE_PROFILE.simulationStrength; + + return previous.strength === nextStrength + ? previous + : { + ...previous, + strength: nextStrength, + }; + }); + }, [projectMemory?.style_guide]); + + // 主动 workspace 健康检查失败标记(区别于 workspacePathMissing 发送失败场景) + const [workspaceHealthError, setWorkspaceHealthError] = useState(false); + + // 引用的角色列表(用于注入到消息中) + const [mentionedCharacters, setMentionedCharacters] = useState( + [], + ); + + // 技能列表(用于 @ 引用) + const [skills, setSkills] = useState([]); + const [skillsLoading, setSkillsLoading] = useState(false); + + // Workbench Store(用于主题工作台右侧面板状态同步) + const pendingSkillKey = useWorkbenchStore((state) => state.pendingSkillKey); + const clearThemeSkillsRailState = useWorkbenchStore( + (state) => state.clearThemeSkillsRailState, + ); + const consumePendingSkill = useWorkbenchStore( + (state) => state.consumePendingSkill, + ); + + // 用于追踪已处理的消息 ID,避免重复处理 + const processedMessageIds = useRef>(new Set()); + const pendingTopicSwitchRef = useRef<{ + topicId: string; + targetProjectId: string; + } | null>(null); + const isResolvingTopicProjectRef = useRef(false); + + // 文件写入回调 ref(用于传递给统一聊天主链 Hook) + const handleWriteFileRef = + useRef< + ( + content: string, + fileName: string, + context?: WriteArtifactContext, + ) => void + >(); + + // 工作流状态(仅在内容创作模式下使用) + const mappedTheme = activeTheme as ThemeType; + const preferredTeamPresetId = useMemo( + () => + selectedTeam?.presetId?.trim() || + (selectedTeam?.source === "builtin" ? selectedTeam.id : undefined), + [selectedTeam], + ); + const selectedTeamLabel = useMemo( + () => buildTeamDefinitionLabel(selectedTeam) || undefined, + [selectedTeam], + ); + const selectedTeamSummary = useMemo( + () => buildTeamDefinitionSummary(selectedTeam) || undefined, + [selectedTeam], + ); + + useEffect(() => { + setSelectedTeam(resolvePersistedSelectedTeam(activeTheme)); + }, [activeTheme]); + + useEffect(() => { + persistSelectedTeam(selectedTeam, activeTheme); + }, [activeTheme, selectedTeam]); + + const handleSelectTeam = useCallback( + (team: TeamDefinition | null) => { + persistSelectedTeam(team, activeTheme); + setSelectedTeam(team); + }, + [activeTheme], + ); + + const handleEnableSuggestedTeam = useCallback( + (suggestedPresetId?: string) => { + const resolvedPresetId = suggestedPresetId?.trim(); + if (!resolvedPresetId) { + return; + } + + const suggestedTeam = createTeamDefinitionFromPreset(resolvedPresetId); + if (suggestedTeam) { + persistSelectedTeam(suggestedTeam, activeTheme); + setSelectedTeam(suggestedTeam); + } + }, + [activeTheme], + ); + + useEffect(() => { + setRuntimeStyleSelection({ + presetId: "project-default", + strength: DEFAULT_STYLE_PROFILE.simulationStrength, + customNotes: "", + }); + }, [mappedTheme, projectId]); + const { steps, currentStepIndex, goToStep, completeStep } = useWorkflow( + mappedTheme, + creationMode, + ); + + // 内容同步 Hook + const { syncContent, syncStatus } = useContentSync({ + debounceMs: 2000, + autoRetry: true, + retryDelayMs: 5000, + }); + + // 判断是否为内容创作模式 + const isContentCreationMode = isContentCreationTheme(activeTheme); + + // Artifact 状态 - 用于在画布中显示 + const artifacts = useAtomValue(artifactsAtom); + const selectedArtifact = useAtomValue(selectedArtifactAtom); + const setArtifacts = useSetAtom(artifactsAtom); + const setSelectedArtifactId = useSetAtom(selectedArtifactIdAtom); + const liveArtifact = useMemo( + () => + selectedArtifact || + (artifacts.length > 0 ? artifacts[artifacts.length - 1] : null), + [artifacts, selectedArtifact], + ); + + // Artifact 预览状态 + const [artifactViewMode, setArtifactViewMode] = useState< + "source" | "preview" + >("source"); + const [artifactPreviewSize, setArtifactPreviewSize] = useState< + "mobile" | "tablet" | "desktop" + >("desktop"); + const [canvasWorkbenchLayoutMode, setCanvasWorkbenchLayoutMode] = + useState("split"); + const [browserAssistLaunching, setBrowserAssistLaunching] = useState(false); + const [browserAssistSessionState, setBrowserAssistSessionState] = + useState(null); + const [browserTaskPreflight, setBrowserTaskPreflight] = + useState(null); + const autoOpenedBrowserAssistSessionIdRef = useRef(""); + const autoLaunchingBrowserAssistKeyRef = useRef(""); + const browserAssistLaunchRequestIdRef = useRef(0); + const browserTaskPreflightLaunchIdRef = useRef(""); + const autoCollapsedTopicSidebarRef = useRef(false); + + // 当有新的 artifact 时,自动打开画布 + useEffect(() => { + if (activeTheme !== "general") return; + if (artifacts.length === 0) return; + const hasNonBrowserAssistArtifact = artifacts.some( + (artifact) => artifact.type !== "browser_assist", + ); + const hasBoundBrowserAssistSession = Boolean( + browserAssistSessionState?.sessionId || + browserAssistSessionState?.profileKey, + ); + if (!hasNonBrowserAssistArtifact && !hasBoundBrowserAssistSession) { + return; + } + + // 自动打开画布显示 artifact + setLayoutMode("chat-canvas"); + }, [ + activeTheme, + artifacts, + browserAssistSessionState?.profileKey, + browserAssistSessionState?.sessionId, + ]); + + const isBrowserAssistReady = useMemo( + () => hasActiveBrowserAssistSession(browserAssistSessionState), + [browserAssistSessionState], + ); + const browserAssistEntryLabel = useMemo(() => { + if (browserTaskPreflight?.phase === "launching" || browserAssistLaunching) { + return "浏览器启动中"; + } + if ( + browserTaskPreflight?.phase === "awaiting_user" || + browserTaskPreflight?.phase === "ready_to_resume" + ) { + return "等待登录"; + } + if (browserTaskPreflight?.phase === "failed") { + return "浏览器未连接"; + } + if (isBrowserAssistReady) { + return "浏览器已就绪"; + } + return "浏览器协助"; + }, [ + browserAssistLaunching, + browserTaskPreflight?.phase, + isBrowserAssistReady, + ]); + const browserAssistAttentionLevel = useMemo(() => { + if (browserTaskPreflight?.phase === "launching" || browserAssistLaunching) { + return "info" as const; + } + + if ( + browserTaskPreflight?.phase === "awaiting_user" || + browserTaskPreflight?.phase === "ready_to_resume" || + browserTaskPreflight?.phase === "failed" + ) { + return "warning" as const; + } + + return "idle" as const; + }, [browserAssistLaunching, browserTaskPreflight?.phase]); + + useEffect(() => { + if (activeTheme === "general") { + return; + } + setBrowserTaskPreflight(null); + }, [activeTheme]); + + // 跳转到设置页安装技能 + const handleNavigateToSkillSettings = useCallback(() => { + _onNavigate?.("settings", { tab: SettingsTabs.Skills }); + }, [_onNavigate]); + + const loadSkills = useCallback( + async (includeRemote = false): Promise => { + const startedAt = Date.now(); + logAgentDebug("AgentChatPage", "loadSkills.start", { + includeRemote, + }); + setSkillsLoading(true); + try { + const loadedSkills = includeRemote + ? await skillsApi.getAll("lime") + : await skillsApi.getLocal("lime"); + logAgentDebug("AgentChatPage", "loadSkills.success", { + durationMs: Date.now() - startedAt, + includeRemote, + skillsCount: loadedSkills.length, + }); + setSkills(loadedSkills); + return loadedSkills; + } catch (error) { + console.warn("[AgentChatPage] 加载 skills 失败:", error); + logAgentDebug( + "AgentChatPage", + "loadSkills.error", + { + durationMs: Date.now() - startedAt, + error, + includeRemote, + }, + { level: "warn" }, + ); + setSkills([]); + return []; + } finally { + setSkillsLoading(false); + } + }, + [], + ); + + const handleRefreshSkills = useCallback(async () => { + await loadSkills(true); + }, [loadSkills]); + + // 加载项目、Memory 和内容 + useEffect(() => { + let cancelled = false; + + const loadData = async () => { + const startedAt = Date.now(); + logAgentDebug("AgentChatPage", "loadData.start", { + contentId: contentId ?? null, + lockTheme, + projectId: projectId ?? null, + }); + + if (contentId) { + setIsInitialContentLoading(true); + setInitialContentLoadError(null); + } else { + setIsInitialContentLoading(false); + setInitialContentLoadError(null); + } + + if (!projectId) { + if (cancelled) { + return; + } + logAgentDebug("AgentChatPage", "loadData.noProject", { + contentId: contentId ?? null, + durationMs: Date.now() - startedAt, + }); + setProject(null); + setProjectMemory(null); + setIsInitialContentLoading(false); + return; + } + + try { + const p = await getProject(projectId); + if (!p) { + if (cancelled) { + return; + } + logAgentDebug( + "AgentChatPage", + "loadData.projectMissing", + { + contentId: contentId ?? null, + durationMs: Date.now() - startedAt, + projectId, + }, + { level: "warn" }, + ); + setProject(null); + setProjectMemory(null); + if (contentId) { + setInitialContentLoadError("当前项目不存在或已被删除"); + } + return; + } + + if (cancelled) { + return; + } + + setProject(p); + const theme = projectTypeToTheme(p.workspaceType); + logAgentDebug("AgentChatPage", "loadData.projectLoaded", { + durationMs: Date.now() - startedAt, + projectId: p.id, + theme, + workspaceType: p.workspaceType, + }); + if (!lockTheme || !initialTheme) { + setActiveTheme(theme); + } + + const memory = await getProjectMemory(projectId); + if (cancelled) { + return; + } + setProjectMemory(memory); + logAgentDebug("AgentChatPage", "loadData.memoryLoaded", { + charactersCount: memory?.characters?.length ?? 0, + durationMs: Date.now() - startedAt, + hasOutline: Boolean(memory?.outline?.length), + hasStyleGuide: Boolean(memory?.style_guide), + projectId, + }); + + if (!contentId) { + logAgentDebug("AgentChatPage", "loadData.projectOnlyComplete", { + durationMs: Date.now() - startedAt, + projectId, + }); + return; + } + + const content = await getContent(contentId); + if (cancelled) { + return; + } + + if (!content) { + logAgentDebug( + "AgentChatPage", + "loadData.contentMissing", + { + contentId, + durationMs: Date.now() - startedAt, + projectId, + }, + { level: "warn" }, + ); + setInitialContentLoadError("文稿不存在或读取失败"); + return; + } + + logAgentDebug("AgentChatPage", "loadData.contentLoaded", { + bodyLength: content.body?.length ?? 0, + contentId: content.id, + durationMs: Date.now() - startedAt, + projectId, + }); + + contentMetadataRef.current = content.metadata || {}; + const canvasTheme = ( + lockTheme && initialTheme + ? normalizeInitialTheme(initialTheme) + : theme + ) as ThemeType; + const rawBody = content.body || ""; + const sanitizedBody = isCorruptedThemeWorkbenchDocumentContent(rawBody) + ? "" + : rawBody; + + if (rawBody && sanitizedBody !== rawBody) { + setInitialContentLoadError( + "当前文稿未生成有效主稿,请重新生成或稍后重试", + ); + } else { + setInitialContentLoadError(null); + } + + let initialState = + createInitialCanvasState(canvasTheme, sanitizedBody) || + createInitialDocumentState(sanitizedBody); + + if (initialState.type === "document") { + const backendDocumentState = await getThemeWorkbenchDocumentState( + content.id, + ).catch((error) => { + console.warn( + "[AgentChatPage] 读取主题工作台版本状态失败,降级为 metadata 解析:", + error, + ); + logAgentDebug( + "AgentChatPage", + "loadData.documentStateError", + { + contentId: content.id, + durationMs: Date.now() - startedAt, + error, + }, + { level: "warn" }, + ); + return null; + }); + logAgentDebug("AgentChatPage", "loadData.documentStateLoaded", { + contentId: content.id, + durationMs: Date.now() - startedAt, + hasBackendDocumentState: Boolean(backendDocumentState), + }); + const backendApplied = backendDocumentState + ? applyBackendThemeWorkbenchDocumentState( + initialState, + backendDocumentState, + sanitizedBody, + ) + : null; + + if (backendApplied) { + initialState = backendApplied.state; + setDocumentVersionStatusMap(backendApplied.statusMap); + } else { + const persisted = readPersistedThemeWorkbenchDocument( + content.metadata, + ); + if (persisted) { + const restoredVersions = persisted.versions.map((version) => + version.id === persisted.currentVersionId + ? { ...version, content: sanitizedBody || version.content } + : version, + ); + const currentVersion = + restoredVersions.find( + (version) => version.id === persisted.currentVersionId, + ) || restoredVersions[restoredVersions.length - 1]; + initialState = { + ...initialState, + versions: restoredVersions, + currentVersionId: currentVersion.id, + content: currentVersion.content, + }; + setDocumentVersionStatusMap(persisted.versionStatusMap); + } else { + setDocumentVersionStatusMap({}); + } + } + } else { + setDocumentVersionStatusMap({}); + } + + lastCanvasSyncRequestRef.current = { + contentId: content.id, + body: serializeCanvasStateForSync(initialState), + }; + setCanvasState(initialState); + setLayoutMode("canvas"); + logAgentDebug("AgentChatPage", "loadData.complete", { + contentId: content.id, + durationMs: Date.now() - startedAt, + initialStateType: initialState.type, + projectId, + }); + } catch (error) { + console.error("[AgentChatPage] 加载项目或文稿失败:", error); + logAgentDebug( + "AgentChatPage", + "loadData.error", + { + contentId: contentId ?? null, + durationMs: Date.now() - startedAt, + error, + projectId: projectId ?? null, + }, + { level: "error" }, + ); + if (!cancelled && contentId) { + setInitialContentLoadError("文稿加载失败,请稍后重试"); + } + } finally { + if (!cancelled) { + setIsInitialContentLoading(false); + } + } + }; + + void loadData(); + + return () => { + cancelled = true; + }; + }, [projectId, contentId, lockTheme, initialTheme]); + + useEffect(() => { + if (!shouldBootstrapCanvasOnEntry) { + return; + } + + setLayoutMode("canvas"); + setCanvasState((previous) => { + if (previous) { + return previous; + } + + return ( + createInitialCanvasState(normalizedEntryTheme, "") || + createInitialDocumentState("") + ); + }); + }, [normalizedEntryTheme, shouldBootstrapCanvasOnEntry]); + + // 当 projectId 变化时主动检查 workspace 目录健康状态 + // 静默修复(auto-created)或显示 banner 提示用户重新选择 + useEffect(() => { + setWorkspaceHealthError(false); + const normalizedId = normalizeProjectId(projectId); + if (!normalizedId) return; + + const startedAt = Date.now(); + logAgentDebug("AgentChatPage", "workspaceCheck.start", { + projectId: normalizedId, + }); + ensureWorkspaceReady(normalizedId) + .then(({ repaired, rootPath }) => { + if (repaired) { + recordWorkspaceRepair({ + workspaceId: normalizedId, + rootPath, + source: "agent_chat_page", + }); + console.info("[AgentChatPage] workspace 目录已自动修复:", rootPath); + } + logAgentDebug("AgentChatPage", "workspaceCheck.success", { + durationMs: Date.now() - startedAt, + projectId: normalizedId, + repaired, + rootPath, + }); + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + console.warn("[AgentChatPage] workspace 目录检查失败:", message); + logAgentDebug( + "AgentChatPage", + "workspaceCheck.error", + { + durationMs: Date.now() - startedAt, + error: err, + projectId: normalizedId, + }, + { level: "warn" }, + ); + setWorkspaceHealthError(true); + }); + }, [projectId]); + + useEffect(() => { + const normalizedProjectId = normalizeProjectId(projectId); + if (!normalizedProjectId) { + return; + } + + if (project && project.id === normalizedProjectId && !project.isArchived) { + savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId); + return; + } + + getProject(normalizedProjectId) + .then((resolvedProject) => { + if (!resolvedProject || resolvedProject.isArchived) { + return; + } + savePersistedProjectId(LAST_PROJECT_ID_KEY, resolvedProject.id); + }) + .catch((error) => { + console.warn("[AgentChatPage] 记录最近项目失败:", error); + }); + }, [project, projectId]); + + const runtimeStylePrompt = useMemo( + () => + buildRuntimeStyleOverridePrompt({ + projectStyleGuide: projectMemory?.style_guide, + selection: runtimeStyleSelection, + activeTheme: mappedTheme, + }), + [mappedTheme, projectMemory?.style_guide, runtimeStyleSelection], + ); + + const runtimeStyleMessagePrompt = useMemo(() => { + const projectDefaultStrength = + getStyleProfileFromGuide(projectMemory?.style_guide) + ?.simulationStrength || DEFAULT_STYLE_PROFILE.simulationStrength; + const hasPresetOverride = + runtimeStyleSelection.presetId !== "project-default" || + runtimeStyleSelection.source === "library"; + const hasCustomNotes = runtimeStyleSelection.customNotes.trim().length > 0; + const hasStrengthOverride = + runtimeStyleSelection.strength !== projectDefaultStrength; + + return hasPresetOverride || hasCustomNotes || hasStrengthOverride + ? runtimeStylePrompt + : ""; + }, [projectMemory?.style_guide, runtimeStylePrompt, runtimeStyleSelection]); + + const chatMode = useMemo( + () => resolveAgentChatMode(mappedTheme, isContentCreationMode), + [isContentCreationMode, mappedTheme], + ); + + // 生成系统提示词(包含项目 Memory) + const systemPrompt = useMemo(() => { + let prompt = ""; + + if (chatMode === "general") { + prompt = buildGeneralAgentSystemPrompt(mappedTheme, { + toolPreferences: chatToolPreferences, + harness: { + browserAssistEnabled: true, + browserAssistProfileKey: GENERAL_BROWSER_ASSIST_PROFILE_KEY, + contentId: contentId || null, + }, + }); + } else if (isContentCreationMode) { + prompt = generateContentCreationPrompt(mappedTheme, creationMode); + } + + // 注入项目 Memory + if (projectMemory) { + const memoryPrompt = generateProjectMemoryPrompt(projectMemory); + if (memoryPrompt) { + prompt = prompt ? `${prompt}\n\n${memoryPrompt}` : memoryPrompt; + } + } + + return prompt || undefined; + }, [ + chatMode, + chatToolPreferences, + contentId, + creationMode, + isContentCreationMode, + mappedTheme, + projectMemory, + ]); + + // 使用 Agent Chat Hook(传递系统提示词) + const { + providerType, + setProviderType, + model, + setModel, + executionStrategy, + setExecutionStrategy, + messages = [], + currentTurnId, + turns = [], + threadItems = [], + todoItems = [], + childSubagentSessions = [], + subagentParentContext = null, + queuedTurns = [], + isSending, + sendMessage, + stopSending, + promoteQueuedTurn = async () => false, + removeQueuedTurn = async () => false, + clearMessages, + deleteMessage, + editMessage, + handlePermissionResponse, + pendingActions = [], + triggerAIGuide, + topics = [], + sessionId, + createFreshSession, + switchTopic: originalSwitchTopic, + deleteTopic, + renameTopic, + updateTopicSnapshot = () => undefined, + workspacePathMissing = false, + fixWorkspacePathAndRetry, + dismissWorkspacePathError, + } = useAgentChatUnified({ + systemPrompt, + onWriteFile: (content, fileName, context) => { + // 使用 ref 调用最新的 handleWriteFile + handleWriteFileRef.current?.(content, fileName, context); + }, + workspaceId: projectId ?? "", + disableSessionRestore: shouldDisableSessionRestore, + }); + const handleOpenSubagentSession = useCallback( + (subagentSessionId: string) => { + void originalSwitchTopic(subagentSessionId); + }, + [originalSwitchTopic], + ); + const handleReturnToParentSession = useCallback(() => { + const parentSessionId = subagentParentContext?.parent_session_id?.trim(); + if (!parentSessionId) { + return; + } + void originalSwitchTopic(parentSessionId); + }, [originalSwitchTopic, subagentParentContext?.parent_session_id]); + const [teamWaitSummary, setTeamWaitSummary] = + useState(null); + const [teamControlSummary, setTeamControlSummary] = + useState(null); + const handleCloseSubagentSession = useCallback( + async (subagentSessionId: string) => { + try { + const response = await closeAgentRuntimeSubagent({ + id: subagentSessionId, + }); + const summary = buildTeamControlSummary({ + action: "close", + requestedSessionIds: [subagentSessionId], + cascadeSessionIds: response.cascade_session_ids, + affectedSessionIds: response.changed_session_ids, + }); + if (summary.affectedSessionIds.length > 0) { + setTeamControlSummary(summary); + } + + if (summary.affectedSessionIds.length > 1) { + toast.success( + `子代理已级联关闭 ${summary.affectedSessionIds.length} 个会话`, + ); + } else if (summary.affectedSessionIds.length === 1) { + toast.success("子代理已关闭"); + } else { + toast.info( + `子代理当前状态为${resolveTeamWorkspaceRuntimeStatusLabel(response.previous_status.kind)},未发生新的关闭变更`, + ); + } + } catch (error) { + const message = + error instanceof Error ? error.message : "关闭子代理失败"; + toast.error(message); + throw error; + } + }, + [], + ); + const handleResumeSubagentSession = useCallback( + async (subagentSessionId: string) => { + try { + const response = await resumeAgentRuntimeSubagent({ + id: subagentSessionId, + }); + const summary = buildTeamControlSummary({ + action: "resume", + requestedSessionIds: [subagentSessionId], + cascadeSessionIds: response.cascade_session_ids, + affectedSessionIds: response.changed_session_ids, + }); + if (summary.affectedSessionIds.length > 0) { + setTeamControlSummary(summary); + } + + if (summary.affectedSessionIds.length > 1) { + toast.success( + `子代理已级联恢复 ${summary.affectedSessionIds.length} 个会话`, + ); + } else if (summary.affectedSessionIds.length === 1) { + toast.success("子代理已恢复"); + } else { + toast.info( + `子代理当前状态为${resolveTeamWorkspaceRuntimeStatusLabel(response.status.kind)},未发生新的恢复变更`, + ); + } + } catch (error) { + const message = + error instanceof Error ? error.message : "恢复子代理失败"; + toast.error(message); + throw error; + } + }, + [], + ); + const handleWaitSubagentSession = useCallback( + async (subagentSessionId: string, timeoutMs = 30_000) => { + try { + const response = await waitAgentRuntimeSubagents({ + ids: [subagentSessionId], + timeout_ms: timeoutMs, + }); + if (response.timed_out) { + toast.info("等待超时,子代理仍未进入最终状态"); + return; + } + + const status = response.status[subagentSessionId]; + toast.success( + `子代理已进入${resolveTeamWorkspaceRuntimeStatusLabel(status?.kind)}状态`, + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "等待子代理失败"; + toast.error(message); + throw error; + } + }, + [], + ); + const handleWaitActiveTeamSessions = useCallback( + async (subagentSessionIds: string[], timeoutMs = 30_000) => { + const normalizedSessionIds = + normalizeUniqueSessionIds(subagentSessionIds); + + if (normalizedSessionIds.length === 0) { + const error = new Error("没有可等待的活跃子代理"); + toast.error(error.message); + throw error; + } + + try { + const response = await waitAgentRuntimeSubagents({ + ids: normalizedSessionIds, + timeout_ms: timeoutMs, + }); + if (response.timed_out) { + setTeamWaitSummary({ + awaitedSessionIds: normalizedSessionIds, + timedOut: true, + updatedAt: Date.now(), + }); + toast.info("等待超时,团队内活跃子代理仍未进入最终状态"); + return; + } + + const resolvedSessionId = + normalizedSessionIds.find((sessionId) => + isTeamWorkspaceTerminalStatus(response.status[sessionId]?.kind), + ) ?? normalizedSessionIds[0]; + const resolvedStatus = resolvedSessionId + ? response.status[resolvedSessionId]?.kind + : undefined; + + setTeamWaitSummary({ + awaitedSessionIds: normalizedSessionIds, + timedOut: false, + resolvedSessionId, + resolvedStatus, + updatedAt: Date.now(), + }); + toast.success( + `团队内 agent 已进入${resolveTeamWorkspaceRuntimeStatusLabel(resolvedStatus)}状态`, + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "等待团队内子代理失败"; + toast.error(message); + throw error; + } + }, + [], + ); + const handleCloseCompletedTeamSessions = useCallback( + async (subagentSessionIds: string[]) => { + const normalizedSessionIds = + normalizeUniqueSessionIds(subagentSessionIds); + + if (normalizedSessionIds.length === 0) { + const error = new Error("没有可关闭的已完成子代理"); + toast.error(error.message); + throw error; + } + + const results = await Promise.allSettled( + normalizedSessionIds.map((sessionId) => + closeAgentRuntimeSubagent({ id: sessionId }), + ), + ); + const successfulResponses = results + .filter( + ( + result, + ): result is PromiseFulfilledResult< + Awaited> + > => result.status === "fulfilled", + ) + .map((result) => result.value); + const succeededCount = results.filter( + (result) => result.status === "fulfilled", + ).length; + const affectedSessionIds = normalizeUniqueSessionIds( + successfulResponses.flatMap((response) => response.changed_session_ids), + ); + const cascadeSessionIds = normalizeUniqueSessionIds( + successfulResponses.flatMap((response) => response.cascade_session_ids), + ); + const failedResults = results.filter( + (result): result is PromiseRejectedResult => + result.status === "rejected", + ); + + if (successfulResponses.length > 0) { + setTeamControlSummary( + buildTeamControlSummary({ + action: "close_completed", + requestedSessionIds: normalizedSessionIds, + cascadeSessionIds, + affectedSessionIds, + }), + ); + } + + if (succeededCount > 0) { + toast.success( + affectedSessionIds.length > 0 + ? `已级联关闭 ${affectedSessionIds.length} 个会话` + : `已关闭 ${succeededCount} 个已完成 agent`, + ); + } + + if (failedResults.length > 0) { + const firstFailure = failedResults[0]?.reason; + const message = + firstFailure instanceof Error + ? firstFailure.message + : "部分已完成 agent 关闭失败"; + toast.error(message); + if (succeededCount === 0) { + throw firstFailure instanceof Error + ? firstFailure + : new Error(message); + } + } + }, + [], + ); + const handleSendSubagentInput = useCallback( + async ( + subagentSessionId: string, + message: string, + options?: { interrupt?: boolean }, + ) => { + const normalizedMessage = message.trim(); + if (!normalizedMessage) { + const error = new Error("请输入要发给子代理的内容"); + toast.error(error.message); + throw error; + } + + try { + await sendAgentRuntimeSubagentInput({ + id: subagentSessionId, + message: normalizedMessage, + interrupt: options?.interrupt === true, + }); + toast.success( + options?.interrupt === true + ? "已中断当前执行并发送新任务" + : "已向子代理发送补充任务", + ); + } catch (error) { + const messageText = + error instanceof Error ? error.message : "发送子代理输入失败"; + toast.error(messageText); + throw error; + } + }, + [], + ); + const currentSessionTitle = useMemo( + () => topics.find((topic) => topic.id === sessionId)?.title ?? null, + [sessionId, topics], + ); + const showTeamWorkspaceBoard = + chatToolPreferences.subagent || + childSubagentSessions.length > 0 || + Boolean(subagentParentContext); + const currentSessionRuntimeStatus = useMemo( + () => + deriveCurrentSessionRuntimeStatus({ + isSending, + queuedTurnCount: queuedTurns.length, + turns, + }), + [isSending, queuedTurns.length, turns], + ); + const currentSessionLatestTurnStatus = useMemo( + () => deriveLatestTurnRuntimeStatus(turns), + [turns], + ); + const { + liveRuntimeBySessionId: teamLiveRuntimeBySessionId, + liveActivityBySessionId: teamLiveActivityBySessionId, + activityRefreshVersionBySessionId: teamActivityRefreshVersionBySessionId, + } = useTeamWorkspaceRuntime({ + currentSessionId: sessionId, + currentSessionRuntimeStatus, + currentSessionLatestTurnStatus, + currentSessionQueuedTurnCount: queuedTurns.length, + childSubagentSessions, + subagentParentContext, + }); + useEffect(() => { + logAgentDebug( + "AgentChatPage", + "stateSnapshot", + { + activeTheme, + contentId: contentId ?? null, + initialContentLoadError: initialContentLoadError ?? null, + isInitialContentLoading, + isSending, + layoutMode, + messagesCount: messages.length, + projectId: projectId ?? null, + sessionId: sessionId ?? null, + skillsCount: skills.length, + skillsLoading, + topicsCount: topics.length, + workspaceHealthError, + }, + { + dedupeKey: JSON.stringify({ + activeTheme, + contentId: contentId ?? null, + initialContentLoadError: initialContentLoadError ?? null, + isInitialContentLoading, + isSending, + layoutMode, + messagesCount: messages.length, + projectId: projectId ?? null, + sessionId: sessionId ?? null, + skillsCount: skills.length, + skillsLoading, + topicsCount: topics.length, + workspaceHealthError, + }), + throttleMs: 800, + }, + ); + }, [ + activeTheme, + contentId, + initialContentLoadError, + isInitialContentLoading, + isSending, + layoutMode, + messages.length, + projectId, + sessionId, + skills.length, + skillsLoading, + topics.length, + workspaceHealthError, + ]); + const settledLiveArtifact = useMemo( + () => + settleLiveArtifactAfterStreamStops(liveArtifact, { + streamActive: isSending, + }), + [isSending, liveArtifact], + ); + const settledWorkbenchArtifacts = useMemo(() => { + if (!settledLiveArtifact) { + return artifacts; + } + + let updated = false; + const nextArtifacts = artifacts.map((artifact) => { + if (artifact.id !== settledLiveArtifact.id) { + return artifact; + } + + updated = updated || artifact !== settledLiveArtifact; + return settledLiveArtifact; + }); + + return updated ? nextArtifacts : artifacts; + }, [artifacts, settledLiveArtifact]); + const artifactDisplayState = useArtifactDisplayState( + settledLiveArtifact, + artifacts, + ); + const currentCanvasArtifact = artifactDisplayState.liveArtifact; + const displayedCanvasArtifact = artifactDisplayState.displayArtifact; + const currentBrowserAssistScopeKey = useMemo( + () => + activeTheme === "general" + ? resolveBrowserAssistSessionScopeKey(projectId, sessionId) + : null, + [activeTheme, projectId, sessionId], + ); + const browserAssistArtifact = useMemo( + () => + artifacts.find( + (artifact) => + artifact.id === GENERAL_BROWSER_ASSIST_ARTIFACT_ID && + artifact.type === "browser_assist" && + resolveBrowserAssistArtifactScopeKey(artifact) === + currentBrowserAssistScopeKey, + ) || null, + [artifacts, currentBrowserAssistScopeKey], + ); + const latestBrowserAssistSessionFromMessages = useMemo( + () => findLatestBrowserAssistSessionInMessages(messages), + [messages], + ); + const browserAssistSessionFromArtifact = useMemo( + () => extractBrowserAssistSessionFromArtifact(browserAssistArtifact), + [browserAssistArtifact], + ); + const browserAssistStorageKey = useMemo( + () => + activeTheme === "general" + ? `${projectId || "global"}:${sessionId || "active"}` + : null, + [activeTheme, projectId, sessionId], + ); + const isBrowserAssistCanvasVisible = + activeTheme === "general" && + layoutMode !== "chat" && + currentCanvasArtifact?.type === "browser_assist"; + const compatSubagentRuntime = useCompatSubagentRuntime(sessionId); + const realSubagentTimelineItems = useMemo( + () => + buildRealSubagentTimelineItems({ + threadId: sessionId, + turns, + childSessions: childSubagentSessions, + }), + [childSubagentSessions, sessionId, turns], + ); + const syntheticSubagentItems = useMemo( + () => + buildSyntheticSubagentTimelineItems({ + threadId: sessionId, + turnId: currentTurnId, + events: compatSubagentRuntime.events, + }), + [compatSubagentRuntime.events, currentTurnId, sessionId], + ); + const effectiveThreadItems = useMemo( + () => + mergeThreadItems( + threadItems, + realSubagentTimelineItems, + realSubagentTimelineItems.length > 0 + ? undefined + : syntheticSubagentItems, + ), + [realSubagentTimelineItems, syntheticSubagentItems, threadItems], + ); + const harnessState = useMemo( + () => + deriveHarnessSessionState( + messages, + pendingActions, + effectiveThreadItems, + todoItems, + ), + [effectiveThreadItems, messages, pendingActions, todoItems], + ); + const activeRuntimeStatusTitle = useMemo(() => { + if (!isSending) { + return null; + } + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role === "assistant" && message.runtimeStatus?.title) { + return message.runtimeStatus.title; + } + } + + return "Agent 正在准备执行"; + }, [isSending, messages]); + const [harnessPanelVisible, setHarnessPanelVisible] = useState(() => + loadPersistedBoolean(HARNESS_PANEL_VISIBILITY_KEY, false), + ); + const [toolInventory, setToolInventory] = + useState(null); + const [toolInventoryLoading, setToolInventoryLoading] = useState(false); + const [toolInventoryError, setToolInventoryError] = useState( + null, + ); + const toolInventoryRequestIdRef = useRef(0); + const thinkingVariantWarnedRef = useRef>(new Set()); + const resolveSendProviderContext = useCallback(async () => { + const configuredProviders = await loadConfiguredProviders(); + const selectedProvider = + configuredProviders.find((provider) => provider.key === providerType) || + null; + const providerModels = await loadProviderModels(selectedProvider); + + return { + selectedProvider, + providerModels, + }; + }, [providerType]); + + useEffect(() => { + onSessionChange?.(sessionId ?? null); + }, [onSessionChange, sessionId]); + + useEffect(() => { + if (activeTheme !== "general") { + setArtifacts([]); + return; + } + + const messageArtifacts = mergeArtifacts( + messages.flatMap((message) => message.artifacts || []), + ); + setArtifacts((currentArtifacts) => + mergeMessageArtifactsIntoStore( + messageArtifacts, + currentArtifacts, + currentBrowserAssistScopeKey, + ), + ); + }, [activeTheme, currentBrowserAssistScopeKey, messages, setArtifacts]); + + useEffect(() => { + if (activeTheme !== "general") { + setSelectedArtifactId(null); + return; + } + + if (artifacts.length === 0) { + if (selectedArtifact) { + setSelectedArtifactId(null); + } + return; + } + + if (!selectedArtifact) { + setSelectedArtifactId(artifacts[artifacts.length - 1]?.id || null); + return; + } + + const selectedStillExists = artifacts.some( + (artifact) => artifact.id === selectedArtifact.id, + ); + if (!selectedStillExists) { + setSelectedArtifactId(artifacts[artifacts.length - 1]?.id || null); + } + }, [activeTheme, artifacts, selectedArtifact, setSelectedArtifactId]); + + useEffect(() => { + if (activeTheme !== "general" || !displayedCanvasArtifact) { + return; + } + setArtifactViewMode( + resolveDefaultArtifactViewMode(displayedCanvasArtifact), + ); + }, [activeTheme, displayedCanvasArtifact]); + + useEffect(() => { + savePersistedBoolean(HARNESS_PANEL_VISIBILITY_KEY, harnessPanelVisible); + }, [harnessPanelVisible]); + + const contextWorkspace = useThemeContextWorkspace({ + projectId, + activeTheme, + messages, + providerType, + model, + }); + const isThemeWorkbench = contextWorkspace.enabled; + const harnessSkillNames = useMemo( + () => collectConversationSkillNames(messages), + [messages], + ); + const harnessPendingCount = harnessState.pendingApprovals.length; + const shouldAlwaysShowHarnessToggle = + contextWorkspace.enabled && mappedTheme === "social-media"; + const shouldAlwaysShowGeneralWorkbenchToggle = + chatMode === "general" && !contextWorkspace.enabled; + const hasHarnessActivity = + harnessPanelVisible || + harnessState.hasSignals || + compatSubagentRuntime.isRunning; + const showHarnessToggle = + shouldAlwaysShowHarnessToggle || + shouldAlwaysShowGeneralWorkbenchToggle || + hasHarnessActivity; + const harnessAttentionLevel = + harnessPendingCount > 0 + ? "warning" + : hasHarnessActivity + ? "active" + : "idle"; + const navbarHarnessPanelVisible = harnessPanelVisible; + const visibleContextItems = useMemo(() => { + const activeItems = contextWorkspace.sidebarContextItems.filter( + (item) => item.active, + ); + return activeItems.length > 0 + ? activeItems + : contextWorkspace.sidebarContextItems; + }, [contextWorkspace.sidebarContextItems]); + const harnessEnvironment = useMemo( + () => ({ + skillsCount: harnessSkillNames.length, + skillNames: harnessSkillNames.slice(0, 4), + memorySignals: [ + projectMemory?.characters.length ? "角色" : null, + projectMemory?.world_building ? "世界观" : null, + projectMemory?.style_guide ? "风格" : null, + projectMemory?.outline.length ? "大纲" : null, + ].filter((item): item is string => item !== null), + contextItemsCount: contextWorkspace.sidebarContextItems.length, + activeContextCount: contextWorkspace.sidebarContextItems.filter( + (item) => item.active, + ).length, + contextItemNames: visibleContextItems + .map((item) => item.name) + .filter((name) => !!name.trim()) + .slice(0, 4), + contextEnabled: contextWorkspace.enabled, + }), + [ + contextWorkspace.enabled, + contextWorkspace.sidebarContextItems, + harnessSkillNames, + projectMemory?.characters.length, + projectMemory?.outline.length, + projectMemory?.style_guide, + projectMemory?.world_building, + visibleContextItems, + ], + ); + const shouldUseCompactThemeWorkbench = + isThemeWorkbench && (mappedTheme === "video" || mappedTheme === "poster"); + const shouldSkipThemeWorkbenchAutoGuideWithoutPrompt = + isThemeWorkbench && + (shouldUseCompactThemeWorkbench || mappedTheme === "novel"); + const enableThemeWorkbenchPanelCollapse = + isThemeWorkbench && mappedTheme === "social-media"; + const handleToggleHarnessPanel = useCallback(() => { + setHarnessPanelVisible((current) => !current); + }, []); + + useEffect(() => { + void loadSkills(false); + }, [loadSkills]); + + // 主题工作台模式:同步 skills 状态到 store + // 注意:不再设置 themeSkillsRailState,避免"操作面板"覆盖默认 Skills Rail + // 默认 Skills Rail 已包含完整的技能分类(文字多搜索、视觉生成、音频生成等) + useEffect(() => { + if (!isThemeWorkbench) { + clearThemeSkillsRailState(); + } + }, [isThemeWorkbench, clearThemeSkillsRailState]); + + // 组件卸载时清理 store 状态 + useEffect(() => { + return () => { + clearThemeSkillsRailState(); + }; + }, [clearThemeSkillsRailState]); + + useEffect(() => { + if (!isThemeWorkbench) { + setThemeWorkbenchCreationTaskEvents([]); + } + }, [isThemeWorkbench]); + + useEffect(() => { + if (!isThemeWorkbench || !sessionId) { + return; + } + + setThemeWorkbenchCreationTaskEvents([]); + + let cancelled = false; + let unlisten: (() => void) | null = null; + + safeListen( + THEME_WORKBENCH_CREATION_TASK_EVENT_NAME, + (event) => { + if (cancelled) { + return; + } + const normalized = normalizeThemeWorkbenchCreationTaskEvent( + event.payload || {}, + ); + if (!normalized) { + return; + } + setThemeWorkbenchCreationTaskEvents((previous) => { + const deduplicated = previous.filter( + (item) => + item.taskId !== normalized.taskId && + item.path !== normalized.path, + ); + return [normalized, ...deduplicated].slice( + 0, + MAX_THEME_WORKBENCH_CREATION_TASK_EVENTS, + ); + }); + }, + ) + .then((dispose) => { + if (cancelled) { + void dispose(); + return; + } + unlisten = dispose; + }) + .catch((error) => { + console.warn("[AgentChatPage] 监听任务提交事件失败:", error); + }); + + return () => { + cancelled = true; + if (unlisten) { + unlisten(); + } + }; + }, [isThemeWorkbench, sessionId]); + + useEffect(() => { + if (!isThemeWorkbench || canvasState) { + return; + } + + const initialThemeWorkbenchCanvas = + createInitialCanvasState(mappedTheme, "") || + createInitialDocumentState(""); + if (!initialThemeWorkbenchCanvas) { + return; + } + + setCanvasState(initialThemeWorkbenchCanvas); + setLayoutMode((previous) => (previous === "chat" ? "canvas" : previous)); + }, [canvasState, isThemeWorkbench, mappedTheme]); + + useEffect(() => { + if (enableThemeWorkbenchPanelCollapse) { + return; + } + setThemeWorkbenchSidebarCollapsed(false); + }, [enableThemeWorkbenchPanelCollapse]); + const versionTopics = useMemo(() => { + if (!isThemeWorkbench || !canvasState || canvasState.type !== "document") { + return []; + } + return canvasState.versions.map((version, index) => ({ + id: version.id, + title: version.description?.trim() || `版本 ${index + 1}`, + messagesCount: version.content.trim() ? 2 : 0, + })); + }, [canvasState, isThemeWorkbench]); + const currentVersionId = + isThemeWorkbench && canvasState?.type === "document" + ? canvasState.currentVersionId + : null; + const { branchItems, setTopicStatus } = useTopicBranchBoard({ + enabled: isThemeWorkbench && canvasState?.type === "document", + projectId, + currentTopicId: currentVersionId, + topics: versionTopics, + externalStatusMap: documentVersionStatusMap, + onStatusMapChange: setDocumentVersionStatusMap, + }); + + useEffect(() => { + if ( + !isThemeWorkbench || + !contentId || + !canvasState || + canvasState.type !== "document" + ) { + return; + } + + const persisted = buildPersistedThemeWorkbenchDocument( + canvasState, + documentVersionStatusMap, + ); + if (!persisted) { + return; + } + + const snapshot = JSON.stringify(persisted); + if (snapshot === persistedWorkbenchSnapshotRef.current) { + return; + } + + const nextMetadata = { + ...(contentMetadataRef.current || {}), + [THEME_WORKBENCH_DOCUMENT_META_KEY]: persisted, + }; + + const timer = setTimeout(() => { + updateContent(contentId, { + metadata: nextMetadata, + }) + .then((updated) => { + contentMetadataRef.current = updated.metadata || nextMetadata; + persistedWorkbenchSnapshotRef.current = snapshot; + }) + .catch((error) => { + console.warn("[AgentChatPage] 保存文稿版本状态失败:", error); + }); + }, 1000); + + return () => clearTimeout(timer); + }, [canvasState, contentId, documentVersionStatusMap, isThemeWorkbench]); + + const pendingActionRequest = useMemo(() => { + const latestPendingMessage = [...messages] + .reverse() + .find((message) => + message.actionRequests?.some((request) => request.status === "pending"), + ); + + if (!latestPendingMessage?.actionRequests) { + return null; + } + + return ( + [...latestPendingMessage.actionRequests] + .reverse() + .find((request) => request.status === "pending") || null + ); + }, [messages]); + + // 提取最新的 A2UI Form(从最后一条 assistant 消息的 content 解析) + const pendingMessageA2UIForm = useMemo(() => { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + + if (msg.role === "user") { + return null; + } + + if (msg.role === "assistant" && msg.content) { + try { + const parsed = parseAIResponse(msg.content, false); + if (parsed.hasA2UI) { + for (let j = parsed.parts.length - 1; j >= 0; j--) { + const part = parsed.parts[j]; + if (part.type === "a2ui" && typeof part.content !== "string") { + return part.content; + } + } + } + } catch { + // 解析失败,忽略 + } + } + } + return null; + }, [messages]); + + const pendingPromotedA2UIActionRequest = useMemo(() => { + if (pendingMessageA2UIForm) { + return null; + } + + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + const pendingRequest = [...(message.actionRequests || [])] + .reverse() + .find( + (request) => + request.status === "pending" && + isActionRequestA2UICompatible(request), + ); + + if (pendingRequest) { + return pendingRequest; + } + } + + return null; + }, [messages, pendingMessageA2UIForm]); + + const pendingLegacyQuestionnaireA2UIForm = useMemo(() => { + if (pendingMessageA2UIForm || pendingActionRequest) { + return null; + } + + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + + if (message.role === "user") { + return null; + } + + if (message.role !== "assistant") { + continue; + } + + if ((message.actionRequests || []).length > 0) { + return null; + } + + return buildLegacyQuestionnaireA2UI(message.content || ""); + } + + return null; + }, [messages, pendingActionRequest, pendingMessageA2UIForm]); + + const pendingA2UIForm = useMemo(() => { + if (pendingMessageA2UIForm) { + return pendingMessageA2UIForm; + } + + if (pendingPromotedA2UIActionRequest) { + return buildActionRequestA2UI(pendingPromotedA2UIActionRequest); + } + + return pendingLegacyQuestionnaireA2UIForm; + }, [ + pendingLegacyQuestionnaireA2UIForm, + pendingMessageA2UIForm, + pendingPromotedA2UIActionRequest, + ]); + + const a2uiSubmissionNotice = useMemo(() => { + if (pendingA2UIForm) { + return null; + } + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === "assistant") { + const submittedActionRequest = [...(msg.actionRequests || [])] + .reverse() + .find( + (request) => + request.status === "submitted" && + isActionRequestA2UICompatible(request), + ); + + if (submittedActionRequest) { + const summary = summarizeActionRequestSubmission( + submittedActionRequest, + ); + return { + title: "补充信息已确认", + summary: summary || "已收到你的补充信息,正在继续推进下一步。", + }; + } + + continue; + } + + if (msg.role !== "user") { + continue; + } + + const content = msg.content.trim(); + if (!content.startsWith("我的选择:")) { + return null; + } + + const summary = content + .split("\n") + .slice(1) + .map((line) => line.replace(/^[-•]\s*/, "").trim()) + .filter(Boolean) + .slice(0, 3) + .join(" · "); + + return { + title: "需求已确认", + summary: summary || "已收到你的补充信息,正在继续推进下一步。", + }; + } + + return null; + }, [messages, pendingA2UIForm]); + + useEffect(() => { + const unsubscribe = subscribeDocumentEditorFocus((focused) => { + documentEditorFocusedRef.current = focused; + }); + return unsubscribe; + }, []); + + useEffect(() => { + if (!isThemeWorkbench || !sessionId) { + themeWorkbenchRunStateSignatureRef.current = ""; + setThemeWorkbenchBackendRunState(null); + return; + } + + let disposed = false; + let inFlight = false; + let timer: number | null = null; + const activePollIntervalMs = isSending ? 1000 : 3000; + const idlePollIntervalMs = isSending ? 1000 : 10000; + const focusedPollIntervalMs = isSending ? 1000 : 15000; + + const scheduleNext = (delayMs: number) => { + if (disposed) { + return; + } + timer = window.setTimeout(() => { + void fetchRunState(); + }, delayMs); + }; + + const fetchRunState = async () => { + if (disposed || inFlight) { + return; + } + + inFlight = true; + try { + const state = await executionRunGetThemeWorkbenchState(sessionId, 3); + if (!disposed) { + const nextSignature = buildThemeWorkbenchRunStateSignature(state); + if (themeWorkbenchRunStateSignatureRef.current !== nextSignature) { + themeWorkbenchRunStateSignatureRef.current = nextSignature; + setThemeWorkbenchBackendRunState(state); + } + + const hasFreshRunningQueueItem = (state.queue_items || []).some( + (item) => { + if (item.status !== "running") { + return false; + } + const startedAt = new Date(item.started_at); + if (Number.isNaN(startedAt.getTime())) { + return false; + } + return ( + Date.now() - startedAt.getTime() <= + THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS + ); + }, + ); + + const latestTerminalRunning = + state.latest_terminal?.status === "running"; + const hasActiveBackendRun = + state.run_state === "auto_running" || + hasFreshRunningQueueItem || + latestTerminalRunning; + const isEditorFocused = documentEditorFocusedRef.current; + scheduleNext( + hasActiveBackendRun + ? activePollIntervalMs + : isEditorFocused + ? focusedPollIntervalMs + : idlePollIntervalMs, + ); + } + } catch (error) { + if (!disposed) { + console.warn("[AgentChatPage] 拉取主题工作台运行状态失败:", error); + if (themeWorkbenchRunStateSignatureRef.current !== "null") { + themeWorkbenchRunStateSignatureRef.current = "null"; + setThemeWorkbenchBackendRunState(null); + } + scheduleNext( + documentEditorFocusedRef.current + ? focusedPollIntervalMs + : activePollIntervalMs, + ); + } + } finally { + inFlight = false; + } + }; + + void fetchRunState(); + + return () => { + disposed = true; + if (timer !== null) { + window.clearTimeout(timer); + } + }; + }, [isSending, isThemeWorkbench, sessionId]); + + const loadThemeWorkbenchHistory = useCallback( + async (offset: number, replace: boolean) => { + if ( + !isThemeWorkbench || + !sessionId || + themeWorkbenchHistoryLoadingRef.current + ) { + return; + } + + themeWorkbenchHistoryLoadingRef.current = true; + setThemeWorkbenchHistoryLoading(true); + try { + const page = await executionRunListThemeWorkbenchHistory( + sessionId, + THEME_WORKBENCH_HISTORY_PAGE_SIZE, + offset, + ); + setThemeWorkbenchHistoryTerminals((previous) => + replace + ? mergeThemeWorkbenchTerminalItems(page.items || []) + : mergeThemeWorkbenchTerminalItems(previous, page.items || []), + ); + setThemeWorkbenchHistoryHasMore(Boolean(page.has_more)); + setThemeWorkbenchHistoryNextOffset(page.next_offset ?? null); + } catch (error) { + console.warn("[AgentChatPage] 拉取主题工作台历史日志失败:", error); + if (replace) { + setThemeWorkbenchHistoryTerminals([]); + setThemeWorkbenchHistoryHasMore(false); + setThemeWorkbenchHistoryNextOffset(null); + } + } finally { + themeWorkbenchHistoryLoadingRef.current = false; + setThemeWorkbenchHistoryLoading(false); + } + }, + [isThemeWorkbench, sessionId], + ); + + useEffect(() => { + if (!isThemeWorkbench || !sessionId) { + themeWorkbenchHistoryLoadingRef.current = false; + setThemeWorkbenchHistoryTerminals([]); + setThemeWorkbenchHistoryHasMore(false); + setThemeWorkbenchHistoryNextOffset(null); + setThemeWorkbenchHistoryLoading(false); + return; + } + + void loadThemeWorkbenchHistory(0, true); + }, [isThemeWorkbench, loadThemeWorkbenchHistory, sessionId]); + + const themeWorkbenchRequiredSkillNames = useMemo(() => { + if (!isThemeWorkbench) { + return [] as string[]; + } + + const requiredSkillNames = new Set(); + messages.forEach((message) => { + if (message.role !== "user") { + return; + } + const skillName = parseSkillSlashCommand(message.content)?.skillName; + if (skillName) { + requiredSkillNames.add(skillName); + } + }); + (themeWorkbenchBackendRunState?.queue_items || []).forEach((item) => { + const sourceRef = resolveThemeWorkbenchSkillSourceRef(item); + if (sourceRef) { + requiredSkillNames.add(sourceRef); + } + }); + const terminalSourceRef = resolveThemeWorkbenchSkillSourceRef( + themeWorkbenchBackendRunState?.latest_terminal || {}, + ); + if (terminalSourceRef) { + requiredSkillNames.add(terminalSourceRef); + } + + return [...requiredSkillNames].sort(); + }, [ + isThemeWorkbench, + messages, + themeWorkbenchBackendRunState?.latest_terminal, + themeWorkbenchBackendRunState?.queue_items, + ]); + + useEffect(() => { + if (!isThemeWorkbench) { + setThemeWorkbenchSkillDetailMap((prev) => + Object.keys(prev).length === 0 ? prev : {}, + ); + return; + } + + const missingSkillNames = themeWorkbenchRequiredSkillNames.filter( + (skillName) => !(skillName in themeWorkbenchSkillDetailMap), + ); + if (missingSkillNames.length === 0) { + return; + } + + let disposed = false; + Promise.all( + missingSkillNames.map(async (skillName) => { + try { + const detail = await skillExecutionApi.getSkillDetail(skillName); + return [skillName, detail] as const; + } catch (error) { + console.warn( + "[AgentChatPage] 加载 Skill 详情失败:", + skillName, + error, + ); + return [skillName, null] as const; + } + }), + ).then((entries) => { + if (disposed) { + return; + } + setThemeWorkbenchSkillDetailMap((prev) => { + const next = { ...prev }; + entries.forEach(([skillName, detail]) => { + next[skillName] = detail; + }); + return next; + }); + }); + + return () => { + disposed = true; + }; + }, [ + isThemeWorkbench, + themeWorkbenchRequiredSkillNames, + themeWorkbenchSkillDetailMap, + ]); + + const themeWorkbenchWorkflowSteps = useMemo( + () => + buildThemeWorkbenchWorkflowSteps( + messages, + themeWorkbenchBackendRunState, + isSending, + themeWorkbenchSkillDetailMap, + ), + [ + isSending, + messages, + themeWorkbenchBackendRunState, + themeWorkbenchSkillDetailMap, + ], + ); + + const themeWorkbenchActiveQueueItem = useMemo(() => { + const queueItems = themeWorkbenchBackendRunState?.queue_items || []; + return ( + queueItems.find((item) => item.status === "running") || + queueItems[0] || + null + ); + }, [themeWorkbenchBackendRunState?.queue_items]); + + const themeWorkbenchMergedTerminals = useMemo( + () => + mergeThemeWorkbenchTerminalItems( + resolveThemeWorkbenchRecentTerminals(themeWorkbenchBackendRunState), + themeWorkbenchHistoryTerminals, + ), + [themeWorkbenchBackendRunState, themeWorkbenchHistoryTerminals], + ); + + const themeWorkbenchExecutionRunMap = useMemo(() => { + const map = new Map(); + if (!isThemeWorkbench || !themeWorkbenchBackendRunState) { + return map; + } + + const register = (executionId?: string | null, runId?: string | null) => { + const normalizedExecutionId = executionId?.trim(); + const normalizedRunId = runId?.trim(); + if (!normalizedExecutionId || !normalizedRunId) { + return; + } + map.set(normalizedExecutionId, normalizedRunId); + }; + + (themeWorkbenchBackendRunState.queue_items || []).forEach((item) => { + register(item.execution_id, item.run_id); + }); + themeWorkbenchMergedTerminals.forEach((item) => { + register(item.execution_id, item.run_id); + }); + + return map; + }, [ + isThemeWorkbench, + themeWorkbenchBackendRunState, + themeWorkbenchMergedTerminals, + ]); + + const themeWorkbenchBackendActivityLogs = useMemo< + SidebarActivityLog[] + >(() => { + if (!isThemeWorkbench || !themeWorkbenchBackendRunState) { + return []; + } + + const runningLogs = (themeWorkbenchBackendRunState.queue_items || []).map( + (item) => { + const gateKey = + item.gate_key || inferThemeWorkbenchGateFromQueueItem(item).key; + return { + id: `run-queue-${item.run_id}`, + name: item.title || "执行主题工作台编排", + status: "running" as const, + timeLabel: formatThemeWorkbenchRunTimeLabel(item.started_at), + applyTarget: resolveThemeWorkbenchApplyTargetByGateKey(gateKey), + runId: item.run_id, + executionId: item.execution_id || undefined, + sessionId: item.session_id || undefined, + artifactPaths: + Array.isArray(item.artifact_paths) && item.artifact_paths.length > 0 + ? item.artifact_paths + : undefined, + gateKey, + source: item.source, + sourceRef: item.source_ref || undefined, + }; + }, + ); + + const terminalLogs: SidebarActivityLog[] = + themeWorkbenchMergedTerminals.map((terminal) => ({ + id: `run-terminal-${terminal.run_id}`, + name: terminal.title || "执行主题工作台编排", + status: terminal.status === "success" ? "completed" : "failed", + timeLabel: formatThemeWorkbenchRunTimeLabel( + terminal.finished_at || terminal.started_at, + ), + durationLabel: formatThemeWorkbenchRunDurationLabel( + terminal.started_at, + terminal.finished_at, + ), + applyTarget: resolveThemeWorkbenchApplyTargetByGateKey( + terminal.gate_key || "idle", + ), + runId: terminal.run_id, + executionId: terminal.execution_id || undefined, + sessionId: terminal.session_id || undefined, + artifactPaths: + Array.isArray(terminal.artifact_paths) && + terminal.artifact_paths.length > 0 + ? terminal.artifact_paths + : undefined, + gateKey: terminal.gate_key || "idle", + source: terminal.source, + sourceRef: terminal.source_ref || undefined, + })); + + return [...runningLogs, ...terminalLogs]; + }, [ + isThemeWorkbench, + themeWorkbenchBackendRunState, + themeWorkbenchMergedTerminals, + ]); + + const handleLoadMoreThemeWorkbenchHistory = useCallback(() => { + const nextOffset = + themeWorkbenchHistoryNextOffset ?? themeWorkbenchHistoryTerminals.length; + void loadThemeWorkbenchHistory(nextOffset, false); + }, [ + loadThemeWorkbenchHistory, + themeWorkbenchHistoryNextOffset, + themeWorkbenchHistoryTerminals.length, + ]); + + const themeWorkbenchActivityLogs = useMemo(() => { + if (!isThemeWorkbench) { + return contextWorkspace.activityLogs; + } + const enrichedContextLogs = contextWorkspace.activityLogs.map((log) => { + const normalizedRunId = log.runId?.trim(); + if (normalizedRunId) { + return { + ...log, + runId: normalizedRunId, + }; + } + + const candidateExecutionIds = + resolveExecutionIdCandidatesForActivityLog(log); + for (const executionId of candidateExecutionIds) { + const mappedRunId = themeWorkbenchExecutionRunMap.get(executionId); + if (!mappedRunId) { + continue; + } + return { + ...log, + executionId, + runId: mappedRunId, + }; + } + + return log; + }); + + return [...themeWorkbenchBackendActivityLogs, ...enrichedContextLogs]; + }, [ + contextWorkspace.activityLogs, + isThemeWorkbench, + themeWorkbenchBackendActivityLogs, + themeWorkbenchExecutionRunMap, + ]); + + const handleViewThemeWorkbenchRunDetail = useCallback((runId: string) => { + const normalizedRunId = runId.trim(); + if (!normalizedRunId) { + return; + } + setSelectedThemeWorkbenchRunId(normalizedRunId); + }, []); + + const handleViewContextDetail = useCallback( + (contextId: string) => { + const detail = contextWorkspace.getContextDetail(contextId); + if (!detail) { + toast.error("无法找到上下文详情"); + return; + } + + // 显示上下文详情 + const sourceLabel = + detail.source === "material" + ? "素材库" + : detail.source === "content" + ? "历史内容" + : "搜索结果"; + + toast.info( +
+
+ {detail.name} +
+
+ 来源: {sourceLabel} · 约 {detail.estimatedTokens} tokens +
+
+ {detail.bodyText || detail.previewText} +
+
, + { duration: 10000 }, + ); + }, + [contextWorkspace], + ); + + useEffect(() => { + if (!isThemeWorkbench || !selectedThemeWorkbenchRunId) { + setThemeWorkbenchRunDetailLoading(false); + setSelectedThemeWorkbenchRunDetail(null); + return; + } + + let cancelled = false; + setThemeWorkbenchRunDetailLoading(true); + executionRunGet(selectedThemeWorkbenchRunId) + .then((detail) => { + if (!cancelled) { + setSelectedThemeWorkbenchRunDetail(detail); + } + }) + .catch((error) => { + if (cancelled) { + return; + } + setSelectedThemeWorkbenchRunDetail(null); + console.warn("[AgentChatPage] 加载运行详情失败:", error); + }) + .finally(() => { + if (!cancelled) { + setThemeWorkbenchRunDetailLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [isThemeWorkbench, selectedThemeWorkbenchRunId]); + + const currentGateBase = useMemo(() => { + if (!isThemeWorkbench) { + return { + key: "idle", + title: "编排待启动", + requiresUserDecision: false, + description: "输入目标后将自动进入编排执行。", + }; + } + + if (pendingActionRequest) { + const prompt = + pendingActionRequest.prompt || + pendingActionRequest.questions?.[0]?.question || + "等待你的决策以继续执行后续节点。"; + return { + key: pendingActionRequest.actionType, + title: "人工闸门", + requiresUserDecision: true, + description: prompt, + }; + } + + if (themeWorkbenchBackendRunState?.run_state === "auto_running") { + const backendGateKey = themeWorkbenchBackendRunState.current_gate_key; + if ( + backendGateKey === "topic_select" || + backendGateKey === "write_mode" || + backendGateKey === "publish_confirm" + ) { + const backendGate = resolveThemeWorkbenchGateByKey( + backendGateKey, + themeWorkbenchActiveQueueItem?.title, + ); + return { + key: backendGate.key, + title: backendGate.title, + requiresUserDecision: false, + description: backendGate.description, + }; + } + const backendGate = inferThemeWorkbenchGateFromQueueItem( + themeWorkbenchActiveQueueItem, + ); + return { + key: backendGate.key, + title: backendGate.title, + requiresUserDecision: false, + description: backendGate.description, + }; + } + + return { + key: "idle", + title: "编排待启动", + requiresUserDecision: false, + description: "输入目标后将自动进入编排执行。", + }; + }, [ + isThemeWorkbench, + pendingActionRequest, + themeWorkbenchActiveQueueItem, + themeWorkbenchBackendRunState?.current_gate_key, + themeWorkbenchBackendRunState?.run_state, + ]); + + const themeWorkbenchRunState = useMemo< + "idle" | "auto_running" | "await_user_decision" + >(() => { + if (!isThemeWorkbench) { + return "idle"; + } + if (currentGateBase.requiresUserDecision) { + return "await_user_decision"; + } + if (themeWorkbenchBackendRunState) { + if (themeWorkbenchBackendRunState.run_state !== "auto_running") { + return "idle"; + } + + const hasFreshRunningQueueItem = ( + themeWorkbenchBackendRunState.queue_items || [] + ).some((item) => { + if (item.status !== "running") { + return false; + } + const startedAt = new Date(item.started_at); + if (Number.isNaN(startedAt.getTime())) { + return false; + } + return ( + Date.now() - startedAt.getTime() <= + THEME_WORKBENCH_ACTIVE_RUN_MAX_AGE_MS + ); + }); + + if (hasFreshRunningQueueItem || isSending) { + return "auto_running"; + } + return "idle"; + } + return isSending ? "auto_running" : "idle"; + }, [ + currentGateBase.requiresUserDecision, + isThemeWorkbench, + themeWorkbenchBackendRunState, + isSending, + ]); + + const currentGate = useMemo(() => { + const status = currentGateBase.requiresUserDecision + ? ("waiting" as const) + : themeWorkbenchRunState === "auto_running" + ? ("running" as const) + : ("idle" as const); + + return { + key: currentGateBase.key, + title: currentGateBase.title, + description: currentGateBase.description, + status, + }; + }, [currentGateBase, themeWorkbenchRunState]); + const harnessRequestMetadata = useMemo( + () => + buildHarnessRequestMetadata({ + theme: mappedTheme, + creationMode, + chatMode, + webSearchEnabled: chatToolPreferences.webSearch, + thinkingEnabled: chatToolPreferences.thinking, + taskModeEnabled: chatToolPreferences.task, + subagentModeEnabled: chatToolPreferences.subagent, + sessionMode: isThemeWorkbench ? "theme_workbench" : "default", + gateKey: isThemeWorkbench ? currentGate.key : undefined, + runTitle: themeWorkbenchActiveQueueItem?.title?.trim() || undefined, + contentId: contentId || undefined, + browserAssistProfileKey: + mappedTheme === "general" + ? GENERAL_BROWSER_ASSIST_PROFILE_KEY + : undefined, + preferredTeamPresetId, + selectedTeamId: selectedTeam?.id, + selectedTeamSource: selectedTeam?.source, + selectedTeamLabel, + selectedTeamSummary, + selectedTeamRoles: selectedTeam?.roles, + }), + [ + chatMode, + chatToolPreferences.subagent, + chatToolPreferences.task, + chatToolPreferences.thinking, + chatToolPreferences.webSearch, + contentId, + creationMode, + currentGate.key, + isThemeWorkbench, + mappedTheme, + preferredTeamPresetId, + selectedTeam?.id, + selectedTeam?.roles, + selectedTeam?.source, + selectedTeamLabel, + selectedTeamSummary, + themeWorkbenchActiveQueueItem?.title, + ], + ); + const refreshToolInventory = useCallback(async () => { + const requestId = toolInventoryRequestIdRef.current + 1; + toolInventoryRequestIdRef.current = requestId; + setToolInventoryLoading(true); + setToolInventoryError(null); + + try { + const nextInventory = await getAgentRuntimeToolInventory({ + caller: "assistant", + creator: chatMode === "creator", + browserAssist: mappedTheme === "general", + metadata: { + harness: harnessRequestMetadata, + }, + }); + + if (toolInventoryRequestIdRef.current !== requestId) { + return; + } + + setToolInventory(nextInventory); + } catch (error) { + if (toolInventoryRequestIdRef.current !== requestId) { + return; + } + + setToolInventoryError( + error instanceof Error ? error.message : "读取工具库存失败", + ); + } finally { + if (toolInventoryRequestIdRef.current === requestId) { + setToolInventoryLoading(false); + } + } + }, [chatMode, harnessRequestMetadata, mappedTheme]); + + useEffect(() => { + if (!harnessPanelVisible) { + return; + } + + void refreshToolInventory(); + }, [harnessPanelVisible, refreshToolInventory]); + + const socialMediaHarnessSummary = useMemo(() => { + if (!isThemeWorkbench || mappedTheme !== "social-media") { + return null; + } + + const latestTerminal = + themeWorkbenchBackendRunState?.latest_terminal ?? null; + const activeRun = themeWorkbenchActiveQueueItem ?? latestTerminal; + const artifactPaths = + Array.isArray(themeWorkbenchActiveQueueItem?.artifact_paths) && + themeWorkbenchActiveQueueItem.artifact_paths.length > 0 + ? themeWorkbenchActiveQueueItem.artifact_paths + : Array.isArray(latestTerminal?.artifact_paths) && + latestTerminal.artifact_paths.length > 0 + ? latestTerminal.artifact_paths + : []; + + return { + runState: themeWorkbenchRunState, + stageTitle: currentGate.title, + stageDescription: currentGate.description, + runTitle: activeRun?.title || null, + artifactCount: artifactPaths.length, + updatedAt: + themeWorkbenchBackendRunState?.updated_at || + latestTerminal?.finished_at || + latestTerminal?.started_at || + themeWorkbenchActiveQueueItem?.started_at || + null, + pendingCount: harnessPendingCount, + }; + }, [ + currentGate.description, + currentGate.title, + harnessPendingCount, + isThemeWorkbench, + mappedTheme, + themeWorkbenchActiveQueueItem, + themeWorkbenchBackendRunState?.latest_terminal, + themeWorkbenchBackendRunState?.updated_at, + themeWorkbenchRunState, + ]); + + useEffect(() => { + if (!isThemeWorkbench || themeWorkbenchRunState !== "idle") { + return; + } + if (!canvasState || canvasState.type !== "document") { + return; + } + + setDocumentVersionStatusMap((previous) => { + const latestTerminal = themeWorkbenchBackendRunState?.latest_terminal; + if (latestTerminal) { + const terminalVersionId = latestTerminal.run_id; + const terminalVersionExists = canvasState.versions.some( + (version) => version.id === terminalVersionId, + ); + if (terminalVersionExists) { + const terminalStatus: TopicBranchStatus = + latestTerminal.status === "success" ? "merged" : "candidate"; + if (previous[terminalVersionId] !== terminalStatus) { + return { + ...previous, + [terminalVersionId]: terminalStatus, + }; + } + } + } + + const currentVersionId = canvasState.currentVersionId; + if (!currentVersionId || previous[currentVersionId] !== "in_progress") { + return previous; + } + return { + ...previous, + [currentVersionId]: "pending", + }; + }); + }, [ + canvasState, + isThemeWorkbench, + themeWorkbenchBackendRunState?.latest_terminal, + themeWorkbenchRunState, + ]); + + // 会话文件持久化 hook + const { + saveFile: saveSessionFile, + files: sessionFiles, + readFile: readSessionFile, + meta: sessionMeta, + } = useSessionFiles({ + sessionId, + theme: mappedTheme, + creationMode, + autoInit: true, + }); + + const syncResourceProjectSelection = useCallback( + (targetProjectId: string | null | undefined) => { + const normalizedProjectId = normalizeProjectId(targetProjectId); + if (!normalizedProjectId) { + return; + } + + setStoredResourceProjectId(normalizedProjectId, { + source: "general-chat", + emitEvent: true, + }); + }, + [], + ); + + const ensureGeneralResourceHashes = useCallback( + async (targetProjectId: string) => { + const existingHashes = + generalResourceHashesRef.current.get(targetProjectId); + if (existingHashes) { + return existingHashes; + } + + const nextHashes = new Set(); + + try { + const materials = await listMaterials(targetProjectId); + materials.forEach((material) => { + const hash = extractGeneralChatResourceHash(material); + if (hash) { + nextHashes.add(hash); + } + }); + } catch (error) { + console.warn("[AgentChatPage] 读取资源去重缓存失败:", error); + } + + generalResourceHashesRef.current.set(targetProjectId, nextHashes); + return nextHashes; + }, + [], + ); + + const resolveGeneralArtifactSyncPath = useCallback( + async (rawFilePath: string): Promise => { + const normalizedFilePath = rawFilePath.trim(); + if (!normalizedFilePath) { + return null; + } + + if ( + normalizedFilePath.startsWith("/") || + normalizedFilePath.startsWith("~/") || + normalizedFilePath.startsWith("\\\\") || + /^[A-Za-z]:[\\/]/.test(normalizedFilePath) + ) { + return normalizedFilePath; + } + + if (sessionId) { + try { + return await resolveSessionFilePath(sessionId, normalizedFilePath); + } catch (error) { + console.warn("[AgentChatPage] 解析会话文件路径失败:", error); + } + } + + return ( + resolveAbsoluteWorkspacePath(project?.rootPath, normalizedFilePath) || + null + ); + }, + [project?.rootPath, sessionId], + ); + + const syncGeneralArtifactToResource = useCallback( + async (input: { rawFilePath: string; preferredName?: string }) => { + if (activeTheme !== "general") { + return; + } + + const normalizedProjectId = normalizeProjectId(projectId); + const normalizedRawFilePath = input.rawFilePath.trim(); + if (!normalizedProjectId || !normalizedRawFilePath) { + return; + } + + const materialType = inferGeneralChatResourceMaterialType( + normalizedRawFilePath, + ); + if (!materialType) { + return; + } + + const resolvedFilePath = await resolveGeneralArtifactSyncPath( + normalizedRawFilePath, + ); + const normalizedResolvedFilePath = resolvedFilePath?.trim(); + if (!normalizedResolvedFilePath) { + return; + } + + const pathHash = buildGeneralChatResourceHash(normalizedResolvedFilePath); + const dedupeKey = `${normalizedProjectId}:${pathHash}`; + if (generalResourceSyncInFlightRef.current.has(dedupeKey)) { + return; + } + + const knownHashes = + await ensureGeneralResourceHashes(normalizedProjectId); + if (knownHashes.has(pathHash)) { + return; + } + + generalResourceSyncInFlightRef.current.add(dedupeKey); + try { + await uploadMaterial({ + projectId: normalizedProjectId, + name: + input.preferredName?.trim() || + extractFileNameFromPath(normalizedResolvedFilePath), + type: materialType, + filePath: normalizedResolvedFilePath, + tags: buildGeneralChatResourceTags( + normalizedResolvedFilePath, + sessionId, + ), + description: buildGeneralChatResourceDescription(sessionId), + }); + + knownHashes.add(pathHash); + syncResourceProjectSelection(normalizedProjectId); + } catch (error) { + console.warn("[AgentChatPage] 自动补录资源失败:", error); + } finally { + generalResourceSyncInFlightRef.current.delete(dedupeKey); + } + }, + [ + activeTheme, + ensureGeneralResourceHashes, + projectId, + resolveGeneralArtifactSyncPath, + sessionId, + syncResourceProjectSelection, + ], + ); + + useEffect(() => { + if (activeTheme !== "general") { + return; + } + + syncResourceProjectSelection(projectId); + }, [activeTheme, projectId, syncResourceProjectSelection]); + + // 监听画布状态变化,自动同步到 Content + useEffect(() => { + if (!canvasState || !contentId) { + return; + } + + try { + const content = serializeCanvasStateForSync(canvasState); + if (isSyncContentEmpty(content)) { + return; + } + + const previousRequest = lastCanvasSyncRequestRef.current; + if ( + previousRequest?.contentId === contentId && + previousRequest.body === content + ) { + return; + } + + lastCanvasSyncRequestRef.current = { contentId, body: content }; + syncContent(contentId, content); + } catch (error) { + console.error("提取画布内容失败:", error); + } + }, [canvasState, contentId, syncContent]); + + // 追踪已恢复元数据和文件的会话 ID + const restoredMetaSessionId = useRef(null); + const restoredFilesSessionId = useRef(null); + // 用于追踪是否已触发过 AI 引导 + const hasTriggeredGuide = useRef(false); + const consumedInitialPromptRef = useRef(null); + + // 当 sessionMeta 加载完成时,恢复主题和创建模式 + useEffect(() => { + if (!sessionId || !sessionMeta) { + return; + } + + // 检查 sessionMeta 是否属于当前 sessionId + if (sessionMeta.sessionId !== sessionId) { + return; + } + + // 避免重复恢复 + if (restoredMetaSessionId.current === sessionId) { + return; + } + + console.log("[AgentChatPage] 恢复会话元数据:", sessionId, sessionMeta); + + // 从会话元数据恢复主题(类型已统一,直接使用) + if (sessionMeta.theme && (!lockTheme || !initialTheme)) { + // 通用对话入口(initialTheme 为空或 "general")不应恢复为内容创作主题, + // 避免切换历史任务时错误激活社媒等创作模式 + const entryIsGeneral = !initialTheme || initialTheme === "general"; + const restoredIsCreation = isContentCreationTheme(sessionMeta.theme); + if (entryIsGeneral && restoredIsCreation) { + console.log( + "[AgentChatPage] 通用对话入口,跳过恢复内容创作主题:", + sessionMeta.theme, + ); + } else { + console.log("[AgentChatPage] 恢复主题:", sessionMeta.theme); + setActiveTheme(sessionMeta.theme); + } + } + + // 从会话元数据恢复创建模式 + if (sessionMeta.creationMode) { + console.log("[AgentChatPage] 恢复创建模式:", sessionMeta.creationMode); + setCreationMode(sessionMeta.creationMode as CreationMode); + } + + restoredMetaSessionId.current = sessionId; + }, [sessionId, sessionMeta, lockTheme, initialTheme]); + + // 当 sessionFiles 加载完成时,恢复文件到 taskFiles + useEffect(() => { + if (!sessionId || sessionFiles.length === 0) { + return; + } + + // 避免重复恢复 + if (restoredFilesSessionId.current === sessionId) { + return; + } + + // 如果当前已有 taskFiles,说明是本次会话新生成的文件,不需要从持久化恢复 + if (taskFiles.length > 0) { + restoredFilesSessionId.current = sessionId; + return; + } + + console.log( + "[AgentChatPage] 开始恢复文件:", + sessionId, + sessionFiles.length, + "个文件", + ); + + // 恢复文件到 taskFiles + const restoreFiles = async () => { + const restoredFiles: TaskFile[] = []; + + for (const file of sessionFiles) { + try { + const content = await readSessionFile(file.name); + if (content) { + restoredFiles.push({ + id: crypto.randomUUID(), + name: file.name, + type: normalizeSessionTaskFileType( + file.fileType, + file.name, + content, + ), + content, + version: 1, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + }); + } + } catch (err) { + console.error("[AgentChatPage] 恢复文件失败:", file.name, err); + } + } + + if (restoredFiles.length > 0) { + console.log( + "[AgentChatPage] 从持久化存储恢复", + restoredFiles.length, + "个文件", + ); + setTaskFiles(restoredFiles); + } + restoredFilesSessionId.current = sessionId; + }; + + restoreFiles(); + }, [sessionId, sessionFiles, readSessionFile, taskFiles.length]); + + const resetTopicLocalState = useCallback(() => { + setLayoutMode("chat"); + setCanvasState(null); + setGeneralCanvasState(DEFAULT_CANVAS_STATE); + setTaskFiles([]); + setBrowserTaskPreflight(null); + setSelectedFileId(undefined); + processedMessageIds.current.clear(); + restoredMetaSessionId.current = null; + restoredFilesSessionId.current = null; + hasTriggeredGuide.current = false; + consumedInitialPromptRef.current = null; + }, []); + + const runTopicSwitch = useCallback( + async (topicId: string) => { + const startedAt = Date.now(); + logAgentDebug("AgentChatPage", "runTopicSwitch.start", { + currentProjectId: projectId ?? null, + topicId, + }); + resetTopicLocalState(); + try { + await originalSwitchTopic(topicId); + logAgentDebug("AgentChatPage", "runTopicSwitch.success", { + durationMs: Date.now() - startedAt, + topicId, + }); + } catch (error) { + logAgentDebug( + "AgentChatPage", + "runTopicSwitch.error", + { + durationMs: Date.now() - startedAt, + error, + topicId, + }, + { level: "error" }, + ); + throw error; + } + }, + [originalSwitchTopic, projectId, resetTopicLocalState], + ); + + const switchTopic = useCallback( + async (topicId: string) => { + if (isResolvingTopicProjectRef.current) { + logAgentDebug( + "AgentChatPage", + "switchTopic.skipWhileResolving", + { topicId }, + { level: "warn", throttleMs: 1000 }, + ); + return; + } + + isResolvingTopicProjectRef.current = true; + try { + logAgentDebug("AgentChatPage", "switchTopic.start", { + currentProjectId: projectId ?? null, + externalProjectId: externalProjectId ?? null, + topicId, + }); + const decision = await resolveTopicSwitchProject({ + lockedProjectId: externalProjectId ?? null, + topicBoundProjectId: loadPersistedProjectId( + `${TOPIC_PROJECT_KEY_PREFIX}${topicId}`, + ), + lastProjectId: loadPersistedProjectId(LAST_PROJECT_ID_KEY), + loadProjectById: async (candidateProjectId) => { + const project = await getProject(candidateProjectId); + return project + ? { id: project.id, isArchived: project.isArchived } + : null; + }, + loadDefaultProject: async () => { + const project = await getDefaultProject(); + return project + ? { id: project.id, isArchived: project.isArchived } + : null; + }, + createDefaultProject: async () => { + const project = await getOrCreateDefaultProject(); + return project + ? { id: project.id, isArchived: project.isArchived } + : null; + }, + }); + logAgentDebug("AgentChatPage", "switchTopic.decision", { + createdDefault: + decision.status === "ok" ? decision.createdDefault : false, + decisionStatus: decision.status, + projectId: decision.status === "ok" ? decision.projectId : null, + topicId, + }); + + if (decision.status === "blocked") { + toast.error("该任务绑定了其他项目,请先切换到对应项目"); + return; + } + + if (decision.status === "missing") { + toast.error("未找到可用项目,请先创建项目"); + return; + } + + const targetProjectId = decision.projectId; + if (decision.createdDefault) { + toast.info("未找到可用项目,已自动创建默认项目"); + } + + savePersistedProjectId(LAST_PROJECT_ID_KEY, targetProjectId); + + const currentProjectId = normalizeProjectId(projectId); + if (currentProjectId !== targetProjectId) { + pendingTopicSwitchRef.current = { topicId, targetProjectId }; + logAgentDebug("AgentChatPage", "switchTopic.deferUntilProjectReady", { + currentProjectId, + targetProjectId, + topicId, + }); + setInternalProjectId(targetProjectId); + return; + } + + await runTopicSwitch(topicId); + } catch (error) { + console.error("[AgentChatPage] 解析任务项目失败:", error); + logAgentDebug( + "AgentChatPage", + "switchTopic.error", + { + error, + projectId: projectId ?? null, + topicId, + }, + { level: "error" }, + ); + toast.error("切换任务失败,请稍后重试"); + } finally { + isResolvingTopicProjectRef.current = false; + } + }, + [externalProjectId, projectId, runTopicSwitch], + ); + + useTrayModelShortcuts({ + providerType, + setProviderType, + model, + setModel, + activeTheme: mappedTheme, + deferInitialSync: false, + }); + + useEffect(() => { + const pending = pendingTopicSwitchRef.current; + if (!pending) { + return; + } + + const currentProjectId = normalizeProjectId(projectId); + if (currentProjectId !== pending.targetProjectId) { + return; + } + + pendingTopicSwitchRef.current = null; + logAgentDebug("AgentChatPage", "switchTopic.resumePending", { + projectId: currentProjectId, + topicId: pending.topicId, + }); + runTopicSwitch(pending.topicId).catch((error) => { + console.error("[AgentChatPage] 执行待切换任务失败:", error); + logAgentDebug( + "AgentChatPage", + "switchTopic.resumePendingError", + { + error, + projectId: currentProjectId, + topicId: pending.topicId, + }, + { level: "error" }, + ); + toast.error("加载任务失败,请重试"); + }); + }, [projectId, runTopicSwitch]); + + /** + * 从 AI 响应中提取文档内容 + * 支持多种格式: + * 1. ... 标签(推荐) + * 2. ```markdown ... ``` 代码块 + * 3. 以 # 开头的 Markdown 内容(仅非主题工作台) + */ + const extractDocumentContent = useCallback( + (content: string): string | null => { + // 1. 检查 标签 + const documentMatch = content.match(/([\s\S]*?)<\/document>/); + if (documentMatch) { + return documentMatch[1].trim(); + } + + // 2. 检查 markdown 代码块 + const markdownMatch = content.match(/```(?:markdown|md)\n([\s\S]*?)```/); + if (markdownMatch) { + return markdownMatch[1].trim(); + } + + // 3. 主题工作台:不使用启发式规则,避免误判普通回复 + if (isThemeWorkbench) { + return null; + } + + // 4. 非主题工作台:如果整个内容以 # 开头且长度超过 200 字符,认为是文档 + if (content.trim().startsWith("#") && content.length > 200) { + return content.trim(); + } + + return null; + }, + [isThemeWorkbench], + ); + + const looksLikeSerializedNovelState = useCallback((content: string) => { + const trimmed = content.trim(); + if (!trimmed) return false; + + const jsonCandidate = + trimmed.match(/^```json\s*([\s\S]*?)```$/i)?.[1] || trimmed; + + if (!(jsonCandidate.startsWith("[") || jsonCandidate.startsWith("{"))) { + return false; + } + + return ( + jsonCandidate.includes('"title"') && + (jsonCandidate.includes('"number"') || + jsonCandidate.includes('"chapters"')) + ); + }, []); + + const upsertNovelCanvasState = useCallback( + (prev: CanvasStateUnion | null, content: string) => { + if (!prev || prev.type !== "novel") { + return createInitialNovelState(content); + } + + if (looksLikeSerializedNovelState(content)) { + return createInitialNovelState(content); + } + + const targetChapterId = + prev.currentChapterId || prev.chapters[0]?.id || crypto.randomUUID(); + const now = Date.now(); + + if (prev.chapters.length === 0) { + const initialized = createInitialNovelState(content); + return { + ...initialized, + currentChapterId: initialized.chapters[0]?.id || targetChapterId, + }; + } + + return { + ...prev, + chapters: prev.chapters.map((chapter) => + chapter.id === targetChapterId + ? { + ...chapter, + content, + wordCount: countNovelWords(content), + updatedAt: now, + } + : chapter, + ), + }; + }, + [looksLikeSerializedNovelState], + ); + + // 监听 AI 消息变化,自动提取文档内容 + useEffect(() => { + if (!isContentCreationMode) return; + + // 找到最新的 assistant 消息 + const lastAssistantMsg = [...messages] + .reverse() + .find( + (msg) => + msg.role === "assistant" && + !msg.isThinking && + msg.content && + msg.purpose !== "content_review" && + msg.purpose !== "style_audit", + ); + + if (!lastAssistantMsg) return; + + // 主题工作台 fallback:仅在 AI 未使用 write_file 且画布为空时提取 + if (isThemeWorkbench) { + const hasWriteFileToolCall = lastAssistantMsg.toolCalls?.some((tc) => { + const name = (tc.name || "").toLowerCase(); + return name.includes("write") || name.includes("create_file"); + }); + if (hasWriteFileToolCall) return; + if (canvasState && !isCanvasStateEmpty(canvasState)) return; + } + + // 检查是否已处理过 + if (processedMessageIds.current.has(lastAssistantMsg.id)) return; + + // 提取文档内容 + const docContent = extractDocumentContent(lastAssistantMsg.content); + if (docContent) { + // 标记为已处理 + processedMessageIds.current.add(lastAssistantMsg.id); + + // 更新画布内容(仅文档类型画布支持流式更新) + setCanvasState((prev) => { + // 如果是海报主题,不自动更新画布 + if (mappedTheme === "poster") { + return prev; + } + + if (mappedTheme === "novel") { + return upsertNovelCanvasState(prev, docContent); + } + + if (!prev || prev.type !== "document") { + return createInitialDocumentState(docContent); + } + // 添加新版本 + const newVersion = { + id: crypto.randomUUID(), + content: docContent, + createdAt: Date.now(), + description: `AI 生成 - 版本 ${prev.versions.length + 1}`, + }; + return { + ...prev, + content: docContent, + versions: [...prev.versions, newVersion], + currentVersionId: newVersion.id, + }; + }); + + // 自动打开画布 + setLayoutMode("chat-canvas"); + } + }, [ + messages, + isContentCreationMode, + isThemeWorkbench, + extractDocumentContent, + mappedTheme, + upsertNovelCanvasState, + canvasState, + ]); + + const ensureBrowserAssistCanvasRef = useRef< + ( + sourceText: string, + options?: { + silent?: boolean; + navigationMode?: "none" | "explicit-url" | "best-effort"; + }, + ) => Promise + >(async () => false); + + const runBrowserTaskPreflight = useCallback( + async (preflight: BrowserTaskPreflight) => { + setBrowserTaskPreflight((current) => + current?.requestId === preflight.requestId + ? { + ...current, + phase: "launching", + detail: current.detail, + } + : current, + ); + + const launchInput = preflight.launchUrl || preflight.sourceText; + const navigationMode = + preflight.launchUrl && preflight.launchUrl !== preflight.sourceText + ? ("explicit-url" as const) + : ("best-effort" as const); + + try { + const launched = await ensureBrowserAssistCanvasRef.current( + launchInput, + { + silent: false, + navigationMode, + }, + ); + + setBrowserTaskPreflight((current) => { + if (current?.requestId !== preflight.requestId) { + return current; + } + + if (!launched) { + return { + ...current, + phase: "failed", + detail: + "还没有建立可用的浏览器会话。请确认本机浏览器/CDP 可用后重试。", + }; + } + + return { + ...current, + phase: "awaiting_user", + detail: + preflight.requirement === "required_with_user_step" + ? `已为你打开${preflight.platformLabel || "浏览器协助"}。请先在右侧浏览器完成登录、扫码、验证码或授权,再继续当前任务。` + : "浏览器已经准备好。请确认右侧页面可操作后继续当前任务。", + }; + }); + } catch (error) { + setBrowserTaskPreflight((current) => { + if (current?.requestId !== preflight.requestId) { + return current; + } + + return { + ...current, + phase: "failed", + detail: + error instanceof Error && error.message + ? error.message + : "启动浏览器协助失败,请稍后重试。", + }; + }); + } + }, + [], + ); + + const handleSend = useCallback( + async ( + images?: MessageImage[], + webSearch?: boolean, + thinking?: boolean, + textOverride?: string, + sendExecutionStrategy?: "react" | "code_orchestrated" | "auto", + autoContinuePayload?: AutoContinueRequestPayload, + sendOptions?: HandleSendOptions, + ) => { + let sourceText = textOverride ?? input; + if (!sourceText.trim() && (!images || images.length === 0)) return false; + if (browserTaskPreflight && !sendOptions?.browserPreflightConfirmed) { + toast.info("请先完成当前浏览器准备后,再继续发送新的任务"); + return false; + } + const effectiveToolPreferences = + sendOptions?.toolPreferencesOverride ?? chatToolPreferences; + + const browserRequirementMatch = + mappedTheme === "general" && !sendOptions?.purpose + ? detectBrowserTaskRequirement(sourceText) + : null; + const requestedWebSearch = + webSearch ?? effectiveToolPreferences.webSearch; + const effectiveWebSearch = + browserRequirementMatch && + browserRequirementMatch.requirement !== "optional" + ? false + : requestedWebSearch; + const effectiveThinking = thinking ?? effectiveToolPreferences.thinking; + + if (!projectId) { + sendOptions?.observer?.onError?.("请先选择项目后再开始对话"); + toast.error("请先选择项目后再开始对话"); + return false; + } + + if ( + browserRequirementMatch && + !sendOptions?.browserPreflightConfirmed && + !isBrowserAssistReady + ) { + const preflight: BrowserTaskPreflight = { + requestId: `${BROWSER_PREFLIGHT_REQUEST_PREFIX}${crypto.randomUUID()}`, + createdAt: Date.now(), + sourceText, + images: images || [], + webSearch, + thinking, + sendExecutionStrategy, + autoContinuePayload, + sendOptions, + requirement: browserRequirementMatch.requirement, + reason: browserRequirementMatch.reason, + phase: "launching", + launchUrl: browserRequirementMatch.launchUrl, + platformLabel: browserRequirementMatch.platformLabel, + detail: "正在尝试建立浏览器会话,请稍候...", + }; + + setInput(""); + setMentionedCharacters([]); + setBrowserTaskPreflight(preflight); + return true; + } + + if ( + isThemeWorkbench && + mappedTheme === "social-media" && + sourceText.trim() && + !sourceText.trimStart().startsWith("/") && + !sendOptions?.skipThemeSkillPrefix + ) { + sourceText = `/${SOCIAL_ARTICLE_SKILL_KEY} ${sourceText}`.trim(); + } + + let text = sourceText; + + const preparedActiveContextPrompt = contextWorkspace.enabled + ? await contextWorkspace.prepareActiveContextPrompt() + : ""; + + if (contextWorkspace.enabled && preparedActiveContextPrompt) { + const slashCommandMatch = text.match( + /^\/([a-zA-Z0-9_-]+)\s*([\s\S]*)$/, + ); + if (slashCommandMatch) { + const [, skillName, skillArgs] = slashCommandMatch; + const mergedArgs = [preparedActiveContextPrompt, skillArgs.trim()] + .filter((part) => part.length > 0) + .join("\n\n"); + text = `/${skillName} ${mergedArgs}`.trim(); + } else { + text = `${preparedActiveContextPrompt}\n\n${text}`; + } + } + + // 如果有引用的角色,注入角色信息 + if (mentionedCharacters.length > 0) { + const characterContext = mentionedCharacters + .map((char) => { + let context = `角色:${char.name}`; + if (char.description) context += `\n简介:${char.description}`; + if (char.personality) context += `\n性格:${char.personality}`; + if (char.background) context += `\n背景:${char.background}`; + return context; + }) + .join("\n\n"); + + text = `[角色上下文]\n${characterContext}\n\n[用户输入]\n${text}`; + } + + if (!sendOptions?.purpose && runtimeStyleMessagePrompt) { + text = `[本次任务风格要求]\n${runtimeStyleMessagePrompt}\n\n[用户输入]\n${text}`; + } + + if (browserRequirementMatch) { + void ensureBrowserAssistCanvasRef + .current(browserRequirementMatch.launchUrl || sourceText, { + silent: true, + navigationMode: + browserRequirementMatch.launchUrl && + browserRequirementMatch.launchUrl !== sourceText + ? "explicit-url" + : "best-effort", + }) + .catch((error) => { + console.warn( + "[AgentChatPage] 强浏览器任务发送前准备浏览器失败,继续由主流程处理:", + error, + ); + }); + } else { + preheatBrowserAssistInBackground({ + activeTheme, + sourceText, + ensureBrowserAssistCanvas: ensureBrowserAssistCanvasRef.current, + onError: (error) => { + console.warn( + "[AgentChatPage] 发送前预热浏览器协助失败,继续发送消息:", + error, + ); + }, + }); + } + + try { + const { selectedProvider, providerModels } = + await resolveSendProviderContext(); + const memoryParams = { + scope: "aster" as const, + workspaceId: projectId, + sessionId, + providerKey: providerType, + }; + const rememberedBaseModel = loadRememberedBaseModel(memoryParams); + let effectiveModel = model; + + if (effectiveThinking) { + if (!isReasoningModel(model, providerModels)) { + saveRememberedBaseModel({ + ...memoryParams, + modelId: model, + }); + } + + const thinkingResult = resolveThinkingModel({ + currentModelId: model, + models: providerModels, + }); + effectiveModel = thinkingResult.targetModelId; + + if (thinkingResult.switched) { + setModel(thinkingResult.targetModelId); + } else if ( + thinkingResult.reason === "no_variant" && + providerModels.length > 0 + ) { + const warnKey = `${providerType}:${model}`; + if (!thinkingVariantWarnedRef.current.has(warnKey)) { + thinkingVariantWarnedRef.current.add(warnKey); + toast.warning( + "当前 Provider 没有可用的 Thinking 模型,已保持原模型", + ); + } + } + } else { + const restoreResult = resolveBaseModelOnThinkingOff({ + currentModelId: model, + models: providerModels, + rememberedBaseModel, + }); + effectiveModel = restoreResult.targetModelId; + + if (restoreResult.switched) { + setModel(restoreResult.targetModelId); + } + } + + const compatibilityResult = resolveProviderModelCompatibility({ + providerType, + configuredProviderType: selectedProvider?.type, + model: effectiveModel, + }); + if (compatibilityResult.changed) { + effectiveModel = compatibilityResult.model; + if (model !== compatibilityResult.model) { + setModel(compatibilityResult.model); + } + if (compatibilityResult.reason) { + toast.warning(compatibilityResult.reason); + } + } + + if ((images?.length || 0) > 0) { + const visionResult = resolveVisionModel({ + currentModelId: effectiveModel, + models: providerModels, + }); + + if (visionResult.reason === "no_vision_model") { + toast.error( + "当前 Provider 没有可用的多模态模型,请切换到支持多模态的 Provider 或模型后再发送图片", + ); + return false; + } + + if (visionResult.reason !== "already_vision") { + const suggestedModel = visionResult.targetModelId.trim(); + toast.error( + suggestedModel + ? `当前模型 ${effectiveModel} 不支持多模态图片理解,请切换到 ${suggestedModel} 或其他支持多模态的模型后再发送图片` + : `当前模型 ${effectiveModel} 不支持多模态图片理解,请切换到支持多模态的模型后再发送图片`, + ); + return false; + } + } + + setInput(""); + setMentionedCharacters([]); // 清空引用的角色 + + const existingHarnessMetadata = extractExistingHarnessMetadata( + sendOptions?.requestMetadata, + ); + const nextSendOptions: HandleSendOptions = { + ...(sendOptions || {}), + requestMetadata: { + ...(sendOptions?.requestMetadata || {}), + harness: buildHarnessRequestMetadata({ + base: existingHarnessMetadata, + theme: mappedTheme, + creationMode, + chatMode, + webSearchEnabled: effectiveWebSearch, + thinkingEnabled: effectiveThinking, + taskModeEnabled: effectiveToolPreferences.task, + subagentModeEnabled: effectiveToolPreferences.subagent, + sessionMode: isThemeWorkbench ? "theme_workbench" : "default", + gateKey: isThemeWorkbench ? currentGate.key : undefined, + runTitle: + themeWorkbenchActiveQueueItem?.title?.trim() || undefined, + contentId: contentId || undefined, + browserRequirement: browserRequirementMatch?.requirement, + browserRequirementReason: browserRequirementMatch?.reason, + browserLaunchUrl: browserRequirementMatch?.launchUrl, + browserAssistProfileKey: + mappedTheme === "general" + ? GENERAL_BROWSER_ASSIST_PROFILE_KEY + : undefined, + preferredTeamPresetId, + selectedTeamId: selectedTeam?.id, + selectedTeamSource: selectedTeam?.source, + selectedTeamLabel, + selectedTeamSummary, + selectedTeamRoles: selectedTeam?.roles, + }), + }, + }; + + if (autoContinuePayload) { + await sendMessage( + text, + images || [], + effectiveWebSearch, + effectiveThinking, + false, + sendExecutionStrategy, + effectiveModel, + autoContinuePayload, + nextSendOptions, + ); + } else { + await sendMessage( + text, + images || [], + effectiveWebSearch, + effectiveThinking, + false, + sendExecutionStrategy, + effectiveModel, + undefined, + nextSendOptions, + ); + } + + return true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + sendOptions?.observer?.onError?.(errorMessage); + console.error("[AgentChat] 发送消息失败:", error); + toast.error(`发送失败: ${errorMessage}`); + // 恢复输入内容,让用户可以重试 + setInput(sourceText); + return false; + } + }, + [ + chatToolPreferences, + browserTaskPreflight, + isBrowserAssistReady, + contextWorkspace, + input, + creationMode, + contentId, + currentGate.key, + chatMode, + isThemeWorkbench, + mentionedCharacters, + mappedTheme, + activeTheme, + model, + projectId, + preferredTeamPresetId, + selectedTeam?.id, + selectedTeam?.roles, + selectedTeam?.source, + selectedTeamLabel, + selectedTeamSummary, + providerType, + resolveSendProviderContext, + runtimeStyleMessagePrompt, + sendMessage, + sessionId, + setModel, + themeWorkbenchActiveQueueItem?.title, + ], + ); + + const handleRecommendationClick = useCallback( + (shortLabel: string, fullPrompt: string) => { + setInput(fullPrompt); + + if ( + activeTheme !== "general" || + !isTeamRuntimeRecommendation(shortLabel, fullPrompt) + ) { + return; + } + + const nextToolPreferences = chatToolPreferences.subagent + ? chatToolPreferences + : { + ...chatToolPreferences, + subagent: true, + }; + + if (!chatToolPreferences.subagent) { + setChatToolPreferences(nextToolPreferences); + } + saveChatToolPreferences(nextToolPreferences, activeTheme); + void handleSend( + [], + nextToolPreferences.webSearch, + nextToolPreferences.thinking, + fullPrompt, + executionStrategy, + undefined, + { + toolPreferencesOverride: nextToolPreferences, + }, + ); + }, + [ + activeTheme, + chatToolPreferences, + executionStrategy, + handleSend, + setChatToolPreferences, + ], + ); + + const handleSendRef = useRef(handleSend); + const webSearchPreferenceRef = useRef(chatToolPreferences.webSearch); + + useEffect(() => { + handleSendRef.current = handleSend; + }, [handleSend]); + + useEffect(() => { + webSearchPreferenceRef.current = chatToolPreferences.webSearch; + }, [chatToolPreferences.webSearch]); + + useEffect(() => { + if (!browserTaskPreflight) { + return; + } + + if (isBrowserAssistReady) { + if ( + browserTaskPreflight.phase === "launching" || + browserTaskPreflight.phase === "failed" + ) { + setBrowserTaskPreflight((current) => + current?.requestId === browserTaskPreflight.requestId + ? { + ...current, + phase: "awaiting_user", + detail: + current.requirement === "required_with_user_step" + ? `浏览器已经连接。请先在右侧完成${current.platformLabel || "目标站点"}登录、扫码或验证码,然后继续当前任务。` + : "浏览器已经连接,请确认页面可操作后继续当前任务。", + } + : current, + ); + } + return; + } + + if ( + browserTaskPreflight.phase === "awaiting_user" || + browserTaskPreflight.phase === "ready_to_resume" + ) { + setBrowserTaskPreflight((current) => + current?.requestId === browserTaskPreflight.requestId + ? { + ...current, + phase: "failed", + detail: "浏览器会话已断开,请重新启动浏览器后再继续。", + } + : current, + ); + } + }, [browserTaskPreflight, isBrowserAssistReady]); + + const handlePermissionResponseWithBrowserPreflight = useCallback( + async (response: { + requestId: string; + confirmed: boolean; + response?: string; + actionType?: "tool_confirmation" | "ask_user" | "elicitation"; + userData?: unknown; + }) => { + if ( + !browserTaskPreflight || + response.requestId !== browserTaskPreflight.requestId + ) { + await handlePermissionResponse(response); + return; + } + + const userData = + response.userData && typeof response.userData === "object" + ? (response.userData as Record) + : null; + const browserAction = + typeof userData?.browserAction === "string" + ? userData.browserAction + : ""; + + if (browserAction === "launch") { + await runBrowserTaskPreflight(browserTaskPreflight); + return; + } + + if (browserAction === "continue") { + if (!isBrowserAssistReady) { + setBrowserTaskPreflight((current) => + current?.requestId === browserTaskPreflight.requestId + ? { + ...current, + phase: "failed", + detail: "尚未检测到可用的浏览器会话,请先启动或恢复浏览器。", + } + : current, + ); + toast.error("浏览器还没有准备好,请先完成启动或恢复浏览器"); + return; + } + + const pending = browserTaskPreflight; + setBrowserTaskPreflight(null); + await handleSendRef.current( + pending.images, + pending.webSearch, + pending.thinking, + pending.sourceText, + pending.sendExecutionStrategy, + pending.autoContinuePayload, + { + ...(pending.sendOptions || {}), + browserPreflightConfirmed: true, + }, + ); + return; + } + + await handlePermissionResponse(response); + }, + [ + browserTaskPreflight, + handlePermissionResponse, + isBrowserAssistReady, + runBrowserTaskPreflight, + ], + ); + + const handleDocumentThinkingEnabledChange = useCallback( + (enabled: boolean) => { + setChatToolPreferences((previous) => + previous.thinking === enabled + ? previous + : { + ...previous, + thinking: enabled, + }, + ); + }, + [], + ); + + const handleDocumentAutoContinueRun = useCallback( + async (payload: AutoContinueRunPayload) => { + await handleSendRef.current( + [], + webSearchPreferenceRef.current, + payload.thinkingEnabled, + payload.prompt, + undefined, + { + enabled: payload.settings.enabled, + fast_mode_enabled: payload.settings.fastModeEnabled, + continuation_length: payload.settings.continuationLength, + sensitivity: payload.settings.sensitivity, + source: "theme_workbench_document_auto_continue", + }, + ); + }, + [], + ); + + const handleDocumentContentReviewRun = useCallback( + async (payload: ContentReviewRunPayload) => { + return await new Promise((resolve, reject) => { + void handleSendRef + .current( + [], + webSearchPreferenceRef.current, + payload.thinkingEnabled, + payload.prompt, + undefined, + undefined, + { + skipThemeSkillPrefix: true, + purpose: "content_review", + observer: { + onComplete: resolve, + onError: (message) => reject(new Error(message)), + }, + }, + ) + .catch((error) => { + reject(error instanceof Error ? error : new Error(String(error))); + }); + }); + }, + [], + ); + + const handleDocumentTextStylizeRun = useCallback( + async (payload: TextStylizeRunPayload) => { + return await new Promise((resolve, reject) => { + void handleSendRef + .current( + [], + webSearchPreferenceRef.current, + payload.thinkingEnabled, + payload.prompt, + undefined, + undefined, + { + skipThemeSkillPrefix: true, + purpose: "text_stylize", + observer: { + onComplete: resolve, + onError: (message) => reject(new Error(message)), + }, + }, + ) + .catch((error) => { + reject(error instanceof Error ? error : new Error(String(error))); + }); + }); + }, + [], + ); + + // 监听主题工作台技能触发 + useEffect(() => { + if (!pendingSkillKey || !isThemeWorkbench) { + return; + } + + // 立即消费,避免重复触发 + consumePendingSkill(); + + // 触发技能命令 + const command = `/${pendingSkillKey}`; + console.log("[AgentChatPage] 执行技能命令:", command); + handleSend([], false, false, command); + }, [pendingSkillKey, isThemeWorkbench, consumePendingSkill, handleSend]); + + const handleClearMessages = useCallback(() => { + clearMessages(); + setInput(""); + setSelectedText(""); + setBrowserTaskPreflight(null); + // 重置布局模式 + setLayoutMode("chat"); + autoCollapsedTopicSidebarRef.current = false; + setShowSidebar(defaultTopicSidebarVisible); + // 清理画布和文件状态 + setCanvasState(null); + setGeneralCanvasState(DEFAULT_CANVAS_STATE); + setTaskFiles([]); + setSelectedFileId(undefined); + processedMessageIds.current.clear(); + pendingTopicSwitchRef.current = null; + isResolvingTopicProjectRef.current = false; + }, [clearMessages, defaultTopicSidebarVisible]); + + const handleSwitchBranchVersion = useCallback( + (versionId: string) => { + setCanvasState((previous) => { + if (!previous || previous.type !== "document") { + return previous; + } + + const targetVersion = previous.versions.find( + (version) => version.id === versionId, + ); + if (!targetVersion) { + return previous; + } + + return { + ...previous, + currentVersionId: targetVersion.id, + content: targetVersion.content, + }; + }); + }, + [setCanvasState], + ); + + const handleCreateVersionSnapshot = useCallback(() => { + setCanvasState((previous) => { + if (!previous || previous.type !== "document") { + toast.info("当前没有可管理的文稿版本"); + return previous; + } + + const content = previous.content.trim(); + if (!content) { + toast.info("主稿为空,无法创建版本快照"); + return previous; + } + + const nextIndex = previous.versions.length + 1; + const newVersion = { + id: crypto.randomUUID(), + content: previous.content, + createdAt: Date.now(), + description: `手动快照 - 版本 ${nextIndex}`, + }; + + toast.success("已创建版本快照"); + return { + ...previous, + versions: [...previous.versions, newVersion], + currentVersionId: newVersion.id, + }; + }); + }, [setCanvasState]); + + const handleSetBranchStatus = useCallback( + ( + topicId: string, + status: "in_progress" | "pending" | "merged" | "candidate", + ) => { + setTopicStatus(topicId, status); + if (status === "merged") { + toast.success("已将该版本标记为主稿"); + } else if (status === "pending") { + toast.info("已将该版本标记为待评审"); + } + }, + [setTopicStatus], + ); + + const handleAddImage = useCallback(async () => { + try { + const selected = await openDialog({ + multiple: false, + filters: [ + { + name: "图片", + extensions: ["jpg", "jpeg", "png", "gif", "webp"], + }, + ], + }); + + if (!selected) { + return; + } + + const filePath = selected; + if (!filePath) { + toast.error("未选择文件"); + return; + } + + if (!sessionId) { + toast.error("会话未就绪"); + return; + } + + toast.info("正在上传图片..."); + + // 上传图片到会话 + const imageUrl = await uploadImageToSession(sessionId, filePath); + + // 插入图片到文档 + setCanvasState((previous) => { + if (!previous || previous.type !== "document") { + toast.error("当前不在文档编辑模式"); + return previous; + } + + const fileName = filePath.split(/[\\/]/).pop() || "image"; + const imageMarkdown = `\n\n![${fileName}](${imageUrl})\n\n`; + + return { + ...previous, + content: previous.content + imageMarkdown, + }; + }); + + toast.success("图片已添加"); + } catch (error) { + console.error("添加图片失败:", error); + toast.error(error instanceof Error ? error.message : "添加图片失败"); + } + }, [sessionId, setCanvasState]); + + const handleImportDocument = useCallback(async () => { + try { + const selected = await openDialog({ + multiple: false, + filters: [ + { + name: "文档", + extensions: ["md", "txt"], + }, + ], + }); + + if (!selected) { + return; + } + + const filePath = selected; + if (!filePath) { + toast.error("未选择文件"); + return; + } + + toast.info("正在导入文稿..."); + + // 调用后端解析接口 + const content = await importDocument(filePath); + + // 加载到文档 + setCanvasState((previous) => { + if (!previous || previous.type !== "document") { + toast.error("当前不在文档编辑模式"); + return previous; + } + + return { + ...previous, + content: content, + }; + }); + + toast.success("文稿已导入"); + } catch (error) { + console.error("导入文稿失败:", error); + toast.error(error instanceof Error ? error.message : "导入文稿失败"); + } + }, [setCanvasState]); + + // 响应首页导航触发的新会话请求 + useEffect(() => { + if (!newChatAt) { + return; + } + + const requestKey = String(newChatAt); + if (handledNewChatRequestRef.current === requestKey) { + return; + } + handledNewChatRequestRef.current = requestKey; + + clearMessages({ + showToast: false, + }); + setInput(""); + setSelectedText(""); + setBrowserTaskPreflight(null); + setLayoutMode("chat"); + autoCollapsedTopicSidebarRef.current = false; + setShowSidebar(defaultTopicSidebarVisible); + setCanvasState(null); + setGeneralCanvasState(DEFAULT_CANVAS_STATE); + setTaskFiles([]); + setSelectedFileId(undefined); + setMentionedCharacters([]); + processedMessageIds.current.clear(); + pendingTopicSwitchRef.current = null; + isResolvingTopicProjectRef.current = false; + restoredMetaSessionId.current = null; + restoredFilesSessionId.current = null; + hasTriggeredGuide.current = false; + consumedInitialPromptRef.current = null; + + if (!externalProjectId) { + setInternalProjectId(null); + setProject(null); + setProjectMemory(null); + setActiveTheme(normalizeInitialTheme(initialTheme)); + setCreationMode(initialCreationMode ?? "guided"); + } + + const toastId = initialSessionName + ? "openclaw-agent-handoff" + : "agent-new-chat"; + const canCreateFreshSession = Boolean(projectId?.trim()); + + if (!canCreateFreshSession) { + return; + } + + void (async () => { + const newSessionId = await createFreshSession(initialSessionName); + if (newSessionId) { + toast.success( + initialSessionName + ? `已创建新任务:${initialSessionName}` + : "已创建新任务", + { id: toastId }, + ); + } else { + toast.error("创建新任务失败,请重试。", { id: toastId }); + } + })(); + }, [ + createFreshSession, + initialSessionName, + newChatAt, + clearMessages, + defaultTopicSidebarVisible, + externalProjectId, + initialTheme, + initialCreationMode, + projectId, + ]); + + const handleBackHome = useCallback(() => { + clearMessages({ + showToast: false, + }); + setInput(""); + setSelectedText(""); + setLayoutMode("chat"); + setShowSidebar(true); + setCanvasState(null); + setGeneralCanvasState(DEFAULT_CANVAS_STATE); + setTaskFiles([]); + setSelectedFileId(undefined); + processedMessageIds.current.clear(); + pendingTopicSwitchRef.current = null; + isResolvingTopicProjectRef.current = false; + setInternalProjectId(null); + setProject(null); + setProjectMemory(null); + setActiveTheme("general"); + setCreationMode("guided"); + _onNavigate?.("agent", buildHomeAgentParams()); + }, [clearMessages, _onNavigate]); + + useEffect(() => { + if (!initialDispatchKey) { + return; + } + + setBootstrapDispatchSnapshot({ + key: initialDispatchKey, + prompt: initialUserPrompt, + images: initialUserImages || [], + }); + }, [initialDispatchKey, initialUserImages, initialUserPrompt]); + + useEffect(() => { + if (messages.length > 0) { + setBootstrapDispatchSnapshot(null); + return; + } + + if (!initialDispatchKey && !isSending && queuedTurns.length === 0) { + setBootstrapDispatchSnapshot(null); + } + }, [initialDispatchKey, isSending, messages.length, queuedTurns.length]); + + const activeBootstrapDispatch = useMemo(() => { + if ( + initialDispatchKey && + ((initialUserPrompt || "").trim() || (initialUserImages || []).length > 0) + ) { + return { + key: initialDispatchKey, + prompt: initialUserPrompt, + images: initialUserImages || [], + }; + } + + return bootstrapDispatchSnapshot; + }, [ + bootstrapDispatchSnapshot, + initialDispatchKey, + initialUserImages, + initialUserPrompt, + ]); + const isBootstrapDispatchPending = + activeBootstrapDispatch !== null && + consumedInitialPromptRef.current !== activeBootstrapDispatch.key; + const shouldShowBootstrapDispatchPreview = + !shouldUseCompactThemeWorkbench && + Boolean(activeBootstrapDispatch) && + messages.length === 0 && + (isSending || queuedTurns.length > 0); + const bootstrapDispatchPreviewMessages = useMemo(() => { + if (!shouldShowBootstrapDispatchPreview || !activeBootstrapDispatch) { + return [] as Message[]; + } + + return buildInitialDispatchPreviewMessages( + activeBootstrapDispatch.key, + activeBootstrapDispatch.prompt, + activeBootstrapDispatch.images, + ); + }, [activeBootstrapDispatch, shouldShowBootstrapDispatchPreview]); + + const displayMessages = useMemo(() => { + const collapsedMessages = collapseLegacyQuestionnaireMessages(messages); + if (browserTaskPreflight) { + return [ + ...collapsedMessages, + ...buildBrowserPreflightMessages(browserTaskPreflight), + ]; + } + + if ( + collapsedMessages.length === 0 && + bootstrapDispatchPreviewMessages.length > 0 + ) { + return bootstrapDispatchPreviewMessages; + } + + return collapsedMessages; + }, [bootstrapDispatchPreviewMessages, browserTaskPreflight, messages]); + + useEffect(() => { + if (!sessionId) { + return; + } + + updateTopicSnapshot( + sessionId, + buildLiveTaskSnapshot({ + messages: displayMessages, + isSending, + pendingActionCount: pendingActions.length, + queuedTurnCount: queuedTurns.length, + workspaceError: Boolean(workspacePathMissing || workspaceHealthError), + }), + ); + }, [ + displayMessages, + isSending, + pendingActions.length, + queuedTurns.length, + sessionId, + updateTopicSnapshot, + workspaceHealthError, + workspacePathMissing, + ]); + + // 当开始对话时自动折叠侧边栏 + const hasMessages = messages.length > 0; + const hasDisplayMessages = displayMessages.length > 0; + + const handleCanvasSelectionTextChange = useCallback((text: string) => { + const normalized = text.trim().replace(/\s+/g, " "); + const nextValue = + normalized.length > 500 ? normalized.slice(0, 500) : normalized; + startTransition(() => { + setSelectedText((previous) => + previous === nextValue ? previous : nextValue, + ); + }); + }, []); + + useEffect(() => { + setSelectedText(""); + }, [activeTheme, contentId]); + + useEffect(() => { + if (!canvasState || canvasState.type !== "novel") { + setNovelChapterListCollapsed(false); + } + }, [canvasState]); + + useEffect(() => { + autoCollapsedTopicSidebarRef.current = false; + setShowSidebar(defaultTopicSidebarVisible); + }, [defaultTopicSidebarVisible]); + + useEffect(() => { + if (showChatPanel) { + setLayoutMode((previous) => + previous === "canvas" ? "chat-canvas" : previous, + ); + return; + } + + setShowSidebar(false); + + if (layoutMode === "canvas") { + return; + } + + if (layoutMode === "chat-canvas") { + setLayoutMode("canvas"); + return; + } + + const fallbackContent = "# 新文档\n\n在这里开始编写内容..."; + + if (activeTheme === "general") { + setGeneralCanvasState((previous) => ({ + ...previous, + isOpen: true, + contentType: + previous.contentType === "empty" ? "markdown" : previous.contentType, + content: previous.content || fallbackContent, + })); + } else if (!canvasState) { + const initialState = + createInitialCanvasState(mappedTheme, fallbackContent) || + createInitialDocumentState(fallbackContent); + setCanvasState(initialState); + } + + setLayoutMode("canvas"); + }, [showChatPanel, layoutMode, activeTheme, canvasState, mappedTheme]); + + useEffect(() => { + if ( + isThemeWorkbench || + activeTheme !== "general" || + layoutMode !== "chat-canvas" + ) { + setCanvasWorkbenchLayoutMode("split"); + } + }, [activeTheme, isThemeWorkbench, layoutMode]); + + useEffect(() => { + const shouldAutoHideTopicSidebar = + showChatPanel && + !isThemeWorkbench && + activeTheme === "general" && + layoutMode === "chat-canvas" && + canvasWorkbenchLayoutMode === "stacked"; + + if (shouldAutoHideTopicSidebar) { + if (showSidebar) { + autoCollapsedTopicSidebarRef.current = true; + setShowSidebar(false); + } + return; + } + + if (autoCollapsedTopicSidebarRef.current) { + autoCollapsedTopicSidebarRef.current = false; + setShowSidebar(true); + } + }, [ + activeTheme, + canvasWorkbenchLayoutMode, + isThemeWorkbench, + layoutMode, + showChatPanel, + showSidebar, + ]); + + useEffect(() => { + onHasMessagesChange?.(hasMessages); + }, [hasMessages, onHasMessagesChange]); + + // 当有可渲染主稿文件时,仅在需要时同步到画布,避免打断当前编辑 + useEffect(() => { + const renderableFiles = taskFiles.filter((file) => + isRenderableTaskFile(file, isThemeWorkbench), + ); + if (renderableFiles.length === 0) { + return; + } + + const { targetFile, nextSelectedFileId } = resolveCanvasTaskFileTarget( + renderableFiles, + selectedFileId, + ); + if (!targetFile?.content) { + return; + } + + if (nextSelectedFileId) { + setSelectedFileId((previous) => + previous === nextSelectedFileId ? previous : nextSelectedFileId, + ); + } + + if ( + shouldDeferCanvasSyncWhileEditing({ + canvasType: canvasState?.type ?? null, + editorFocused: documentEditorFocusedRef.current, + }) + ) { + return; + } + + const targetContent = targetFile.content; + setCanvasState((prev) => { + if (mappedTheme === "music") { + const sections = parseLyrics(targetContent); + if (!prev || prev.type !== "music") { + const musicState = createInitialMusicState(); + musicState.sections = sections; + const titleMatch = targetContent.match(/^#\s*(.+)$/m); + if (titleMatch) { + musicState.spec.title = titleMatch[1].trim(); + } + return musicState; + } + return { ...prev, sections }; + } + + if (mappedTheme === "novel") { + return upsertNovelCanvasState(prev, targetContent); + } + + if (!prev || prev.type !== "document") { + return createInitialDocumentState(targetContent); + } + if (prev.content === targetContent) { + return prev; + } + return { ...prev, content: targetContent }; + }); + setLayoutMode("chat-canvas"); + }, [ + taskFiles, + isThemeWorkbench, + mappedTheme, + upsertNovelCanvasState, + selectedFileId, + canvasState?.type, + ]); + + const handleToggleSidebar = useCallback(() => { + if (!showChatPanel) { + return; + } + setShowSidebar((prev) => !prev); + }, [showChatPanel]); + + const handleToggleNovelChapterList = useCallback(() => { + setNovelChapterListCollapsed((prev) => !prev); + }, []); + + const handleAddNovelChapter = useCallback(() => { + setCanvasState((prev) => { + if (!prev || prev.type !== "novel") { + return prev; + } + + const now = Date.now(); + const chapterNumber = prev.chapters.length + 1; + const title = `第${chapterNumber}章`; + const newChapter = { + id: crypto.randomUUID(), + number: chapterNumber, + title, + content: `# ${title}\n\n`, + wordCount: 0, + status: "draft" as const, + createdAt: now, + updatedAt: now, + }; + + return { + ...prev, + chapters: [...prev.chapters, newChapter], + currentChapterId: newChapter.id, + }; + }); + setNovelChapterListCollapsed(false); + }, []); + + // 切换画布显示 + const handleToggleCanvas = useCallback(() => { + // General 主题使用专门的画布 + if (activeTheme === "general") { + setGeneralCanvasState((prev) => ({ + ...prev, + isOpen: !prev.isOpen, + contentType: + prev.contentType === "empty" ? "markdown" : prev.contentType, + content: prev.content || "# 新文档\n\n在这里开始编写内容...", + })); + setLayoutMode((prev) => (prev === "chat" ? "chat-canvas" : "chat")); + return; + } + + setLayoutMode((prev) => { + if (prev === "chat") { + // 打开画布时,如果没有画布状态则创建初始状态 + if (!canvasState) { + const initialState = + createInitialCanvasState( + mappedTheme, + "# 新文档\n\n在这里开始编写内容...", + ) || + createInitialDocumentState("# 新文档\n\n在这里开始编写内容..."); + setCanvasState(initialState); + } + return "chat-canvas"; + } + return "chat"; + }); + }, [canvasState, mappedTheme, activeTheme]); + + // 关闭画布 + const handleCloseCanvas = useCallback(() => { + setLayoutMode("chat"); + setNovelChapterListCollapsed(false); + // General 主题关闭画布状态 + if (activeTheme === "general") { + setGeneralCanvasState((prev) => ({ ...prev, isOpen: false })); + } + }, [activeTheme]); + + const resolvedCanvasState = useMemo(() => { + if (canvasState) { + return canvasState; + } + + if (shouldBootstrapCanvasOnEntry) { + return ( + createInitialCanvasState(normalizedEntryTheme, "") || + createInitialDocumentState("") + ); + } + + if (isThemeWorkbench && isContentCreationTheme(activeTheme)) { + return ( + createInitialCanvasState(mappedTheme, "") || + createInitialDocumentState("") + ); + } + + return null; + }, [ + activeTheme, + canvasState, + isThemeWorkbench, + mappedTheme, + normalizedEntryTheme, + shouldBootstrapCanvasOnEntry, + ]); + + const showNovelNavbarControls = + layoutMode !== "chat" && resolvedCanvasState?.type === "novel"; + + const upsertGeneralArtifact = useCallback( + (artifact: Artifact) => { + setArtifacts((currentArtifacts) => + mergeArtifacts([...currentArtifacts, artifact]), + ); + }, + [setArtifacts], + ); + + useEffect(() => { + if ( + activeTheme !== "general" || + !liveArtifact || + !settledLiveArtifact || + liveArtifact === settledLiveArtifact + ) { + return; + } + + upsertGeneralArtifact(settledLiveArtifact); + }, [activeTheme, liveArtifact, settledLiveArtifact, upsertGeneralArtifact]); + + const commitBrowserAssistSessionState = useCallback( + (candidate: BrowserAssistSessionState | null) => { + if (activeTheme !== "general" || !candidate) { + return; + } + + setBrowserAssistSessionState((current) => { + const next = mergeBrowserAssistSessionStates(current, candidate); + return areBrowserAssistSessionStatesEqual(current, next) + ? current + : next; + }); + }, + [activeTheme], + ); + + useEffect(() => { + if (activeTheme !== "general") { + setBrowserAssistSessionState(null); + return; + } + + setBrowserAssistSessionState( + loadBrowserAssistSessionState(projectId, sessionId), + ); + }, [activeTheme, browserAssistStorageKey, projectId, sessionId]); + + useEffect(() => { + if (activeTheme !== "general") { + return; + } + + commitBrowserAssistSessionState(browserAssistSessionFromArtifact); + }, [ + activeTheme, + browserAssistSessionFromArtifact, + commitBrowserAssistSessionState, + ]); + + useEffect(() => { + if (activeTheme !== "general") { + return; + } + + commitBrowserAssistSessionState(latestBrowserAssistSessionFromMessages); + }, [ + activeTheme, + commitBrowserAssistSessionState, + latestBrowserAssistSessionFromMessages, + ]); + + useEffect(() => { + if (activeTheme !== "general") { + return; + } + + if (browserAssistSessionState) { + saveBrowserAssistSessionState( + projectId, + sessionId, + browserAssistSessionState, + ); + return; + } + + clearBrowserAssistSessionState(projectId, sessionId); + }, [ + activeTheme, + browserAssistSessionState, + browserAssistStorageKey, + projectId, + sessionId, + ]); + + const navigateBrowserAssistCanvasToUrl = useCallback( + async (url: string, options?: { silent?: boolean }): Promise => { + if (activeTheme !== "general" || !url.trim()) { + return false; + } + + const artifactMeta = asRecord(browserAssistArtifact?.meta); + const profileKey = + browserAssistSessionState?.profileKey || + readFirstString(artifactMeta ? [artifactMeta] : [], [ + "profileKey", + "profile_key", + ]) || + GENERAL_BROWSER_ASSIST_PROFILE_KEY; + const currentUrl = + browserAssistSessionState?.url || + readFirstString(artifactMeta ? [artifactMeta] : [], [ + "url", + "launchUrl", + ]) || + ""; + const fallbackTitle = + browserAssistSessionState?.title || + browserAssistArtifact?.title?.trim() || + "浏览器协助"; + + if (currentUrl === url) { + setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + setLayoutMode("chat-canvas"); + return true; + } + + setBrowserAssistLaunching(true); + + try { + const result = await browserExecuteAction({ + profile_key: profileKey, + backend: "cdp_direct", + action: "navigate", + args: { + action: "goto", + url, + wait_for_page_info: true, + }, + timeout_ms: 20000, + }); + + if (!result.success) { + throw new Error(result.error || "浏览器导航失败"); + } + + const resultData = asRecord(result.data); + const pageInfo = + asRecord(resultData?.page_info) || asRecord(resultData?.pageInfo); + const nextUrl = + readFirstString( + [pageInfo, resultData], + ["url", "target_url", "targetUrl"], + ) || url; + const nextTitle = + readFirstString( + [pageInfo, resultData], + ["title", "target_title", "targetTitle"], + ) || fallbackTitle; + + commitBrowserAssistSessionState( + createBrowserAssistSessionState({ + sessionId: + result.session_id || + browserAssistSessionState?.sessionId || + undefined, + profileKey: profileKey, + url: nextUrl, + title: nextTitle, + targetId: + result.target_id || + browserAssistSessionState?.targetId || + undefined, + transportKind: browserAssistSessionState?.transportKind, + lifecycleState: browserAssistSessionState?.lifecycleState || "live", + controlMode: browserAssistSessionState?.controlMode, + source: "runtime_launch", + updatedAt: Date.now(), + }), + ); + setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + setLayoutMode("chat-canvas"); + + if (!options?.silent) { + toast.success(`已切换浏览器页面:${nextTitle}`); + } + return true; + } catch (error) { + if (!options?.silent) { + toast.error( + `切换浏览器页面失败: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return false; + } finally { + setBrowserAssistLaunching(false); + } + }, + [ + activeTheme, + browserAssistArtifact, + browserAssistSessionState, + commitBrowserAssistSessionState, + setSelectedArtifactId, + ], + ); + + const ensureBrowserAssistCanvas = useCallback( + async ( + sourceText: string, + options?: { + silent?: boolean; + navigationMode?: "none" | "explicit-url" | "best-effort"; + }, + ): Promise => { + if (activeTheme !== "general") { + return false; + } + + const navigationMode = options?.navigationMode || "best-effort"; + const targetUrl = + navigationMode === "explicit-url" + ? extractExplicitUrlFromText(sourceText) + : navigationMode === "best-effort" + ? resolveBrowserAssistLaunchUrl(sourceText) + : null; + const artifactMeta = asRecord(browserAssistArtifact?.meta); + const hasSessionContext = Boolean( + browserAssistSessionState?.sessionId || + browserAssistSessionState?.profileKey || + readFirstString(artifactMeta ? [artifactMeta] : [], [ + "sessionId", + "session_id", + "profileKey", + "profile_key", + ]) || + browserAssistArtifact, + ); + + if (hasSessionContext) { + setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + setLayoutMode("chat-canvas"); + if (!targetUrl) { + return true; + } + return navigateBrowserAssistCanvasToUrl(targetUrl, options); + } + + if (!targetUrl) { + return false; + } + + const browserAssistScopeKey = + currentBrowserAssistScopeKey || + resolveBrowserAssistSessionScopeKey(projectId, sessionId); + const launchKey = `${GENERAL_BROWSER_ASSIST_PROFILE_KEY}:${targetUrl}`; + if (autoLaunchingBrowserAssistKeyRef.current === launchKey) { + setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + setLayoutMode("chat-canvas"); + return true; + } + autoLaunchingBrowserAssistKeyRef.current = launchKey; + upsertGeneralArtifact( + buildPendingBrowserAssistArtifact({ + scopeKey: browserAssistScopeKey, + profileKey: GENERAL_BROWSER_ASSIST_PROFILE_KEY, + url: targetUrl, + title: "浏览器协助", + }), + ); + setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + setLayoutMode("chat-canvas"); + setBrowserAssistLaunching(true); + + try { + const result = await launchBrowserSession({ + profile_key: GENERAL_BROWSER_ASSIST_PROFILE_KEY, + url: targetUrl, + open_window: false, + stream_mode: "both", + }); + + commitBrowserAssistSessionState( + createBrowserAssistSessionState({ + sessionId: result.session.session_id, + profileKey: result.session.profile_key, + url: + result.session.last_page_info?.url?.trim() || + result.session.target_url?.trim() || + targetUrl, + title: + result.session.last_page_info?.title?.trim() || + result.session.target_title?.trim() || + "浏览器协助", + targetId: result.session.target_id, + transportKind: result.session.transport_kind, + lifecycleState: result.session.lifecycle_state, + controlMode: result.session.control_mode, + source: "runtime_launch", + updatedAt: Date.now(), + }), + ); + setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + setLayoutMode("chat-canvas"); + + if (!options?.silent) { + toast.success( + `浏览器协助已启动:${ + result.session.target_title || + result.session.target_url || + targetUrl + }`, + ); + } + return true; + } catch (error) { + upsertGeneralArtifact( + buildFailedBrowserAssistArtifact({ + scopeKey: browserAssistScopeKey, + profileKey: GENERAL_BROWSER_ASSIST_PROFILE_KEY, + url: targetUrl, + title: "浏览器协助", + error: error instanceof Error ? error.message : String(error), + }), + ); + autoLaunchingBrowserAssistKeyRef.current = ""; + if (!options?.silent) { + toast.error( + `启动浏览器协助失败: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + return false; + } finally { + setBrowserAssistLaunching(false); + } + }, + [ + activeTheme, + browserAssistArtifact, + browserAssistSessionState?.profileKey, + browserAssistSessionState?.sessionId, + commitBrowserAssistSessionState, + navigateBrowserAssistCanvasToUrl, + currentBrowserAssistScopeKey, + projectId, + sessionId, + setSelectedArtifactId, + upsertGeneralArtifact, + ], + ); + + const handleOpenBrowserAssistInCanvas = useCallback(async () => { + await ensureBrowserAssistCanvas(input, { + navigationMode: "best-effort", + }); + }, [ensureBrowserAssistCanvas, input]); + + useEffect(() => { + if ( + !openBrowserAssistOnMount || + openBrowserAssistOnMountHandledRef.current + ) { + return; + } + + openBrowserAssistOnMountHandledRef.current = true; + void ensureBrowserAssistCanvas(initialUserPrompt || "", { + navigationMode: "best-effort", + }); + }, [ensureBrowserAssistCanvas, initialUserPrompt, openBrowserAssistOnMount]); + + const handleResumeSidebarTask = useCallback( + async (topicId: string, statusReason?: TaskStatusReason) => { + if (topicId === sessionId && isResumableBrowserTaskReason(statusReason)) { + await handleOpenBrowserAssistInCanvas(); + return; + } + + await switchTopic(topicId); + }, + [handleOpenBrowserAssistInCanvas, sessionId, switchTopic], + ); + + useEffect(() => { + ensureBrowserAssistCanvasRef.current = ensureBrowserAssistCanvas; + }, [ensureBrowserAssistCanvas]); + + useEffect(() => { + if (!browserTaskPreflight || browserTaskPreflight.phase !== "launching") { + if (!browserTaskPreflight) { + browserTaskPreflightLaunchIdRef.current = ""; + } + return; + } + + if ( + browserTaskPreflightLaunchIdRef.current === browserTaskPreflight.requestId + ) { + return; + } + + browserTaskPreflightLaunchIdRef.current = browserTaskPreflight.requestId; + void runBrowserTaskPreflight(browserTaskPreflight); + }, [browserTaskPreflight, runBrowserTaskPreflight]); + + useEffect(() => { + if (activeTheme !== "general") { + autoOpenedBrowserAssistSessionIdRef.current = ""; + autoLaunchingBrowserAssistKeyRef.current = ""; + browserAssistLaunchRequestIdRef.current += 1; + return; + } + + if ( + !browserAssistSessionState?.sessionId && + !browserAssistSessionState?.profileKey + ) { + return; + } + + const artifactMeta = asRecord(browserAssistArtifact?.meta); + const currentSessionId = readFirstString( + artifactMeta ? [artifactMeta] : [], + ["sessionId", "session_id"], + ); + const currentProfileKey = readFirstString( + artifactMeta ? [artifactMeta] : [], + ["profileKey", "profile_key"], + ); + const currentUrl = readFirstString(artifactMeta ? [artifactMeta] : [], [ + "url", + "launchUrl", + ]); + const currentTargetId = readFirstString( + artifactMeta ? [artifactMeta] : [], + ["targetId", "target_id"], + ); + const currentTransportKind = readFirstString( + artifactMeta ? [artifactMeta] : [], + ["transportKind", "transport_kind"], + ); + const currentLifecycleState = readFirstString( + artifactMeta ? [artifactMeta] : [], + ["lifecycleState", "lifecycle_state"], + ); + const currentControlMode = readFirstString( + artifactMeta ? [artifactMeta] : [], + ["controlMode", "control_mode"], + ); + const currentTitle = browserAssistArtifact?.title?.trim(); + + const nextArtifact = buildBrowserAssistArtifact({ + scopeKey: + currentBrowserAssistScopeKey || + resolveBrowserAssistSessionScopeKey(projectId, sessionId), + profileKey: + browserAssistSessionState.profileKey || + currentProfileKey || + GENERAL_BROWSER_ASSIST_PROFILE_KEY, + browserSessionId: + browserAssistSessionState.sessionId || currentSessionId || "", + url: + browserAssistSessionState.url || currentUrl || "https://www.google.com", + title: browserAssistSessionState.title || currentTitle || "浏览器协助", + targetId: browserAssistSessionState.targetId || currentTargetId, + transportKind: + browserAssistSessionState.transportKind || currentTransportKind, + lifecycleState: + browserAssistSessionState.lifecycleState || currentLifecycleState, + controlMode: browserAssistSessionState.controlMode || currentControlMode, + }); + + const nextMeta = asRecord(nextArtifact.meta); + const nextSessionId = readFirstString(nextMeta ? [nextMeta] : [], [ + "sessionId", + "session_id", + ]); + const nextProfileKey = readFirstString(nextMeta ? [nextMeta] : [], [ + "profileKey", + "profile_key", + ]); + const nextUrl = readFirstString(nextMeta ? [nextMeta] : [], [ + "url", + "launchUrl", + ]); + const nextTargetId = readFirstString(nextMeta ? [nextMeta] : [], [ + "targetId", + "target_id", + ]); + const nextTransportKind = readFirstString(nextMeta ? [nextMeta] : [], [ + "transportKind", + "transport_kind", + ]); + const nextLifecycleState = readFirstString(nextMeta ? [nextMeta] : [], [ + "lifecycleState", + "lifecycle_state", + ]); + const nextControlMode = readFirstString(nextMeta ? [nextMeta] : [], [ + "controlMode", + "control_mode", + ]); + const currentScopeKey = resolveBrowserAssistArtifactScopeKey( + browserAssistArtifact, + ); + const nextScopeKey = resolveBrowserAssistArtifactScopeKey(nextArtifact); + + const shouldUpsertArtifact = + !browserAssistArtifact || + currentScopeKey !== nextScopeKey || + currentSessionId !== nextSessionId || + currentProfileKey !== nextProfileKey || + currentUrl !== nextUrl || + currentTargetId !== nextTargetId || + currentTransportKind !== nextTransportKind || + currentLifecycleState !== nextLifecycleState || + currentControlMode !== nextControlMode || + currentTitle !== nextArtifact.title; + + if (shouldUpsertArtifact) { + upsertGeneralArtifact(nextArtifact); + } + + const autoOpenKey = + browserAssistSessionState.sessionId || + `${ + browserAssistSessionState.profileKey || + GENERAL_BROWSER_ASSIST_PROFILE_KEY + }:${browserAssistSessionState.url || currentUrl || "pending"}`; + if (autoOpenedBrowserAssistSessionIdRef.current !== autoOpenKey) { + autoOpenedBrowserAssistSessionIdRef.current = autoOpenKey; + setSelectedArtifactId(nextArtifact.id); + setLayoutMode("chat-canvas"); + } + }, [ + activeTheme, + browserAssistArtifact, + currentBrowserAssistScopeKey, + browserAssistSessionState, + projectId, + sessionId, + setSelectedArtifactId, + upsertGeneralArtifact, + ]); + + useEffect(() => { + if (activeTheme !== "general") { + autoLaunchingBrowserAssistKeyRef.current = ""; + browserAssistLaunchRequestIdRef.current += 1; + return; + } + + if ( + !browserAssistSessionState?.sessionId && + !browserAssistSessionState?.profileKey + ) { + return; + } + + const nextSessionId = browserAssistSessionState.sessionId || ""; + const nextProfileKey = + browserAssistSessionState.profileKey || + GENERAL_BROWSER_ASSIST_PROFILE_KEY; + const nextUrl = browserAssistSessionState.url || "https://www.google.com"; + const nextTitle = browserAssistSessionState.title || "浏览器协助"; + + if (nextSessionId || !nextProfileKey || !nextUrl) { + return; + } + + const launchKey = `${nextProfileKey}:${nextUrl}`; + if (autoLaunchingBrowserAssistKeyRef.current === launchKey) { + return; + } + autoLaunchingBrowserAssistKeyRef.current = launchKey; + const browserAssistScopeKey = + currentBrowserAssistScopeKey || + resolveBrowserAssistSessionScopeKey(projectId, sessionId); + upsertGeneralArtifact( + buildPendingBrowserAssistArtifact({ + scopeKey: browserAssistScopeKey, + profileKey: nextProfileKey, + url: nextUrl, + title: nextTitle, + }), + ); + setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + setLayoutMode("chat-canvas"); + const launchRequestId = browserAssistLaunchRequestIdRef.current + 1; + browserAssistLaunchRequestIdRef.current = launchRequestId; + void (async () => { + try { + setBrowserAssistLaunching(true); + const result = await launchBrowserSession({ + profile_key: nextProfileKey, + url: nextUrl, + open_window: false, + stream_mode: "both", + }); + if (browserAssistLaunchRequestIdRef.current !== launchRequestId) { + return; + } + + commitBrowserAssistSessionState( + createBrowserAssistSessionState({ + sessionId: result.session.session_id, + profileKey: result.session.profile_key, + url: + result.session.last_page_info?.url?.trim() || + result.session.target_url?.trim() || + nextUrl, + title: + result.session.last_page_info?.title?.trim() || + result.session.target_title?.trim() || + nextTitle, + targetId: result.session.target_id, + transportKind: result.session.transport_kind, + lifecycleState: result.session.lifecycle_state, + controlMode: result.session.control_mode, + source: "runtime_launch", + updatedAt: Date.now(), + }), + ); + setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); + setLayoutMode("chat-canvas"); + } catch (error) { + upsertGeneralArtifact( + buildFailedBrowserAssistArtifact({ + scopeKey: browserAssistScopeKey, + profileKey: nextProfileKey, + url: nextUrl, + title: nextTitle, + error: error instanceof Error ? error.message : String(error), + }), + ); + autoLaunchingBrowserAssistKeyRef.current = ""; + console.warn("[AgentChatPage] 自动拉起浏览器协助实时会话失败:", error); + } finally { + if (browserAssistLaunchRequestIdRef.current === launchRequestId) { + setBrowserAssistLaunching(false); + } + } + })(); + }, [ + activeTheme, + browserAssistSessionState, + commitBrowserAssistSessionState, + currentBrowserAssistScopeKey, + projectId, + sessionId, + setSelectedArtifactId, + upsertGeneralArtifact, + ]); + + // 处理文件写入 - 同名文件更新内容,不同名文件独立保存 + const handleWriteFile = useCallback( + (content: string, fileName: string, context?: WriteArtifactContext) => { + console.log( + "[AgentChatPage] 收到文件写入:", + fileName, + content.length, + "字符", + ); + + // General 主题使用专门的画布处理 + if (activeTheme === "general") { + const existingArtifact = artifacts.find((artifact) => { + if (context?.artifactId && artifact.id === context.artifactId) { + return true; + } + + if (context?.artifact?.id && artifact.id === context.artifact.id) { + return true; + } + + return ( + typeof artifact.meta.filePath === "string" && + artifact.meta.filePath === fileName + ); + }); + const nextContent = + content.length > 0 + ? content + : context?.artifact?.content || existingArtifact?.content || ""; + const nextArtifact = context?.artifact + ? { + ...(existingArtifact || {}), + ...context.artifact, + content: nextContent, + status: + context.status || + context.artifact.status || + existingArtifact?.status || + "pending", + meta: { + ...(existingArtifact?.meta || {}), + ...context.artifact.meta, + ...(context.metadata || {}), + }, + updatedAt: Date.now(), + } + : buildArtifactFromWrite({ + filePath: fileName, + content: nextContent, + context: { + ...context, + artifact: existingArtifact, + status: + context?.status || + (nextContent.length > 0 ? "complete" : "pending"), + }, + }); + + const syncResource = () => { + if (nextArtifact.status !== "complete") { + return; + } + + void syncGeneralArtifactToResource({ + rawFilePath: resolveArtifactFilePath(nextArtifact), + preferredName: nextArtifact.title, + }); + }; + + if (nextContent.length > 0) { + void saveSessionFile(fileName, nextContent) + .then(() => { + syncResource(); + }) + .catch((error) => { + console.error("[AgentChatPage] 持久化 artifact 失败:", error); + syncResource(); + }); + } else { + syncResource(); + } + + upsertGeneralArtifact(nextArtifact); + setSelectedArtifactId(nextArtifact.id); + setArtifactViewMode(resolveDefaultArtifactViewMode(nextArtifact)); + setLayoutMode("chat-canvas"); + return; + } + + const now = Date.now(); + const nextFileType = resolveTaskFileType(fileName, content); + const activeQueueItem = themeWorkbenchActiveQueueItem; + const activeRunVersionId = activeQueueItem?.run_id?.trim() || null; + const activeRunDescription = + activeQueueItem?.title?.trim() || `产物更新 - ${fileName}`; + const socialGateKey = + currentGate.key === "idle" || + currentGate.key === "topic_select" || + currentGate.key === "write_mode" || + currentGate.key === "publish_confirm" + ? currentGate.key + : undefined; + const socialArtifact = + mappedTheme === "social-media" + ? resolveSocialMediaArtifactDescriptor({ + fileName, + gateKey: socialGateKey, + runTitle: activeRunDescription, + }) + : null; + const isThemeWorkbenchPrimaryArtifact = + !isThemeWorkbench || isThemeWorkbenchPrimaryDocumentArtifact(fileName); + const shouldApplyToMainDocument = + nextFileType === "document" && + isThemeWorkbenchPrimaryArtifact && + (!isThemeWorkbench || currentGate.key !== "topic_select"); + const effectiveDocumentVersionId = + activeRunVersionId || + ((isThemeWorkbench || mappedTheme === "social-media") && + shouldApplyToMainDocument + ? `artifact:${fileName}` + : null); + const effectiveVersionDescription = + socialArtifact?.versionLabel || activeRunDescription; + const baseVersionMetadata = + socialArtifact && shouldApplyToMainDocument + ? { + artifactId: socialArtifact.artifactId, + artifactType: socialArtifact.artifactType, + stage: socialArtifact.stage, + platform: socialArtifact.platform, + sourceFileName: fileName, + runId: activeRunVersionId || undefined, + correlationId: + effectiveDocumentVersionId || activeRunVersionId || undefined, + } + : undefined; + const existingTaskFile = taskFilesRef.current.find( + (file) => file.name === fileName, + ); + const hasTaskFileChanged = existingTaskFile?.content !== content; + + if (isThemeWorkbench && effectiveDocumentVersionId) { + const nextStatus: TopicBranchStatus = + activeQueueItem?.status === "running" ? "in_progress" : "pending"; + setDocumentVersionStatusMap((previous) => { + if (previous[effectiveDocumentVersionId] === nextStatus) { + return previous; + } + return { + ...previous, + [effectiveDocumentVersionId]: nextStatus, + }; + }); + } + + // 持久化文件到会话目录 + saveSessionFile(fileName, content).catch((err) => { + console.error("[AgentChatPage] 持久化文件失败:", err); + }); + + // 同步内容到项目(如果有 contentId,先验证存在性) + if (contentId && shouldApplyToMainDocument) { + getContent(contentId) + .then((existingContent) => { + if (existingContent) { + updateContent(contentId, { + body: content, + }).catch((err) => { + console.error("[AgentChatPage] 同步内容到项目失败:", err); + }); + } else { + console.warn( + "[AgentChatPage] contentId 对应的内容不存在,跳过同步:", + contentId, + ); + } + }) + .catch((err) => { + console.error("[AgentChatPage] 检查内容存在性失败:", err); + }); + } else if (isThemeWorkbench && !shouldApplyToMainDocument) { + console.log("[AgentChatPage] 主题工作台非成文阶段,跳过主稿写入:", { + gate: currentGate.key, + fileName, + isPrimaryArtifact: isThemeWorkbenchPrimaryArtifact, + }); + } + + // 根据文件名推进工作流步骤(使用动态映射) + const fileToStepMap = getFileToStepMap(mappedTheme); + const stepIndex = fileToStepMap[fileName]; + if ( + stepIndex !== undefined && + stepIndex === currentStepIndex && + isContentCreationMode + ) { + console.log( + "[AgentChatPage] 推进工作流步骤:", + stepIndex, + "->", + stepIndex + 1, + ); + completeStep({ + aiOutput: { fileName, preview: content.slice(0, 100) }, + }); + } + + if (socialArtifact && hasTaskFileChanged) { + activityLogger.log({ + eventType: existingTaskFile ? "file_update" : "file_create", + status: "success", + title: `${existingTaskFile ? "更新" : "生成"}${socialArtifact.versionLabel}`, + description: fileName, + workspaceId: projectId || undefined, + sessionId: sessionId || undefined, + source: "aster-chat", + correlationId: + effectiveDocumentVersionId || activeRunVersionId || fileName, + metadata: { + ...baseVersionMetadata, + stageLabel: socialArtifact.stageLabel, + isAuxiliary: socialArtifact.isAuxiliary, + }, + }); + + const stageLogKey = `${ + effectiveDocumentVersionId || socialArtifact.artifactId + }:${socialArtifact.stage}`; + if ( + !socialArtifact.isAuxiliary && + socialStageLogRef.current[stageLogKey] !== socialArtifact.stage + ) { + socialStageLogRef.current[stageLogKey] = socialArtifact.stage; + activityLogger.log({ + eventType: "step_complete", + status: "success", + title: socialArtifact.stageLabel, + description: `${socialArtifact.versionLabel}已进入版本链`, + workspaceId: projectId || undefined, + sessionId: sessionId || undefined, + source: "aster-chat", + correlationId: + effectiveDocumentVersionId || activeRunVersionId || fileName, + metadata: { + ...baseVersionMetadata, + stageLabel: socialArtifact.stageLabel, + }, + }); + } + } + + // 更新或创建文件 + setTaskFiles((prev) => { + // 查找同名文件 + const existingIndex = prev.findIndex((f) => f.name === fileName); + + if (existingIndex >= 0) { + // 同名文件存在 - 直接更新内容(不创建新版本) + const existing = prev[existingIndex]; + + // 如果内容完全相同,跳过 + if (existing.content === content) { + console.log("[AgentChatPage] 文件内容相同,跳过:", fileName); + setSelectedFileId(existing.id); + return prev; + } + + // 更新文件内容 + console.log("[AgentChatPage] 更新文件:", fileName); + const updated = [...prev]; + updated[existingIndex] = { + ...existing, + type: nextFileType, + content, + updatedAt: now, + metadata: socialArtifact + ? { + ...(existing.metadata || {}), + ...baseVersionMetadata, + stageLabel: socialArtifact.stageLabel, + versionLabel: socialArtifact.versionLabel, + } + : existing.metadata, + }; + setSelectedFileId(existing.id); + return updated; + } + + // 新文件 - 添加到列表 + console.log("[AgentChatPage] 创建新文件:", fileName); + const newFile: TaskFile = { + id: crypto.randomUUID(), + name: fileName, + type: nextFileType, + content, + version: 1, + createdAt: now, + updatedAt: now, + metadata: socialArtifact + ? { + ...baseVersionMetadata, + stageLabel: socialArtifact.stageLabel, + versionLabel: socialArtifact.versionLabel, + } + : undefined, + }; + setSelectedFileId(newFile.id); + return [...prev, newFile]; + }); + + if (!shouldApplyToMainDocument) { + return; + } + + // 更新画布内容 + setCanvasState((prev) => { + console.log("[AgentChatPage] 更新画布状态:", { + prevType: prev?.type, + mappedTheme, + contentLength: content.length, + }); + + // 海报主题不自动更新画布 + if (mappedTheme === "poster") { + return prev; + } + + // 音乐主题:解析歌词并更新 sections + if (mappedTheme === "music") { + const sections = parseLyrics(content); + if (!prev || prev.type !== "music") { + const musicState = createInitialMusicState(); + musicState.sections = sections; + // 尝试从内容中提取歌曲名称 + const titleMatch = content.match(/^#\s*(.+)$/m); + if (titleMatch) { + musicState.spec.title = titleMatch[1].trim(); + } + console.log("[AgentChatPage] 创建新音乐状态"); + return musicState; + } + // 更新现有音乐状态的 sections + return { + ...prev, + sections, + }; + } + + if (mappedTheme === "novel") { + return upsertNovelCanvasState(prev, content); + } + + // 文档类型画布 + if (!prev || prev.type !== "document") { + console.log("[AgentChatPage] 创建新文档状态"); + const initialDocumentState = createInitialDocumentState(content); + if (!effectiveDocumentVersionId) { + if (!socialArtifact) { + return initialDocumentState; + } + return { + ...initialDocumentState, + platform: + socialArtifact.platform || initialDocumentState.platform, + versions: initialDocumentState.versions.map((version) => ({ + ...version, + description: effectiveVersionDescription, + metadata: baseVersionMetadata, + })), + }; + } + if (!isThemeWorkbench && mappedTheme !== "social-media") { + return initialDocumentState; + } + return { + ...initialDocumentState, + platform: socialArtifact?.platform || initialDocumentState.platform, + versions: [ + { + id: effectiveDocumentVersionId, + content, + createdAt: now, + description: effectiveVersionDescription, + metadata: baseVersionMetadata, + }, + ], + currentVersionId: effectiveDocumentVersionId, + content, + }; + } + + if (effectiveDocumentVersionId) { + const existingIndex = prev.versions.findIndex( + (version) => version.id === effectiveDocumentVersionId, + ); + + if (existingIndex >= 0) { + const nextVersions = [...prev.versions]; + const currentVersion = nextVersions[existingIndex]; + nextVersions[existingIndex] = { + ...currentVersion, + content, + description: + currentVersion.description || effectiveVersionDescription, + metadata: { + ...(currentVersion.metadata || {}), + ...(baseVersionMetadata || {}), + }, + }; + return { + ...prev, + content, + platform: socialArtifact?.platform || prev.platform, + versions: nextVersions, + currentVersionId: effectiveDocumentVersionId, + }; + } + + const parentVersion = + prev.versions.find( + (version) => version.id === prev.currentVersionId, + ) || prev.versions[prev.versions.length - 1]; + const nextVersions = [ + ...prev.versions, + { + id: effectiveDocumentVersionId, + content, + createdAt: now, + description: effectiveVersionDescription, + metadata: { + ...(baseVersionMetadata || {}), + parentVersionId: + parentVersion && + parentVersion.id !== effectiveDocumentVersionId + ? parentVersion.id + : undefined, + parentArtifactId: parentVersion?.metadata?.artifactId, + }, + }, + ].slice(-MAX_PERSISTED_DOCUMENT_VERSIONS); + + return { + ...prev, + content, + platform: socialArtifact?.platform || prev.platform, + versions: nextVersions, + currentVersionId: effectiveDocumentVersionId, + }; + } + console.log("[AgentChatPage] 更新现有文档状态"); + return { + ...prev, + content, + platform: socialArtifact?.platform || prev.platform, + }; + }); + + // 自动打开画布显示流式内容 + setLayoutMode("chat-canvas"); + }, + [ + activeTheme, // 添加 activeTheme 依赖 + artifacts, + setArtifactViewMode, + setSelectedArtifactId, + currentGate.key, + contentId, + currentStepIndex, + isContentCreationMode, + isThemeWorkbench, + completeStep, + mappedTheme, + projectId, + saveSessionFile, + sessionId, + syncGeneralArtifactToResource, + themeWorkbenchActiveQueueItem, + upsertGeneralArtifact, + upsertNovelCanvasState, + ], + ); + + // 更新 ref,供统一聊天主链 Hook 使用 + useEffect(() => { + handleWriteFileRef.current = handleWriteFile; + }, [handleWriteFile]); + + const handleHarnessLoadFilePreview = useCallback( + async (path: string): Promise => { + const normalizedPath = path.trim(); + const createFallbackResult = ( + overrides: Partial = {}, + ): HarnessFilePreviewResult => ({ + path: normalizedPath, + content: null, + isBinary: false, + size: 0, + error: null, + ...overrides, + }); + + if (!normalizedPath) { + return createFallbackResult({ error: "文件路径为空" }); + } + + const fileName = extractFileNameFromPath(normalizedPath); + const candidateNames = [...new Set([normalizedPath, fileName])]; + + const matchedTaskFile = taskFiles.find((file) => + candidateNames.includes(file.name), + ); + if (matchedTaskFile) { + const content = matchedTaskFile.content ?? ""; + return createFallbackResult({ + path: matchedTaskFile.name, + content, + size: content.length, + }); + } + + const matchedSessionFile = sessionFiles.find((file) => + candidateNames.includes(file.name), + ); + if (matchedSessionFile) { + const content = await readSessionFile(matchedSessionFile.name); + if (content !== null) { + return createFallbackResult({ + path: matchedSessionFile.name, + content, + size: content.length, + }); + } + } + + try { + const result = await readFilePreview(normalizedPath, 64 * 1024); + + return createFallbackResult({ + path: result.path || normalizedPath, + content: result.content ?? null, + isBinary: result.isBinary ?? false, + size: result.size ?? 0, + error: result.error ?? null, + }); + } catch (error) { + return createFallbackResult({ + error: error instanceof Error ? error.message : String(error), + }); + } + }, + [readSessionFile, sessionFiles, taskFiles], + ); + + useArtifactAutoPreviewSync({ + enabled: activeTheme === "general", + artifact: currentCanvasArtifact, + loadPreview: handleHarnessLoadFilePreview, + onSyncArtifact: upsertGeneralArtifact, + }); + + const openArtifactInWorkbench = useCallback( + async (artifact: Artifact) => { + let nextArtifact = artifact; + const artifactPath = resolveArtifactFilePath(artifact); + const shouldLoadPreview = artifact.content.length === 0 && artifactPath; + + if (shouldLoadPreview) { + const preview = await handleHarnessLoadFilePreview(artifactPath); + if (preview.error) { + toast.error(`读取产物失败: ${preview.error}`); + } else if (preview.isBinary) { + toast.info("该产物为二进制文件,暂不支持在工作台预览"); + } else if (typeof preview.content === "string") { + nextArtifact = { + ...artifact, + content: preview.content, + meta: { + ...artifact.meta, + filePath: preview.path || artifactPath, + filename: + artifact.meta.filename || + extractFileNameFromPath(preview.path || artifactPath), + }, + updatedAt: Date.now(), + }; + upsertGeneralArtifact(nextArtifact); + } + } + + setSelectedArtifactId(nextArtifact.id); + setArtifactViewMode(resolveDefaultArtifactViewMode(nextArtifact)); + setLayoutMode("chat-canvas"); + }, + [ + handleHarnessLoadFilePreview, + setSelectedArtifactId, + upsertGeneralArtifact, + ], + ); + + const handleArtifactClick = useCallback( + (artifact: Artifact) => { + void openArtifactInWorkbench(artifact); + }, + [openArtifactInWorkbench], + ); + + const findArtifactForCodeBlock = useCallback( + (code: string) => { + const normalizedCode = code.replace(/\r\n/g, "\n").trimEnd(); + if (!normalizedCode) { + return undefined; + } + + return artifacts.find((artifact) => { + if (typeof artifact.content !== "string") { + return false; + } + return ( + artifact.content.replace(/\r\n/g, "\n").trimEnd() === normalizedCode + ); + }); + }, + [artifacts], + ); + + // 处理文件点击 - 在画布中显示文件内容 + const handleFileClick = useCallback( + (fileName: string, content: string) => { + console.log("[AgentChatPage] 文件点击:", fileName, "主题:", activeTheme); + + // General 主题统一走 artifact 工作台 + if (activeTheme === "general") { + const matchingArtifact = artifacts.find((artifact) => { + const artifactPath = resolveArtifactFilePath(artifact); + return ( + artifactPath === fileName || + artifact.title === extractFileNameFromPath(fileName) || + (content.trim().length > 0 && artifact.content === content) + ); + }); + const nextArtifact = + matchingArtifact || + buildArtifactFromWrite({ + filePath: fileName, + content, + context: { + source: "message_content", + status: content.length > 0 ? "complete" : "pending", + }, + }); + + if (!matchingArtifact) { + upsertGeneralArtifact(nextArtifact); + } + + void openArtifactInWorkbench(nextArtifact); + return; + } + + // 查找或创建任务文件 + const nextFileType = resolveTaskFileType(fileName, content); + setTaskFiles((prev) => { + const existingFile = prev.find((f) => f.name === fileName); + if (existingFile) { + setSelectedFileId(existingFile.id); + return prev; + } + // 如果文件不存在,添加到列表 + const newFile: TaskFile = { + id: crypto.randomUUID(), + name: fileName, + type: nextFileType, + content, + version: 1, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + setSelectedFileId(newFile.id); + return [...prev, newFile]; + }); + + if ( + !isRenderableTaskFile( + { name: fileName, type: nextFileType }, + isThemeWorkbench, + ) + ) { + toast.info("该文件为辅助产物,暂不在主稿画布渲染"); + return; + } + + // 更新画布内容 + setCanvasState((prev) => { + // 音乐主题:解析歌词并更新 sections + if (mappedTheme === "music") { + const sections = parseLyrics(content); + if (!prev || prev.type !== "music") { + const musicState = createInitialMusicState(); + musicState.sections = sections; + const titleMatch = content.match(/^#\s*(.+)$/m); + if (titleMatch) { + musicState.spec.title = titleMatch[1].trim(); + } + return musicState; + } + return { ...prev, sections }; + } + + if (mappedTheme === "novel") { + return upsertNovelCanvasState(prev, content); + } + + // 文档类型画布 + if (!prev || prev.type !== "document") { + return createInitialDocumentState(content); + } + return { + ...prev, + content, + }; + }); + + // 打开画布 + setLayoutMode("chat-canvas"); + }, + [ + activeTheme, + artifacts, + isThemeWorkbench, + mappedTheme, + openArtifactInWorkbench, + upsertGeneralArtifact, + upsertNovelCanvasState, + ], + ); + + // 处理代码块点击 - 在画布中显示代码(General 主题专用) + const handleCodeBlockClick = useCallback( + (language: string, code: string) => { + console.log("[AgentChatPage] 代码块点击:", language); + + const matchingArtifact = findArtifactForCodeBlock(code); + if (!matchingArtifact) { + console.warn( + "[AgentChatPage] 代码块未匹配到 artifact,保持内联渲染:", + language, + ); + return; + } + + console.log("[AgentChatPage] 找到匹配的 artifact:", matchingArtifact.id); + void openArtifactInWorkbench(matchingArtifact); + }, + [findArtifactForCodeBlock, openArtifactInWorkbench], + ); + + // 判断是否应该折叠代码块(当画布打开且有 artifact 时) + const shouldCollapseCodeBlocks = useMemo(() => { + if (activeTheme !== "general") return false; + if (layoutMode === "chat") return false; + // 当画布打开时折叠代码块 + return artifacts.length > 0 || generalCanvasState.isOpen; + }, [activeTheme, layoutMode, artifacts.length, generalCanvasState.isOpen]); + + const shouldCollapseCodeBlockInChat = useCallback( + (language: string, code: string) => { + if (!shouldCollapseCodeBlocks) { + return false; + } + + const normalizedLanguage = language.trim().toLowerCase(); + if ( + ["", "text", "plaintext", "plain", "txt", "markdown", "md"].includes( + normalizedLanguage, + ) + ) { + return false; + } + + return Boolean(findArtifactForCodeBlock(code)); + }, + [findArtifactForCodeBlock, shouldCollapseCodeBlocks], + ); + + // 处理任务文件点击 - 在画布中显示文件内容 + const handleTaskFileClick = useCallback( + (file: TaskFile) => { + setSelectedFileId(file.id); + + if ( + !isRenderableTaskFile(file, isThemeWorkbench) || + looksLikeSocialPublishPayload(file.content || "") || + !file.content?.trim() + ) { + toast.info("该文件为辅助产物,暂不在主稿画布渲染"); + return; + } + + const fileContent = file.content ?? ""; + + setCanvasState((prev) => { + // 音乐主题:解析歌词并更新 sections + if (mappedTheme === "music") { + const sections = parseLyrics(fileContent); + if (!prev || prev.type !== "music") { + const musicState = createInitialMusicState(); + musicState.sections = sections; + const titleMatch = fileContent.match(/^#\s*(.+)$/m); + if (titleMatch) { + musicState.spec.title = titleMatch[1].trim(); + } + return musicState; + } + return { ...prev, sections }; + } + + if (mappedTheme === "novel") { + return upsertNovelCanvasState(prev, fileContent); + } + + // 文档类型画布 + if (!prev || prev.type !== "document") { + return createInitialDocumentState(fileContent); + } + return { + ...prev, + content: fileContent, + }; + }); + // 只打开画布,不关闭文件列表(让用户自己关闭) + setLayoutMode("chat-canvas"); + }, + [isThemeWorkbench, mappedTheme, upsertNovelCanvasState], + ); + + // A2UI 表单提交处理 + const handleA2UISubmit = useCallback( + async (formData: A2UIFormData, _messageId: string) => { + console.log("[AgentChatPage] A2UI 表单提交:", formData); + + // 将表单数据格式化为用户消息 + const formattedData = Object.entries(formData) + .map(([key, value]) => { + if (Array.isArray(value)) { + return `- ${key}: ${value.join(", ")}`; + } + return `- ${key}: ${value}`; + }) + .join("\n"); + + const userMessage = `我的选择:\n${formattedData}`; + + // 发送用户消息 + await sendMessage(userMessage, [], false, false); + }, + [sendMessage], + ); + + // 包装 A2UI 表单提交,适配 Inputbar 的签名 + const handleInputbarA2UISubmit = useCallback( + (formData: A2UIFormData) => { + if (pendingPromotedA2UIActionRequest) { + const payload = buildActionRequestSubmissionPayload( + pendingPromotedA2UIActionRequest, + formData, + ); + + void handlePermissionResponseWithBrowserPreflight({ + requestId: pendingPromotedA2UIActionRequest.requestId, + confirmed: true, + actionType: pendingPromotedA2UIActionRequest.actionType, + response: payload.responseText, + userData: payload.userData, + }); + return; + } + + if (pendingLegacyQuestionnaireA2UIForm) { + const submissionPayload = buildLegacyQuestionnaireSubmissionPayload( + pendingLegacyQuestionnaireA2UIForm, + formData, + ); + + if (!submissionPayload) { + toast.info("请至少补充一项信息后再继续"); + return; + } + + void sendMessage( + submissionPayload.formattedMessage, + [], + false, + false, + false, + undefined, + undefined, + undefined, + { + requestMetadata: submissionPayload.requestMetadata, + }, + ); + return; + } + + void handleA2UISubmit(formData, ""); + }, + [ + handleA2UISubmit, + handlePermissionResponseWithBrowserPreflight, + pendingLegacyQuestionnaireA2UIForm, + pendingPromotedA2UIActionRequest, + sendMessage, + ], + ); + + // 存储 triggerAIGuide 函数引用,避免在 useEffect 依赖中包含函数 + const triggerAIGuideRef = useRef(triggerAIGuide); + triggerAIGuideRef.current = triggerAIGuide; + + // 当从项目进入且有 contentId 时,自动启动创作引导 + useEffect(() => { + if (shouldUseCompactThemeWorkbench) { + return; + } + + // 条件: + // - 有 contentId(从项目创建内容进入) + // - 没有消息(messages.length === 0) + // - 项目已加载 + // - 系统提示词已准备好 + // - 不在发送中 + // - 画布内容为空(canvasState 没有实际内容) + // - 尚未触发过引导 + const canvasEmpty = isCanvasStateEmpty(canvasState); + const pendingInitialPrompt = (initialUserPrompt || "").trim(); + const pendingInitialImages = initialUserImages || []; + const defaultGuidePrompt = + contentId && canvasEmpty && !isThemeWorkbench + ? getDefaultGuidePromptByTheme(mappedTheme) + : undefined; + + if ( + contentId && + messages.length === 0 && + project && + systemPrompt && + !isSending && + canvasEmpty + ) { + if (initialDispatchKey) { + if (consumedInitialPromptRef.current === initialDispatchKey) { + return; + } + consumedInitialPromptRef.current = initialDispatchKey; + hasTriggeredGuide.current = true; + console.log("[AgentChatPage] 自动发送首条创作意图消息"); + void (async () => { + const started = await handleSend( + pendingInitialImages, + chatToolPreferences.webSearch, + chatToolPreferences.thinking, + pendingInitialPrompt, + ); + if (!started) { + consumedInitialPromptRef.current = null; + return; + } + onInitialUserPromptConsumed?.(); + })(); + return; + } + + if (hasTriggeredGuide.current) { + return; + } + + if (defaultGuidePrompt) { + hasTriggeredGuide.current = true; + setInput((previous) => previous.trim() || defaultGuidePrompt); + return; + } + + if (isThemeWorkbench) { + if (shouldSkipThemeWorkbenchAutoGuideWithoutPrompt) { + return; + } + hasTriggeredGuide.current = true; + console.log("[AgentChatPage] 主题工作台:触发 AI 引导,创建后端工作流"); + // 同步创建后端工作流(不阻塞触发) + void (async () => { + try { + const { contentWorkflowApi } = + await import("@/lib/api/content-workflow"); + const themeForApi = + mappedTheme as import("@/lib/api/content-workflow").ThemeType; + const modeForApi = + (creationMode as import("@/lib/api/content-workflow").CreationMode) ?? + "guided"; + await contentWorkflowApi.create( + contentId!, + themeForApi, + modeForApi, + ); + console.log("[AgentChatPage] 后端工作流创建成功"); + } catch (e) { + console.warn( + "[AgentChatPage] 后端工作流创建失败(不影响主流程):", + e, + ); + } + })(); + triggerAIGuideRef.current(); + return; + } + + hasTriggeredGuide.current = true; + console.log("[AgentChatPage] 自动触发 AI 创作引导"); + triggerAIGuideRef.current(); + } + }, [ + activeTheme, + contentId, + mappedTheme, + creationMode, + messages.length, + project, + systemPrompt, + isSending, + canvasState, + initialUserPrompt, + initialUserImages, + setInput, + isThemeWorkbench, + handleSend, + chatToolPreferences, + initialDispatchKey, + onInitialUserPromptConsumed, + shouldUseCompactThemeWorkbench, + shouldSkipThemeWorkbenchAutoGuideWithoutPrompt, + ]); + + // 通用聊天场景:若带有 initialUserPrompt,则自动新建并发送首条消息 + useEffect(() => { + const pendingInitialPrompt = (initialUserPrompt || "").trim(); + const pendingInitialImages = initialUserImages || []; + if ( + shouldUseCompactThemeWorkbench || + !initialDispatchKey || + contentId || + !sessionId || + messages.length > 0 || + isSending + ) { + return; + } + + if (consumedInitialPromptRef.current === initialDispatchKey) { + return; + } + + consumedInitialPromptRef.current = initialDispatchKey; + void (async () => { + const started = await handleSend( + pendingInitialImages, + chatToolPreferences.webSearch, + chatToolPreferences.thinking, + pendingInitialPrompt, + ); + if (!started) { + consumedInitialPromptRef.current = null; + return; + } + onInitialUserPromptConsumed?.(); + })(); + }, [ + chatToolPreferences, + contentId, + handleSend, + initialDispatchKey, + initialUserPrompt, + initialUserImages, + isSending, + messages.length, + onInitialUserPromptConsumed, + sessionId, + shouldUseCompactThemeWorkbench, + ]); + + // 当 contentId 变化时重置引导状态 + useEffect(() => { + hasTriggeredGuide.current = false; + consumedInitialPromptRef.current = null; + }, [contentId]); + + // 当 contentId 变化且是主题工作台时,尝试从后端恢复工作流 + useEffect(() => { + if (!contentId || !isThemeWorkbench) return; + + void (async () => { + try { + const { contentWorkflowApi } = + await import("@/lib/api/content-workflow"); + const workflow = await contentWorkflowApi.getByContent(contentId); + if (workflow) { + const completedCount = workflow.steps.filter( + (s) => s.status === "completed" || s.status === "skipped", + ).length; + console.log( + `[AgentChatPage] 找到已有工作流: ${workflow.id},已完成步骤 ${completedCount}/${workflow.steps.length}`, + ); + } + } catch (e) { + // 查询失败不影响主流程 + console.debug("[AgentChatPage] 查询后端工作流失败:", e); + } + })(); + }, [contentId, isThemeWorkbench]); + + // 监听封面图重新生成成功事件,将占位 URL 替换为真实图片 URL + useEffect(() => { + const handler = (e: Event) => { + const { placeholder, imageUrl } = ( + e as CustomEvent + ).detail; + if (!placeholder || !imageUrl) return; + setCanvasState((prev) => { + if (!prev || prev.type !== "document") return prev; + const updatedContent = prev.content.split(placeholder).join(imageUrl); + if (updatedContent === prev.content) return prev; + return { ...prev, content: updatedContent }; + }); + }; + window.addEventListener(COVER_IMAGE_REPLACED_EVENT, handler); + return () => + window.removeEventListener(COVER_IMAGE_REPLACED_EVENT, handler); + }, []); + + // 主题工作台始终使用聊天布局与浮层输入,不走旧 EmptyState 输入流程 + const hasUnconsumedInitialDispatch = + !shouldUseCompactThemeWorkbench && isBootstrapDispatchPending; + const showChatLayout = + agentEntry === "claw" || + hasDisplayMessages || + isThemeWorkbench || + hasUnconsumedInitialDispatch || + isSending || + queuedTurns.length > 0 || + Boolean(browserTaskPreflight); + const shouldHideThemeWorkbenchInputForTheme = shouldUseCompactThemeWorkbench; + const shouldShowThemeWorkbenchFloatingInputOverlay = + isThemeWorkbench && + showChatLayout && + !shouldHideThemeWorkbenchInputForTheme; + const shouldShowThemeWorkbenchSidebarForTheme = + !shouldUseCompactThemeWorkbench; + const showThemeWorkbenchSidebar = + showChatPanel && + showSidebar && + isThemeWorkbench && + shouldShowThemeWorkbenchSidebarForTheme && + (!enableThemeWorkbenchPanelCollapse || !themeWorkbenchSidebarCollapsed); + const showThemeWorkbenchLeftExpandButton = + showChatPanel && + showSidebar && + shouldShowThemeWorkbenchSidebarForTheme && + enableThemeWorkbenchPanelCollapse && + themeWorkbenchSidebarCollapsed; + const handleThemeWorkbenchDeleteTopic = useCallback(() => {}, []); + const handleThemeWorkbenchSidebarCollapse = useCallback(() => { + setThemeWorkbenchSidebarCollapsed(true); + }, []); + const themeWorkbenchSidebarCollapseHandler = useMemo( + () => + enableThemeWorkbenchPanelCollapse + ? handleThemeWorkbenchSidebarCollapse + : undefined, + [enableThemeWorkbenchPanelCollapse, handleThemeWorkbenchSidebarCollapse], + ); + const themeWorkbenchHarnessHeaderAction = useMemo(() => { + if (!isThemeWorkbench || !socialMediaHarnessSummary) { + return null; + } + + return ( + + ); + }, [ + handleToggleHarnessPanel, + harnessPanelVisible, + isThemeWorkbench, + socialMediaHarnessSummary, + ]); + const themeWorkbenchHarnessSlot = useMemo(() => { + return null; + }, []); + const themeWorkbenchHarnessDialog = useMemo(() => { + if (!isThemeWorkbench) { + return null; + } + + return ( + + + + + + ); + }, [ + handleFileClick, + handleHarnessLoadFilePreview, + handleOpenSubagentSession, + childSubagentSessions, + harnessEnvironment, + harnessPanelVisible, + harnessState, + isThemeWorkbench, + refreshToolInventory, + compatSubagentRuntime, + toolInventory, + toolInventoryError, + toolInventoryLoading, + ]); + const themeWorkbenchSidebarNode = useMemo(() => { + if (!showThemeWorkbenchSidebar) { + return null; + } + return ( + + ); + }, [ + branchItems, + contextWorkspace.addFileContext, + contextWorkspace.addLinkContext, + contextWorkspace.addTextContext, + contextWorkspace.contextBudget, + contextWorkspace.contextSearchBlockedReason, + contextWorkspace.contextSearchError, + contextWorkspace.contextSearchLoading, + contextWorkspace.contextSearchMode, + contextWorkspace.contextSearchQuery, + contextWorkspace.setContextSearchMode, + contextWorkspace.setContextSearchQuery, + contextWorkspace.sidebarContextItems, + contextWorkspace.submitContextSearch, + contextWorkspace.toggleContextActive, + handleAddImage, + handleImportDocument, + handleCreateVersionSnapshot, + handleSetBranchStatus, + handleSwitchBranchVersion, + handleThemeWorkbenchDeleteTopic, + handleViewContextDetail, + handleLoadMoreThemeWorkbenchHistory, + handleViewThemeWorkbenchRunDetail, + selectedThemeWorkbenchRunDetail, + showThemeWorkbenchSidebar, + themeWorkbenchHarnessHeaderAction, + themeWorkbenchHarnessSlot, + themeWorkbenchCreationTaskEvents, + themeWorkbenchActivityLogs, + themeWorkbenchHistoryHasMore, + themeWorkbenchHistoryLoading, + themeWorkbenchRunDetailLoading, + themeWorkbenchSidebarCollapseHandler, + themeWorkbenchSkillDetailMap, + themeWorkbenchWorkflowSteps, + messages, + ]); + + const workflowProgressSignature = useMemo(() => { + const shouldShow = isContentCreationMode && hasMessages && steps.length > 0; + if (!shouldShow) { + return "hidden"; + } + + const stepSignature = steps + .map((step) => `${step.id}:${step.status}:${step.title}`) + .join("|"); + return `${currentStepIndex}:${stepSignature}`; + }, [isContentCreationMode, hasMessages, steps, currentStepIndex]); + + const lastWorkflowProgressSignatureRef = useRef(""); + useEffect(() => { + if (!onWorkflowProgressChange) return; + if ( + lastWorkflowProgressSignatureRef.current === workflowProgressSignature + ) { + return; + } + lastWorkflowProgressSignatureRef.current = workflowProgressSignature; + + const shouldShow = isContentCreationMode && hasMessages && steps.length > 0; + if (!shouldShow) { + onWorkflowProgressChange(null); + return; + } + + onWorkflowProgressChange({ + currentIndex: currentStepIndex, + steps: steps.map((step) => ({ + id: step.id, + title: step.title, + status: step.status, + })), + }); + }, [ + onWorkflowProgressChange, + workflowProgressSignature, + isContentCreationMode, + hasMessages, + steps, + currentStepIndex, + ]); + + useEffect(() => { + return () => { + onWorkflowProgressChange?.(null); + }; + }, [onWorkflowProgressChange]); + + const handleManageProviders = useCallback(() => { + _onNavigate?.("settings", { + tab: SettingsTabs.Providers, + }); + }, [_onNavigate]); + + const handleBackToResources = useCallback(() => { + _onNavigate?.("resources"); + }, [_onNavigate]); + + const handleProjectChange = useCallback( + (newProjectId: string) => { + if (externalProjectId) { + return; + } + pendingTopicSwitchRef.current = null; + isResolvingTopicProjectRef.current = false; + savePersistedProjectId(LAST_PROJECT_ID_KEY, newProjectId); + setInternalProjectId(newProjectId); + }, + [externalProjectId], + ); + + const handleSelectWorkspaceDirectory = useCallback(async () => { + const newPath = await openDialog({ directory: true, multiple: false }); + if (!newPath) return; + if (workspacePathMissing) { + // 发送失败场景:更新路径并重试原来的消息 + await fixWorkspacePathAndRetry(newPath); + } else if (projectId) { + // 主动健康检查发现问题:只更新路径,不需要重试 + try { + await updateProjectById(projectId, { rootPath: newPath }); + setWorkspaceHealthError(false); + toast.success("工作区目录已更新"); + } catch (err) { + toast.error( + `更新路径失败: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + }, [fixWorkspacePathAndRetry, projectId, workspacePathMissing]); + + const handleSelectCharacter = useCallback((character: Character) => { + setMentionedCharacters((prev) => { + if (prev.find((c) => c.id === character.id)) { + return prev; + } + return [...prev, character]; + }); + }, []); + + const handleToggleTaskFiles = useCallback(() => { + setTaskFilesExpanded((previous) => !previous); + }, []); + + const visibleTaskFiles = useMemo( + () => + taskFiles.filter((file) => isRenderableTaskFile(file, isThemeWorkbench)), + [taskFiles, isThemeWorkbench], + ); + + const visibleSelectedFileId = useMemo(() => { + if (!selectedFileId) { + return undefined; + } + return visibleTaskFiles.some((file) => file.id === selectedFileId) + ? selectedFileId + : undefined; + }, [selectedFileId, visibleTaskFiles]); + + const activeCanvasTaskFile = useMemo(() => { + return resolveCanvasTaskFileTarget(visibleTaskFiles, visibleSelectedFileId) + .targetFile; + }, [visibleSelectedFileId, visibleTaskFiles]); + + const styleActionContent = useMemo( + () => + extractStyleActionContent({ + activeTheme: mappedTheme, + generalCanvasState, + resolvedCanvasState, + taskFiles: visibleTaskFiles, + selectedFileId: visibleSelectedFileId, + }), + [ + generalCanvasState, + mappedTheme, + resolvedCanvasState, + visibleSelectedFileId, + visibleTaskFiles, + ], + ); + + const styleActionFileName = useMemo( + () => + resolveStyleActionFileName({ + activeTheme: mappedTheme, + generalCanvasState, + resolvedCanvasState, + taskFiles: visibleTaskFiles, + selectedFileId: visibleSelectedFileId, + }), + [ + generalCanvasState, + mappedTheme, + resolvedCanvasState, + visibleSelectedFileId, + visibleTaskFiles, + ], + ); + + const styleActionsDisabled = + !projectId || !runtimeStylePrompt || !styleActionContent.trim(); + + const handleRunStyleRewrite = useCallback(() => { + if (!styleActionContent.trim()) { + toast.error("当前画布还没有可重写的正文内容"); + return; + } + + if (!runtimeStylePrompt) { + toast.error("请先选择项目默认风格或任务风格"); + return; + } + + void handleSend( + [], + chatToolPreferences.webSearch, + chatToolPreferences.thinking, + buildStyleRewritePrompt({ + content: styleActionContent, + stylePrompt: runtimeStylePrompt, + fileName: styleActionFileName, + }), + undefined, + undefined, + { + skipThemeSkillPrefix: true, + purpose: "style_rewrite", + }, + ); + }, [ + chatToolPreferences.thinking, + chatToolPreferences.webSearch, + handleSend, + runtimeStylePrompt, + styleActionContent, + styleActionFileName, + ]); + + const handleRunStyleAudit = useCallback(() => { + if (!styleActionContent.trim()) { + toast.error("当前画布还没有可检查的正文内容"); + return; + } + + if (!runtimeStylePrompt) { + toast.error("请先选择项目默认风格或任务风格"); + return; + } + + void handleSend( + [], + chatToolPreferences.webSearch, + chatToolPreferences.thinking, + buildStyleAuditPrompt({ + content: styleActionContent, + stylePrompt: runtimeStylePrompt, + }), + undefined, + undefined, + { + skipThemeSkillPrefix: true, + purpose: "style_audit", + }, + ); + }, [ + chatToolPreferences.thinking, + chatToolPreferences.webSearch, + handleSend, + runtimeStylePrompt, + styleActionContent, + ]); + + const inputbarNode = useMemo( + () => ( + 0} + providerType={providerType} + setProviderType={setProviderType} + model={model} + setModel={setModel} + executionStrategy={executionStrategy} + setExecutionStrategy={setExecutionStrategy} + activeTheme={activeTheme} + onManageProviders={handleManageProviders} + selectedTeam={selectedTeam} + onSelectTeam={handleSelectTeam} + onEnableSuggestedTeam={handleEnableSuggestedTeam} + disabled={!projectId} + onClearMessages={handleClearMessages} + onToggleCanvas={handleToggleCanvas} + isCanvasOpen={layoutMode !== "chat"} + taskFiles={visibleTaskFiles} + selectedFileId={visibleSelectedFileId} + taskFilesExpanded={taskFilesExpanded} + onToggleTaskFiles={handleToggleTaskFiles} + onTaskFileClick={handleTaskFileClick} + overlayAccessory={ + shouldShowThemeWorkbenchFloatingInputOverlay && + showTeamWorkspaceBoard ? ( + + ) : null + } + characters={projectMemory?.characters || []} + skills={skills} + isSkillsLoading={skillsLoading} + toolStates={chatToolPreferences} + onToolStatesChange={setChatToolPreferences} + onSelectCharacter={handleSelectCharacter} + onNavigateToSettings={handleNavigateToSkillSettings} + onRefreshSkills={handleRefreshSkills} + queuedTurns={queuedTurns} + onPromoteQueuedTurn={promoteQueuedTurn} + onRemoveQueuedTurn={removeQueuedTurn} + /> + ), + [ + activeTheme, + chatToolPreferences, + currentGate, + executionStrategy, + handleClearMessages, + handleManageProviders, + handleNavigateToSkillSettings, + handleRefreshSkills, + handleSelectCharacter, + handleSend, + handleTaskFileClick, + handleToggleCanvas, + handleToggleTaskFiles, + input, + queuedTurns, + isSending, + isThemeWorkbench, + layoutMode, + model, + projectId, + projectMemory?.characters, + promoteQueuedTurn, + providerType, + removeQueuedTurn, + setExecutionStrategy, + setInput, + setModel, + setProviderType, + selectedTeam, + selectedTeamLabel, + selectedTeamSummary, + shouldShowThemeWorkbenchFloatingInputOverlay, + skills, + skillsLoading, + showTeamWorkspaceBoard, + steps, + stopSending, + visibleSelectedFileId, + visibleTaskFiles, + taskFilesExpanded, + themeWorkbenchRunState, + themeWorkbenchWorkflowSteps, + handleInputbarA2UISubmit, + childSubagentSessions, + currentSessionLatestTurnStatus, + currentSessionRuntimeStatus, + currentSessionTitle, + handleCloseCompletedTeamSessions, + handleCloseSubagentSession, + handleEnableSuggestedTeam, + handleOpenSubagentSession, + handleResumeSubagentSession, + handleReturnToParentSession, + handleSendSubagentInput, + handleSelectTeam, + handleWaitActiveTeamSessions, + handleWaitSubagentSession, + pendingA2UIForm, + sessionId, + subagentParentContext, + teamControlSummary, + teamWaitSummary, + teamActivityRefreshVersionBySessionId, + teamLiveActivityBySessionId, + teamLiveRuntimeBySessionId, + a2uiSubmissionNotice, + ], + ); + + const generalWorkbenchDialog = useMemo(() => { + if (chatMode !== "general" || isThemeWorkbench) { + return null; + } + + return ( + + + + } + onOpenSubagentSession={handleOpenSubagentSession} + onLoadFilePreview={handleHarnessLoadFilePreview} + onOpenFile={handleFileClick} + /> + + + ); + }, [ + chatMode, + chatToolPreferences, + activeRuntimeStatusTitle, + childSubagentSessions, + handleFileClick, + handleHarnessLoadFilePreview, + handleOpenSubagentSession, + harnessPanelVisible, + harnessEnvironment, + harnessState, + isSending, + isThemeWorkbench, + mappedTheme, + refreshToolInventory, + compatSubagentRuntime, + toolInventory, + toolInventoryError, + toolInventoryLoading, + ]); + + const canvasRenderTheme = useMemo( + () => + (shouldBootstrapCanvasOnEntry + ? normalizedEntryTheme + : mappedTheme) as ThemeType, + [mappedTheme, normalizedEntryTheme, shouldBootstrapCanvasOnEntry], + ); + + const shouldShowCanvasLoadingState = useMemo( + () => + (!canvasState && + (shouldBootstrapCanvasOnEntry || + isInitialContentLoading || + Boolean(initialContentLoadError))) || + (resolvedCanvasState?.type === "document" && + !resolvedCanvasState.content.trim() && + (isInitialContentLoading || Boolean(initialContentLoadError))), + [ + canvasState, + initialContentLoadError, + isInitialContentLoading, + resolvedCanvasState, + shouldBootstrapCanvasOnEntry, + ], + ); + + const canvasWorkbenchDefaultPreview = + useMemo(() => { + const workspaceRoot = project?.rootPath || null; + + if (canvasRenderTheme === "general") { + if (!generalCanvasState.isOpen || !generalCanvasState.content.trim()) { + return null; + } + + const filePath = generalCanvasState.filename?.trim() || undefined; + return { + title: filePath ? extractFileNameFromPath(filePath) : "当前画布草稿", + content: generalCanvasState.content, + filePath, + absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, filePath), + previousContent: null, + }; + } + + if (!resolvedCanvasState || isCanvasStateEmpty(resolvedCanvasState)) { + return null; + } + + const taskFile = activeCanvasTaskFile; + const taskSelectionKey = taskFile ? `task:${taskFile.id}` : undefined; + + if (resolvedCanvasState.type === "document") { + const currentVersion = + resolvedCanvasState.versions.find( + (item) => item.id === resolvedCanvasState.currentVersionId, + ) || + resolvedCanvasState.versions[ + resolvedCanvasState.versions.length - 1 + ] || + null; + const filePath = + taskFile?.name || currentVersion?.metadata?.sourceFileName; + + return { + selectionKey: + taskSelectionKey || + (currentVersion ? `version:${currentVersion.id}` : undefined), + title: filePath ? extractFileNameFromPath(filePath) : "当前文稿", + content: resolvedCanvasState.content, + filePath, + absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, filePath), + previousContent: resolvePreviousDocumentVersionContent( + currentVersion, + resolvedCanvasState.versions, + ), + }; + } + + const filePath = taskFile?.name; + return { + selectionKey: taskSelectionKey, + title: filePath ? extractFileNameFromPath(filePath) : "当前画布", + content: serializeCanvasStateForSync(resolvedCanvasState), + filePath, + absolutePath: resolveAbsoluteWorkspacePath(workspaceRoot, filePath), + previousContent: null, + }; + }, [ + activeCanvasTaskFile, + canvasRenderTheme, + generalCanvasState.content, + generalCanvasState.filename, + generalCanvasState.isOpen, + project?.rootPath, + resolvedCanvasState, + ]); + + const handleOpenCanvasWorkbenchPath = useCallback(async (path: string) => { + try { + await openPathWithDefaultApp(path); + } catch (error) { + toast.error( + `打开文件失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, []); + + const handleRevealCanvasWorkbenchPath = useCallback(async (path: string) => { + try { + await revealPathInFinder(path); + } catch (error) { + toast.error( + `定位文件失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, []); + + const renderArtifactWorkbenchPreview = useCallback( + (artifact: Artifact, stackedWorkbenchTrigger?: ReactNode) => { + const isLiveSelectedArtifact = + currentCanvasArtifact?.id === artifact.id && + displayedCanvasArtifact !== null; + const toolbarArtifact = + isLiveSelectedArtifact && currentCanvasArtifact + ? currentCanvasArtifact + : artifact; + const previewArtifact = + isLiveSelectedArtifact && displayedCanvasArtifact + ? displayedCanvasArtifact + : artifact; + const isBrowserAssistArtifact = previewArtifact.type === "browser_assist"; + + if (isBrowserAssistArtifact) { + return wrapPreviewWithWorkbenchTrigger( +
+ + {isLiveSelectedArtifact && artifactDisplayState.overlay ? ( + + ) : null} +
, + stackedWorkbenchTrigger, + ); + } + + return ( +
+
+ +
+ + {isLiveSelectedArtifact && artifactDisplayState.overlay ? ( + + ) : null} +
+
+
+ ); + }, + [ + artifactDisplayState.overlay, + artifactDisplayState.showPreviousVersionBadge, + artifactPreviewSize, + artifactViewMode, + currentCanvasArtifact, + displayedCanvasArtifact, + handleCloseCanvas, + setArtifactPreviewSize, + setArtifactViewMode, + ], + ); + + const renderLiveCanvasPreview = useCallback( + (stackedWorkbenchTrigger?: ReactNode) => { + if ( + canvasRenderTheme === "general" && + currentCanvasArtifact && + displayedCanvasArtifact + ) { + return renderArtifactWorkbenchPreview( + currentCanvasArtifact, + stackedWorkbenchTrigger, + ); + } + + if (canvasRenderTheme === "general") { + if (generalCanvasState.isOpen) { + return ( + + setGeneralCanvasState((prev) => ({ ...prev, content })) + } + toolbarActions={stackedWorkbenchTrigger} + /> + ); + } + return null; + } + + if (shouldShowCanvasLoadingState) { + return wrapPreviewWithWorkbenchTrigger( +
+ {isInitialContentLoading + ? "正在加载文稿内容..." + : initialContentLoadError || "正在准备文稿画布..."} +
, + stackedWorkbenchTrigger, + ); + } + + if (!resolvedCanvasState) { + return null; + } + + return wrapPreviewWithWorkbenchTrigger( + , + stackedWorkbenchTrigger, + ); + }, + [ + canvasRenderTheme, + chatToolPreferences.thinking, + contentId, + currentCanvasArtifact, + displayedCanvasArtifact, + generalCanvasState, + handleAddImage, + handleBackHome, + handleCloseCanvas, + handleCanvasSelectionTextChange, + handleDocumentAutoContinueRun, + handleDocumentContentReviewRun, + handleDocumentThinkingEnabledChange, + handleDocumentTextStylizeRun, + handleImportDocument, + initialContentLoadError, + isInitialContentLoading, + isSending, + model, + novelChapterListCollapsed, + preferContentReviewInRightRail, + project?.name, + projectId, + providerType, + renderArtifactWorkbenchPreview, + resolvedCanvasState, + setModel, + setProviderType, + shouldShowCanvasLoadingState, + ], + ); + + const renderCanvasWorkbenchPreview = useCallback( + ( + target: CanvasWorkbenchPreviewTarget, + options?: { + stackedWorkbenchTrigger?: ReactNode; + }, + ) => { + switch (target.kind) { + case "default-canvas": + return renderLiveCanvasPreview(options?.stackedWorkbenchTrigger); + case "artifact": + case "synthetic-artifact": + return renderArtifactWorkbenchPreview( + target.artifact, + options?.stackedWorkbenchTrigger, + ); + case "loading": + return wrapPreviewWithWorkbenchTrigger( +
+ 正在准备预览... +
, + options?.stackedWorkbenchTrigger, + ); + case "unsupported": + return wrapPreviewWithWorkbenchTrigger( +
+ {target.reason} +
, + options?.stackedWorkbenchTrigger, + ); + case "empty": + return wrapPreviewWithWorkbenchTrigger( +
+ 暂无可预览内容 +
, + options?.stackedWorkbenchTrigger, + ); + default: + return null; + } + }, + [renderArtifactWorkbenchPreview, renderLiveCanvasPreview], + ); + + const shouldRenderInlineA2UI = isContentCreationMode; + const isWorkspaceCompactChrome = topBarChrome === "workspace-compact"; + const shouldRenderBrandedEmptyState = !showChatLayout; + const shouldRenderTopBar = !hideTopBar && !shouldRenderBrandedEmptyState; + const themeWorkbenchLayoutBottomSpacing = + resolveThemeWorkbenchLayoutBottomSpacing({ + contextWorkspaceEnabled: contextWorkspace.enabled, + showFloatingInputOverlay: shouldShowThemeWorkbenchFloatingInputOverlay, + hasCanvasContent: layoutMode !== "chat", + themeWorkbenchRunState, + gateStatus: currentGate.status, + }); + + // 聊天区域内容 + const chatContent = useMemo( + () => ( + + + {entryBannerVisible && entryBannerMessage ? ( + + + {entryBannerMessage} + setEntryBannerVisible(false)} + aria-label="关闭入口提示" + > + 关闭 + + + ) : null} + {!hideInlineStepProgress && + isContentCreationMode && + hasMessages && + steps.length > 0 && ( + + )} + + {isContentCreationMode && projectId ? ( + + ) : null} + {showChatLayout ? ( + + <> + {contextWorkspace.enabled ? ( + + + + ) : ( + + )} + {showTeamWorkspaceBoard && + !shouldShowThemeWorkbenchFloatingInputOverlay ? ( + + ) : null} + + + ) : ( + { + handleSend( + images || [], + chatToolPreferences.webSearch, + chatToolPreferences.thinking, + text, + sendExecutionStrategy, + ); + }} + providerType={providerType} + setProviderType={setProviderType} + model={model} + setModel={setModel} + executionStrategy={executionStrategy} + setExecutionStrategy={setExecutionStrategy} + onManageProviders={handleManageProviders} + webSearchEnabled={chatToolPreferences.webSearch} + onWebSearchEnabledChange={(enabled) => + setChatToolPreferences((prev) => ({ + ...prev, + webSearch: enabled, + })) + } + thinkingEnabled={chatToolPreferences.thinking} + onThinkingEnabledChange={(enabled) => + setChatToolPreferences((prev) => ({ + ...prev, + thinking: enabled, + })) + } + taskEnabled={chatToolPreferences.task} + onTaskEnabledChange={(enabled) => + setChatToolPreferences((prev) => ({ + ...prev, + task: enabled, + })) + } + subagentEnabled={chatToolPreferences.subagent} + onSubagentEnabledChange={(enabled) => + setChatToolPreferences((prev) => ({ + ...prev, + subagent: enabled, + })) + } + selectedTeam={selectedTeam} + onSelectTeam={handleSelectTeam} + onEnableSuggestedTeam={handleEnableSuggestedTeam} + creationMode={creationMode} + onCreationModeChange={setCreationMode} + activeTheme={activeTheme} + onThemeChange={(theme) => { + if (!lockTheme) { + setActiveTheme(theme); + } + }} + showThemeTabs={false} + hasCanvasContent={ + activeTheme === "general" + ? artifacts.length > 0 || + Boolean(generalCanvasState.content?.trim()) + : !isCanvasStateEmpty(resolvedCanvasState) + } + hasContentId={Boolean(contentId)} + selectedText={selectedText} + onRecommendationClick={handleRecommendationClick} + characters={projectMemory?.characters || []} + skills={skills} + isSkillsLoading={skillsLoading} + onNavigateToSettings={handleNavigateToSkillSettings} + onRefreshSkills={handleRefreshSkills} + onLaunchBrowserAssist={handleOpenBrowserAssistInCanvas} + browserAssistLoading={browserAssistLaunching} + projectId={projectId ?? null} + onProjectChange={handleProjectChange} + onOpenSettings={() => { + _onNavigate?.("settings", { + tab: SettingsTabs.Appearance, + }); + }} + /> + )} + + {showChatLayout && ( + <> + {(workspacePathMissing || workspaceHealthError) && ( +
+ + 工作区目录不存在,请重新选择一个本地目录后继续 + + + +
+ )} + {!contextWorkspace.enabled && + !shouldHideThemeWorkbenchInputForTheme + ? inputbarNode + : null} + + )} +
+
+ ), + [ + _onNavigate, + activeTheme, + artifacts.length, + browserAssistLaunching, + chatToolPreferences, + contentId, + contextWorkspace.enabled, + creationMode, + currentStepIndex, + currentTurnId, + deleteMessage, + dismissWorkspacePathError, + entryBannerMessage, + entryBannerVisible, + editMessage, + executionStrategy, + generalCanvasState.content, + goToStep, + handleA2UISubmit, + handleArtifactClick, + handleCloseCompletedTeamSessions, + handleCloseSubagentSession, + handleCodeBlockClick, + handleFileClick, + handleEnableSuggestedTeam, + handleManageProviders, + handleNavigateToSkillSettings, + handleOpenBrowserAssistInCanvas, + handleOpenSubagentSession, + handleReturnToParentSession, + handleResumeSubagentSession, + handleSendSubagentInput, + handleWaitActiveTeamSessions, + handleWaitSubagentSession, + handleProjectChange, + handleRecommendationClick, + handleRefreshSkills, + handlePermissionResponseWithBrowserPreflight, + handleSelectTeam, + handleSelectWorkspaceDirectory, + handleSend, + handleWriteFile, + hideInlineStepProgress, + input, + inputbarNode, + isContentCreationMode, + isThemeWorkbench, + isWorkspaceCompactChrome, + lockTheme, + displayMessages, + model, + turns, + projectId, + projectMemory?.characters, + projectMemory?.style_guide, + providerType, + pendingPromotedA2UIActionRequest, + setCreationMode, + setExecutionStrategy, + setInput, + setModel, + setProviderType, + selectedTeam, + selectedTeamLabel, + selectedTeamSummary, + setWorkspaceHealthError, + shouldCollapseCodeBlocks, + selectedText, + setEntryBannerVisible, + showChatLayout, + effectiveThreadItems, + handleRunStyleAudit, + handleRunStyleRewrite, + hasMessages, + childSubagentSessions, + currentSessionLatestTurnStatus, + currentSessionRuntimeStatus, + currentSessionTitle, + mappedTheme, + runtimeStyleSelection, + sessionId, + showTeamWorkspaceBoard, + teamActivityRefreshVersionBySessionId, + teamLiveActivityBySessionId, + teamLiveRuntimeBySessionId, + styleActionsDisabled, + skills, + skillsLoading, + steps, + subagentParentContext, + teamControlSummary, + teamWaitSummary, + workspaceHealthError, + workspacePathMissing, + resolvedCanvasState, + shouldHideThemeWorkbenchInputForTheme, + shouldCollapseCodeBlockInChat, + shouldShowThemeWorkbenchFloatingInputOverlay, + themeWorkbenchLayoutBottomSpacing.messageViewportBottomPadding, + queuedTurns.length, + shouldRenderInlineA2UI, + ], + ); + + // 画布区域内容 + const canvasContent = useMemo(() => { + const liveCanvasPreview = renderLiveCanvasPreview(); + if (!liveCanvasPreview) { + return null; + } + + if (shouldShowCanvasLoadingState || isBrowserAssistCanvasVisible) { + return liveCanvasPreview; + } + + return ( + + ); + }, [ + canvasWorkbenchDefaultPreview, + handleHarnessLoadFilePreview, + handleOpenCanvasWorkbenchPath, + handleRevealCanvasWorkbenchPath, + project, + renderCanvasWorkbenchPreview, + renderLiveCanvasPreview, + resolvedCanvasState, + selectedFileId, + settledWorkbenchArtifacts, + shouldShowCanvasLoadingState, + isBrowserAssistCanvasVisible, + setCanvasWorkbenchLayoutMode, + taskFiles, + workspaceHealthError, + workspacePathMissing, + ]); + + const mainAreaNode = useMemo( + () => ( + + {shouldRenderTopBar && ( + <> + {}} + onBackToProjectManagement={onBackToProjectManagement} + onBackToResources={ + fromResources ? handleBackToResources : undefined + } + showCanvasToggle={!isThemeWorkbench} + isCanvasOpen={layoutMode !== "chat"} + onToggleCanvas={handleToggleCanvas} + projectId={projectId ?? null} + onProjectChange={handleProjectChange} + workspaceType={activeTheme} + onBackHome={handleBackHome} + showBrowserAssistEntry={ + chatMode === "general" && !isThemeWorkbench + } + browserAssistActive={isBrowserAssistCanvasVisible} + browserAssistLoading={browserAssistLaunching} + browserAssistAttentionLevel={browserAssistAttentionLevel} + browserAssistLabel={browserAssistEntryLabel} + onOpenBrowserAssist={() => { + void handleOpenBrowserAssistInCanvas(); + }} + showHarnessToggle={showHarnessToggle} + harnessPanelVisible={navbarHarnessPanelVisible} + onToggleHarnessPanel={handleToggleHarnessPanel} + harnessPendingCount={harnessPendingCount} + harnessAttentionLevel={harnessAttentionLevel} + harnessToggleLabel={ + chatMode === "general" && !isThemeWorkbench + ? "工作台" + : undefined + } + onToggleSettings={() => { + _onNavigate?.("settings", { + tab: SettingsTabs.Appearance, + }); + }} + novelCanvasControls={ + showNovelNavbarControls + ? { + chapterListCollapsed: novelChapterListCollapsed, + onToggleChapterList: handleToggleNovelChapterList, + onAddChapter: handleAddNovelChapter, + onCloseCanvas: handleCloseCanvas, + } + : null + } + /> + + {!isThemeWorkbench && + contentId && + syncStatus !== "idle" && + (() => { + const notice = resolveContentSyncNotice(syncStatus); + const NoticeIcon = notice.Icon; + + return ( + + + + {notice.label} + + + ); + })()} + + )} + + + + + {generalWorkbenchDialog} + {themeWorkbenchHarnessDialog} + {shouldShowThemeWorkbenchFloatingInputOverlay ? ( + + {inputbarNode} + + ) : null} + + ), + [ + _onNavigate, + activeTheme, + canvasContent, + chatContent, + browserAssistAttentionLevel, + browserAssistEntryLabel, + browserAssistLaunching, + contentId, + fromResources, + handleAddNovelChapter, + handleBackHome, + handleBackToResources, + handleCloseCanvas, + handleOpenBrowserAssistInCanvas, + handleProjectChange, + handleToggleHarnessPanel, + handleToggleNovelChapterList, + handleToggleCanvas, + handleToggleSidebar, + hideHistoryToggle, + inputbarNode, + isSending, + isWorkspaceCompactChrome, + isThemeWorkbench, + chatMode, + generalWorkbenchDialog, + harnessAttentionLevel, + isBrowserAssistCanvasVisible, + navbarHarnessPanelVisible, + harnessPendingCount, + layoutMode, + novelChapterListCollapsed, + onBackToProjectManagement, + pendingA2UIForm, + projectId, + shouldShowThemeWorkbenchFloatingInputOverlay, + showChatPanel, + showHarnessToggle, + showNovelNavbarControls, + shouldRenderTopBar, + syncStatus, + themeWorkbenchHarnessDialog, + themeWorkbenchLayoutBottomSpacing.shellBottomInset, + topBarChrome, + ], + ); + + // ========== 渲染逻辑 ========== + + // 所有主题统一使用 useAgentChatUnified / useAsterAgentChat 的状态和渲染逻辑 + // General 主题与其他主题的区别仅在于不显示步骤进度条 + return ( + + {isThemeWorkbench ? ( + themeWorkbenchSidebarNode + ) : showChatPanel && showSidebar ? ( + + ) : null} + {showThemeWorkbenchLeftExpandButton ? ( + setThemeWorkbenchSidebarCollapsed(false)} + title="展开上下文侧栏" + > + + + ) : null} + + {mainAreaNode} + + ); +} diff --git a/src/components/agent/chat/components/AgentRuntimeStrip.tsx b/src/components/agent/chat/components/AgentRuntimeStrip.tsx index 5a9f65ad4..f65ab8252 100644 --- a/src/components/agent/chat/components/AgentRuntimeStrip.tsx +++ b/src/components/agent/chat/components/AgentRuntimeStrip.tsx @@ -1,20 +1,21 @@ import React, { useMemo } from "react"; import { Badge } from "@/components/ui/badge"; -import type { SchedulerEvent, SchedulerProgress } from "@/lib/api/subAgentScheduler"; +import type { AsterSubagentSessionInfo } from "@/lib/api/agentRuntime"; import type { ChatToolPreferences } from "../utils/chatToolPreferences"; +import type { CompatSubagentRuntimeSnapshot } from "../utils/compatSubagentRuntime"; import type { HarnessSessionState } from "../utils/harnessState"; interface AgentRuntimeStripProps { activeTheme?: string; toolPreferences: ChatToolPreferences; harnessState: HarnessSessionState; - subAgentRuntime: { - isRunning: boolean; - progress: SchedulerProgress | null; - events: SchedulerEvent[]; - }; + childSubagentSessions?: AsterSubagentSessionInfo[]; + compatSubagentRuntime: Pick< + CompatSubagentRuntimeSnapshot, + "isRunning" | "progress" + >; variant?: "standalone" | "embedded"; isSending?: boolean; runtimeStatusTitle?: string | null; @@ -42,7 +43,8 @@ export const AgentRuntimeStrip: React.FC = ({ activeTheme, toolPreferences, harnessState, - subAgentRuntime, + compatSubagentRuntime, + childSubagentSessions = [], variant = "standalone", isSending = false, runtimeStatusTitle = null, @@ -67,6 +69,19 @@ export const AgentRuntimeStrip: React.FC = ({ const statusItems = useMemo(() => { const nextItems: StatusItem[] = []; + const runningTeamSessions = childSubagentSessions.filter( + (session) => session.runtime_status === "running", + ).length; + const queuedTeamSessions = childSubagentSessions.filter( + (session) => session.runtime_status === "queued", + ).length; + const activeTeamSessions = runningTeamSessions + queuedTeamSessions; + const completedTeamSessions = childSubagentSessions.filter( + (session) => + session.runtime_status === "completed" || + session.runtime_status === "failed" || + session.runtime_status === "aborted", + ).length; if (isSending) { nextItems.push({ @@ -100,12 +115,30 @@ export const AgentRuntimeStrip: React.FC = ({ }); } - if (subAgentRuntime.isRunning) { + if (activeTeamSessions > 0) { + nextItems.push({ + key: "team_running", + label: + queuedTeamSessions > 0 + ? `Team 运行中 ${activeTeamSessions}/${childSubagentSessions.length} · 排队 ${queuedTeamSessions}` + : `Team 运行中 ${activeTeamSessions}/${childSubagentSessions.length}`, + tone: "secondary", + }); + } else if (childSubagentSessions.length > 0) { + nextItems.push({ + key: "team_sessions", + label: + completedTeamSessions > 0 + ? `Team 会话 ${childSubagentSessions.length} · 已收敛 ${completedTeamSessions}` + : `Team 会话 ${childSubagentSessions.length}`, + tone: "outline", + }); + } else if (compatSubagentRuntime.isRunning) { const progressLabel = - subAgentRuntime.progress && - typeof subAgentRuntime.progress.completed === "number" && - typeof subAgentRuntime.progress.total === "number" - ? `子代理运行中 ${subAgentRuntime.progress.completed}/${subAgentRuntime.progress.total}` + compatSubagentRuntime.progress && + typeof compatSubagentRuntime.progress.completed === "number" && + typeof compatSubagentRuntime.progress.total === "number" + ? `子代理运行中 ${compatSubagentRuntime.progress.completed}/${compatSubagentRuntime.progress.total}` : "子代理运行中"; nextItems.push({ key: "subagent_running", @@ -138,11 +171,12 @@ export const AgentRuntimeStrip: React.FC = ({ return nextItems; }, [ + childSubagentSessions, + compatSubagentRuntime.isRunning, + compatSubagentRuntime.progress, harnessState, isSending, runtimeStatusTitle, - subAgentRuntime.isRunning, - subAgentRuntime.progress, ]); return ( diff --git a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx index 83b4d25b9..06867b365 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx @@ -131,6 +131,7 @@ function renderTimeline( isCurrentTurn?: boolean; turn?: Partial; actionRequests?: ActionRequired[]; + onOpenSubagentSession?: (sessionId: string) => void; }, ): HTMLDivElement { const container = document.createElement("div"); @@ -144,6 +145,7 @@ function renderTimeline( items={items} actionRequests={props?.actionRequests} isCurrentTurn={props?.isCurrentTurn} + onOpenSubagentSession={props?.onOpenSubagentSession} />, ); }); @@ -208,9 +210,23 @@ describe("AgentThreadTimeline", () => { const container = renderTimeline(items, { isCurrentTurn: true }); expect( - container.querySelector('[data-testid="agent-thread-details-inline-text"]') + container.querySelector('[data-testid="agent-thread-overview"]') ?.textContent, ).toContain("已完成页面检查"); + expect( + container.querySelector('[data-testid="agent-thread-details-inline-text"]') + ?.textContent, + ).toContain("思考与计划"); + const overviewNode = container.querySelector('[data-testid="agent-thread-overview"]'); + const toggleNode = container.querySelector('[data-testid="agent-thread-details-toggle"]'); + expect( + Boolean( + overviewNode && + toggleNode && + overviewNode.compareDocumentPosition(toggleNode) & + Node.DOCUMENT_POSITION_FOLLOWING, + ), + ).toBe(true); expect( container.querySelector('[data-testid="agent-thread-flow"]'), ).toBeNull(); @@ -220,7 +236,27 @@ describe("AgentThreadTimeline", () => { expect( container.querySelector('[data-testid="agent-thread-summary"]'), ).not.toBeNull(); - expect(container.textContent).toContain("本回合摘要"); + expect( + container.querySelector('[data-testid="agent-thread-overview"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-details-inline-text"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-details-toggle"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-summary-collapse"]'), + ).not.toBeNull(); + expect(container.textContent).toContain("当前回合摘要"); + expect( + container.querySelector('[data-testid="agent-thread-summary-header"]') + ?.textContent, + ).not.toContain("段流程"); + expect( + container.querySelector('[data-testid="agent-thread-summary-header"]') + ?.textContent, + ).not.toContain("已完成"); expect( container.querySelector('[data-testid="agent-thread-summary-shell"]'), ).not.toBeNull(); @@ -241,6 +277,50 @@ describe("AgentThreadTimeline", () => { expect(container.textContent).toContain("技术细节"); }); + it("展开后应在摘要头提供收起入口,并恢复折叠态头部", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("summary-1", 1), + type: "turn_summary", + text: "已整理出下一步执行顺序。", + }, + { + ...createBaseItem("browser-1", 2), + type: "tool_call", + tool_name: "browser_click", + arguments: { selector: "#publish" }, + }, + ]; + + const container = renderTimeline(items, { + isCurrentTurn: true, + turn: { + status: "running", + }, + }); + + clickTimelineToggle(container); + + const collapseButton = container.querySelector( + '[data-testid="agent-thread-summary-collapse"]', + ); + expect(collapseButton).not.toBeNull(); + + act(() => { + collapseButton?.click(); + }); + + expect( + container.querySelector('[data-testid="agent-thread-summary"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-details-toggle"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-overview"]'), + ).not.toBeNull(); + }); + it("审批块应默认展开,技术细节块默认折叠", () => { const items: AgentThreadItem[] = [ { @@ -358,13 +438,17 @@ describe("AgentThreadTimeline", () => { }); expect( - container.querySelector('[data-testid="agent-thread-details-inline-text"]') + container.querySelector('[data-testid="agent-thread-overview"]') + ?.textContent, + ).toContain("先梳理问题背景"); + expect( + container.querySelector('[data-testid="agent-thread-details-stage"]') ?.textContent, ).toContain("阶段 02"); expect( container.querySelector('[data-testid="agent-thread-details-inline-text"]') ?.textContent, - ).toContain("先梳理问题背景"); + ).toContain("思考与计划"); }); it("运行中的块应被高亮,已完成块应降噪", () => { @@ -422,6 +506,37 @@ describe("AgentThreadTimeline", () => { expect(container.textContent).toContain("执行中"); }); + it("流程展开后不应重复显示顶部当前进展卡片", () => { + const items: AgentThreadItem[] = [ + { + ...createBaseItem("search-1", 1), + status: "in_progress", + completed_at: undefined, + updated_at: at(1), + type: "web_search", + action: "web_search", + query: "team runtime 侧栏高度", + }, + ]; + + const container = renderTimeline(items, { + isCurrentTurn: true, + turn: { + status: "running", + }, + }); + + clickTimelineToggle(container); + + expect( + container.querySelector('[data-testid="agent-thread-details"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="agent-thread-overview"]'), + ).toBeNull(); + expect(container.textContent).toContain("当前回合摘要"); + }); + it("浏览器前置等待时不应显示已中断,而应显示待继续", () => { const items: AgentThreadItem[] = [ { @@ -563,6 +678,32 @@ describe("AgentThreadTimeline", () => { expect(container.textContent).not.toContain("```a2ui"); }); + it("纯 reasoning 阶段展开后不应重复渲染思考摘要卡", () => { + const reasoningText = "先核对执行链路,再立即恢复当前运行。"; + const items: AgentThreadItem[] = [ + { + ...createBaseItem("reasoning-1", 1), + type: "reasoning", + text: reasoningText, + }, + ]; + + const container = renderTimeline(items, { + isCurrentTurn: true, + turn: { + status: "running", + }, + }); + + clickTimelineToggle(container); + + expect( + container.querySelector('[data-testid="agent-thread-block:1:thinking:details"]'), + ).toBeNull(); + expect(container.textContent).not.toContain("思考摘要"); + expect((container.textContent?.split(reasoningText).length ?? 1) - 1).toBe(1); + }); + it("已完成的 request_user_input 应以只读 A2UI 卡片回显", () => { const items: AgentThreadItem[] = [ { @@ -594,4 +735,39 @@ describe("AgentThreadTimeline", () => { ).not.toBeNull(); expect(container.querySelector('[data-testid="decision-panel"]')).toBeNull(); }); + + it("真实子代理 item 应支持打开子会话", () => { + const onOpenSubagentSession = vi.fn(); + const items: AgentThreadItem[] = [ + { + ...createBaseItem("subagent-1", 1), + type: "subagent_activity", + status: "completed", + status_label: "completed", + title: "Image #1", + summary: "封面图已生成", + role: "image_editor", + model: "gpt-image-1", + session_id: "child-session-1", + }, + ]; + + const container = renderTimeline(items, { + onOpenSubagentSession, + }); + + clickTimelineToggle(container); + + const button = Array.from( + container.querySelectorAll("button"), + ).find((element) => element.textContent?.includes("打开子会话")); + + expect(button).toBeTruthy(); + + act(() => { + button?.click(); + }); + + expect(onOpenSubagentSession).toHaveBeenCalledWith("child-session-1"); + }); }); diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index 22d22aa8a..6f347364f 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -16,6 +16,7 @@ import { } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, @@ -51,6 +52,7 @@ interface AgentThreadTimelineProps { actionRequests?: ActionRequired[]; isCurrentTurn?: boolean; onFileClick?: (fileName: string, content: string) => void; + onOpenSubagentSession?: (sessionId: string) => void; onPermissionResponse?: (response: ConfirmResponse) => void; } @@ -640,7 +642,11 @@ function renderThinkingItemDetails(item: AgentThreadItem) { ); } - if (item.type === "reasoning" || item.type === "turn_summary") { + if (item.type === "reasoning") { + return null; + } + + if (item.type === "turn_summary") { return ; } @@ -650,6 +656,7 @@ function renderThinkingItemDetails(item: AgentThreadItem) { function renderGroupItemDetails( item: AgentThreadItem, onFileClick?: (fileName: string, content: string) => void, + onOpenSubagentSession?: (sessionId: string) => void, onPermissionResponse?: (response: ConfirmResponse) => void, ) { const toolCall = toToolCallState(item); @@ -717,6 +724,8 @@ function renderGroupItemDetails( } if (item.type === "subagent_activity") { + const subagentSessionId = item.session_id?.trim(); + return ( {item.model} : null} ) : null} + {subagentSessionId && onOpenSubagentSession ? ( +
+ +
+ ) : null}
); } @@ -1000,7 +1021,7 @@ function resolveLatestThinkingPreview( }; } -function resolveCollapsedProcessText(params: { +function resolveCollapsedProcessSnapshot(params: { compactTone: TimelineCompactTone; displayModelSummaryText: string | null; flowBlockCount: number; @@ -1009,7 +1030,12 @@ function resolveCollapsedProcessText(params: { orderedBlocks: AgentThreadOrderedBlock[]; promptPreview: string | null; turnStatusMeta: TurnStatusMeta; -}): string { +}): { + statusLabel: string; + stageLabel: string | null; + detailText: string; + combinedText: string; +} { const { compactTone, displayModelSummaryText, @@ -1022,25 +1048,37 @@ function resolveCollapsedProcessText(params: { } = params; const focusInlineText = resolveFocusInlineText(focusBlock); const latestThinkingPreview = resolveLatestThinkingPreview(orderedBlocks); + const normalizedOverview = turnStatusMeta.overviewText.trim(); const detail = compactTone === "running" ? focusInlineText || - turnStatusMeta.overviewText || displayModelSummaryText || - promptPreview + promptPreview || + focusBlock?.title || + null : compactTone === "waiting" || compactTone === "failed" || compactTone === "paused" - ? turnStatusMeta.overviewText || - focusInlineText || + ? focusInlineText || displayModelSummaryText || - promptPreview + promptPreview || + focusBlock?.title || + null : latestThinkingPreview.text || focusInlineText || displayModelSummaryText || - turnStatusMeta.overviewText || - promptPreview; + promptPreview || + null; + + const fallbackDetail = + compactTone === "done" && latestThinkingPreview.stageLabel + ? "思考与计划" + : focusBlock?.title || (flowBlockCount > 0 ? `${flowBlockCount} 段流程` : null); + const distinctDetail = + detail?.trim() && detail.trim() !== normalizedOverview + ? detail.trim() + : fallbackDetail; const stageLabel = compactTone === "running" @@ -1051,20 +1089,25 @@ function resolveCollapsedProcessText(params: { ? latestThinkingPreview.stageLabel : null; + const detailText = + shortenInlineText( + distinctDetail || "执行轨迹已收起,点击查看完整过程。", + compactTone === "running" ? 88 : 78, + ) || "执行轨迹已收起,点击查看完整过程。"; const segments = [turnStatusMeta.label]; if (stageLabel) { segments.push(stageLabel); } - - const shortDetail = shortenInlineText( - detail || "执行轨迹已收起,点击查看完整过程。", - compactTone === "running" ? 88 : 78, - ); - if (shortDetail && shortDetail !== turnStatusMeta.label) { - segments.push(shortDetail); + if (detailText && detailText !== turnStatusMeta.label) { + segments.push(detailText); } - return segments.join(" · "); + return { + statusLabel: turnStatusMeta.label, + stageLabel, + detailText, + combinedText: segments.join(" · "), + }; } function TimelineCompactStatusIcon({ @@ -1147,6 +1190,7 @@ function TimelineBlockCard({ emphasis, isExpanded, onFileClick, + onOpenSubagentSession, onPermissionResponse, }: { block: AgentThreadOrderedBlock; @@ -1155,6 +1199,7 @@ function TimelineBlockCard({ emphasis: "active" | "default" | "quiet"; isExpanded: boolean; onFileClick?: (fileName: string, content: string) => void; + onOpenSubagentSession?: (sessionId: string) => void; onPermissionResponse?: (response: ConfirmResponse) => void; }) { const Icon = resolveGroupIcon(block.kind); @@ -1164,6 +1209,34 @@ function TimelineBlockCard({ const isActive = emphasis === "active"; const isQuiet = emphasis === "quiet"; const stageLabel = `阶段 ${String(index + 1).padStart(2, "0")}`; + const detailEntries = block.items.flatMap((item) => { + const content = + block.kind === "thinking" + ? renderThinkingItemDetails(item) + : renderGroupItemDetails( + item, + onFileClick, + onOpenSubagentSession, + onPermissionResponse, + ); + + return content ? [{ id: item.id, content }] : []; + }); + const hasDetailEntries = detailEntries.length > 0; + const cardClassName = isActive + ? "overflow-hidden rounded-2xl border border-primary/25 bg-primary/[0.045] shadow-md shadow-primary/10" + : isCompact + ? "overflow-hidden rounded-2xl border border-border/45 bg-background/60" + : isQuiet + ? "overflow-hidden rounded-2xl border border-border/45 bg-background/60" + : "overflow-hidden rounded-2xl border border-border/60 bg-background/75"; + const summaryClassName = isCompact + ? "flex items-start gap-3 px-4 py-2.5" + : "flex items-start gap-3 px-4 py-3"; + const interactiveSummaryClassName = cn( + summaryClassName, + hasDetailEntries ? "cursor-pointer" : "cursor-default", + ); return (
-
- -
-
- - {stageLabel} - - - {block.title} - - {block.countLabel} - - {block.status === "in_progress" ? ( - - - {resolveItemStatusLabel(block.status)} - - ) : ( - resolveItemStatusLabel(block.status) - )} - - {timestamp ? ( - - {timestamp} + +
+
+ + {stageLabel} - ) : null} + + {block.title} + + {block.countLabel} + + {block.status === "in_progress" ? ( + + + {resolveItemStatusLabel(block.status)} + + ) : ( + resolveItemStatusLabel(block.status) + )} + + {timestamp ? ( + + {timestamp} + + ) : null} +
+ {isCompact ? ( +
+ {resolveCompactTechnicalSummary(block)} +
+ ) : block.previewLines.length > 0 ? ( +
+ {block.previewLines.map((line) => ( +
+ {line} +
+ ))} +
+ ) : ( +
+ 已归档该分组的执行细节。 +
+ )}
- {isCompact ? ( -
- {resolveCompactTechnicalSummary(block)} -
- ) : block.previewLines.length > 0 ? ( -
- {block.previewLines.map((line) => ( -
- {line} -
- ))} -
- ) : ( -
- 已归档该分组的执行细节。 -
- )} +
+ + {isCompact ? "展开查看" : block.rawDetailLabel} + + +
+
+
+ {detailEntries.map((entry) => ( +
{entry.content}
+ ))}
-
- - {isCompact ? "展开查看" : block.rawDetailLabel} - - -
-
+
+ ) : (
- {block.items.map((item) => ( -
- {block.kind === "thinking" - ? renderThinkingItemDetails(item) - : renderGroupItemDetails(item, onFileClick, onPermissionResponse)} +
+
+
+ + {stageLabel} + + + {block.title} + + {block.countLabel} + + {block.status === "in_progress" ? ( + + + {resolveItemStatusLabel(block.status)} + + ) : ( + resolveItemStatusLabel(block.status) + )} + + {timestamp ? ( + + {timestamp} + + ) : null} +
+ {isCompact ? ( +
+ {resolveCompactTechnicalSummary(block)} +
+ ) : block.previewLines.length > 0 ? ( +
+ {block.previewLines.map((line) => ( +
+ {line} +
+ ))} +
+ ) : ( +
+ 已归档该分组的执行细节。 +
+ )}
- ))} +
+ 已展示完整内容 +
+
- + )}
); } @@ -1299,6 +1411,7 @@ export const AgentThreadTimeline: React.FC = ({ actionRequests = [], isCurrentTurn = false, onFileClick, + onOpenSubagentSession, onPermissionResponse, }) => { const visibleItems = useMemo( @@ -1380,7 +1493,7 @@ export const AgentThreadTimeline: React.FC = ({ ? "查看当前回合执行细节" : "展开回合执行细节"; const compactTone = resolveCompactTone({ turn, turnStatusMeta }); - const collapsedProcessText = resolveCollapsedProcessText({ + const collapsedProcess = resolveCollapsedProcessSnapshot({ compactTone, displayModelSummaryText: displayModel.summaryText, flowBlockCount, @@ -1391,184 +1504,276 @@ export const AgentThreadTimeline: React.FC = ({ turnStatusMeta, }); const showRunningAccent = compactTone === "running"; + const timelineOverviewText = turnStatusMeta.overviewText.trim() || null; + const hasSummarySupportContent = + Boolean(promptPreview) || + Boolean(focusBlock) || + displayModel.summaryChips.length > 0; + const focusBlockPreviewText = + focusBlock?.previewLines.find((line) => line.trim().length > 0)?.trim() || null; + const summaryPanelTextCandidate = + displayModel.summaryText?.trim() && + displayModel.summaryText.trim() !== timelineOverviewText + ? displayModel.summaryText.trim() + : timelineOverviewText; + const summaryPanelText = + summaryPanelTextCandidate && + (summaryPanelTextCandidate !== timelineOverviewText || + !hasSummarySupportContent) && + summaryPanelTextCandidate !== focusBlockPreviewText + ? summaryPanelTextCandidate + : null; + const overviewShellClassName = cn( + "mb-2 max-w-4xl rounded-2xl border px-3 py-2.5 shadow-sm shadow-slate-950/5", + compactTone === "running" && + "border-sky-200/70 bg-sky-50/72", + compactTone === "waiting" && + "border-amber-200/70 bg-amber-50/78", + compactTone === "failed" && + "border-rose-200/70 bg-rose-50/78", + compactTone === "paused" && + "border-slate-200/80 bg-slate-50/82", + compactTone === "done" && + "border-border/55 bg-background/58", + ); + const overviewLabelClassName = cn( + "text-[11px] font-medium", + compactTone === "running" && "text-sky-700", + compactTone === "waiting" && "text-amber-700", + compactTone === "failed" && "text-rose-700", + compactTone === "paused" && "text-slate-600", + compactTone === "done" && "text-muted-foreground", + ); + const overviewTextClassName = cn( + "mt-1.5 text-sm leading-6", + compactTone === "running" && "text-sky-950/90", + compactTone === "waiting" && "text-amber-950/90", + compactTone === "failed" && "text-rose-950/90", + compactTone === "paused" && "text-slate-700", + compactTone === "done" && "text-foreground/90", + ); return ( - - - - - - -
-
-
- {turnStatusMeta.overviewText} -
-
+
+ 当前进展 + + {turnStatusMeta.label} + + + {isCurrentTurn ? "当前回合" : "历史回合"} + + {formatTimestamp(turn.started_at) || "刚刚"} -
-
- -
-
- -
-
-
-
- 本回合摘要 -
- {flowBlockCount} 段流程 - {isCurrentTurn ? 当前回合 : null} - - {turnStatusMeta.label} - -
- - {formatTimestamp(turn.started_at) || "刚刚"} -
-
- -
- {turnStatusMeta.overviewText} -
- - {promptPreview || focusBlock ? ( -
- {promptPreview ? ( -
-
- 用户目标 -
-
- {promptPreview} -
-
- ) : null} - - {focusBlock ? ( -
-
- 当前聚焦 -
-
- {focusBlockStageLabel ? ( - {focusBlockStageLabel} - ) : null} - - {focusBlock.title} - -
- {focusBlock.previewLines[0] ? ( -
- {focusBlock.previewLines[0]} -
- ) : null} -
- ) : null} -
- ) : null} - - {displayModel.summaryChips.length > 0 ? ( -
- {displayModel.summaryChips.map((chip) => ( - - ))} -
- ) : null} - - {turn.error_message && - turnStatusMeta.badgeVariant === "destructive" ? ( -
- {turn.error_message} -
- ) : null} -
-
- -
- {displayModel.orderedBlocks.map((block, index) => ( - - ))} +
+
{timelineOverviewText}
- - + ) : null} + + + {!detailsExpanded ? ( + + + + ) : null} + + +
+
+
+ +
+
+
+
+ {isCurrentTurn ? "当前回合摘要" : "回合摘要"} +
+ +
+ + {formatTimestamp(turn.started_at) || "刚刚"} +
+
+ + {summaryPanelText ? ( +
+ {summaryPanelText} +
+ ) : null} + + {promptPreview || focusBlock ? ( +
+ {promptPreview ? ( +
+
+ 用户目标 +
+
+ {promptPreview} +
+
+ ) : null} + + {focusBlock ? ( +
+
+ 当前聚焦 +
+
+ {focusBlockStageLabel ? ( + {focusBlockStageLabel} + ) : null} + + {focusBlock.title} + +
+
+ ) : null} +
+ ) : null} + + {displayModel.summaryChips.length > 0 ? ( +
+ {displayModel.summaryChips.map((chip) => ( + + ))} +
+ ) : null} + + {turn.error_message && + turnStatusMeta.badgeVariant === "destructive" ? ( +
+ {turn.error_message} +
+ ) : null} +
+
+ +
+ {displayModel.orderedBlocks.map((block, index) => ( + + ))} +
+
+
+
+
); }; diff --git a/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx b/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx index cb71f9396..2b5cbaabd 100644 --- a/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx +++ b/src/components/agent/chat/components/ChatModelSelector.integration.test.tsx @@ -94,6 +94,7 @@ const mountedRoots: MountedHarness[] = []; interface MountOptions { onManageProviders?: () => void; + chatModelSelectorProps?: Partial>; } function createModel(id: string, providerId: string) { @@ -133,7 +134,7 @@ function mount( workspaceId: string, options: MountOptions = {}, ): HTMLDivElement { - const { onManageProviders } = options; + const { onManageProviders, chatModelSelectorProps } = options; const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -165,6 +166,7 @@ function mount( setModel={chat.setModel} activeTheme="general" onManageProviders={onManageProviders} + {...chatModelSelectorProps} />
{chat.providerType}/{chat.model} @@ -432,4 +434,52 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => { expect(onManageProviders).toHaveBeenCalledTimes(1); }); + + it("关闭后台预加载时,应在打开选择器后再加载 Provider 和模型", async () => { + const container = mount("ws-model-selector-lazy-provider-load", { + chatModelSelectorProps: { + backgroundPreload: "disabled", + }, + }); + + await flushEffects(); + + expect( + mockUseConfiguredProviders.mock.calls.some( + ([options]) => options?.autoLoad === false, + ), + ).toBe(true); + expect( + mockUseConfiguredProviders.mock.calls.some( + ([options]) => options?.autoLoad === true, + ), + ).toBe(false); + + expect( + mockUseProviderModels.mock.calls.some( + ([, options]) => options?.autoLoad === false, + ), + ).toBe(true); + expect( + mockUseProviderModels.mock.calls.some( + ([, options]) => options?.autoLoad === true, + ), + ).toBe(false); + + await act(async () => { + getComboboxTrigger(container).click(); + }); + await flushEffects(); + + expect( + mockUseConfiguredProviders.mock.calls.some( + ([options]) => options?.autoLoad === true, + ), + ).toBe(true); + expect( + mockUseProviderModels.mock.calls.some( + ([, options]) => options?.autoLoad === true, + ), + ).toBe(true); + }); }); diff --git a/src/components/agent/chat/components/ChatSidebar.test.tsx b/src/components/agent/chat/components/ChatSidebar.test.tsx index b484e115f..30cb7262a 100644 --- a/src/components/agent/chat/components/ChatSidebar.test.tsx +++ b/src/components/agent/chat/components/ChatSidebar.test.tsx @@ -128,6 +128,79 @@ describe("ChatSidebar", () => { expect(container.textContent).toContain("任务一"); }); + it("Team Runtime 和任务列表应处于同一滚动区域", () => { + const container = renderSidebar({ + childSubagentSessions: [ + { + id: "child-1", + name: "代码审查代理", + created_at: 1_742_288_400, + updated_at: 1_742_288_520, + session_type: "sub_agent", + task_summary: "检查 team runtime 侧栏遗漏的交互入口。", + role_hint: "reviewer", + runtime_status: "running", + }, + ], + }); + + const scrollArea = container.querySelector( + '[data-testid="chat-sidebar-scroll-area"]', + ) as HTMLDivElement | null; + const teamSection = container.querySelector( + '[data-testid="team-runtime-section"]', + ) as HTMLElement | null; + + expect(scrollArea).toBeTruthy(); + expect(teamSection).toBeTruthy(); + expect(scrollArea?.contains(teamSection)).toBe(true); + expect(scrollArea?.textContent).toContain("Team Runtime"); + expect(scrollArea?.textContent).toContain("任务一"); + }); + + it("点击 Team Runtime 的任务入口应收起顶部区块并滚动到任务列表", () => { + const container = renderSidebar({ + childSubagentSessions: [ + { + id: "child-1", + name: "代码审查代理", + created_at: 1_742_288_400, + updated_at: 1_742_288_520, + session_type: "sub_agent", + task_summary: "检查 team runtime 侧栏遗漏的交互入口。", + role_hint: "reviewer", + runtime_status: "running", + }, + ], + }); + + const taskHeading = container.querySelector( + '[data-testid="task-section-heading"]', + ) as (HTMLDivElement & { scrollIntoView?: ReturnType }) | null; + expect(taskHeading).toBeTruthy(); + + const scrollIntoView = vi.fn(); + if (taskHeading) { + taskHeading.scrollIntoView = scrollIntoView; + } + + const jumpButton = container.querySelector( + 'button[aria-label="跳转到任务列表"]', + ) as HTMLButtonElement | null; + expect(jumpButton).toBeTruthy(); + + act(() => { + jumpButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(scrollIntoView).toHaveBeenCalledWith({ + block: "start", + behavior: "smooth", + }); + expect(container.textContent).toContain("已收起 · 1 个子代理 · 1 个运行中"); + expect(container.textContent).not.toContain("代码审查代理"); + }); + it("点击任务时应触发切换", () => { const onSwitchTopic = vi.fn(); const container = renderSidebar({ onSwitchTopic }); @@ -452,4 +525,344 @@ describe("ChatSidebar", () => { expect(container.textContent).toContain("任务一"); expect(container.textContent).not.toContain("任务二"); }); + + it("父线程应在侧栏展示真实子代理并支持打开", () => { + const onOpenSubagentSession = vi.fn(); + const container = renderSidebar({ + childSubagentSessions: [ + { + id: "child-1", + name: "代码审查代理", + created_at: 1_742_288_400, + updated_at: 1_742_288_520, + session_type: "sub_agent", + task_summary: "检查 team runtime 侧栏遗漏的交互入口。", + role_hint: "reviewer", + runtime_status: "running", + }, + { + id: "child-2", + name: "文档校对代理", + created_at: 1_742_288_410, + updated_at: 1_742_288_480, + session_type: "sub_agent", + task_summary: "核对 roadmap 的阶段完成度。", + role_hint: "writer", + runtime_status: "completed", + }, + ], + onOpenSubagentSession, + }); + + expect(container.textContent).toContain("Team Runtime"); + expect(container.textContent).toContain("代码审查代理"); + expect(container.textContent).toContain("文档校对代理"); + expect(container.textContent).toContain("运行中"); + expect(container.textContent).toContain("已完成"); + + const sessionButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent?.includes("代码审查代理"), + ); + expect(sessionButton).toBeTruthy(); + + act(() => { + sessionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onOpenSubagentSession).toHaveBeenCalledWith("child-1"); + }); + + it("父线程 Team Runtime 区域应支持折叠和展开", () => { + const container = renderSidebar({ + childSubagentSessions: [ + { + id: "child-1", + name: "代码审查代理", + created_at: 1_742_288_400, + updated_at: 1_742_288_520, + session_type: "sub_agent", + task_summary: "检查 team runtime 侧栏遗漏的交互入口。", + role_hint: "reviewer", + runtime_status: "running", + }, + { + id: "child-2", + name: "文档校对代理", + created_at: 1_742_288_410, + updated_at: 1_742_288_480, + session_type: "sub_agent", + task_summary: "核对 roadmap 的阶段完成度。", + role_hint: "writer", + runtime_status: "completed", + }, + ], + }); + + expect(container.textContent).toContain("代码审查代理"); + expect(container.textContent).toContain("文档校对代理"); + + const collapseButton = container.querySelector( + 'button[aria-label="收起 Team Runtime"]', + ) as HTMLButtonElement | null; + expect(collapseButton).toBeTruthy(); + + act(() => { + collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("已收起 · 2 个子代理 · 1 个运行中 · 1 个已完成"); + expect(container.textContent).not.toContain("代码审查代理"); + expect(container.textContent).not.toContain("文档校对代理"); + + const expandButton = container.querySelector( + 'button[aria-label="展开 Team Runtime"]', + ) as HTMLButtonElement | null; + expect(expandButton).toBeTruthy(); + + act(() => { + expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("代码审查代理"); + expect(container.textContent).toContain("文档校对代理"); + }); + + it("父线程 Team Runtime 在子代理较多时应默认收起,并支持展开更多子代理", () => { + const container = renderSidebar({ + childSubagentSessions: [ + { + id: "child-1", + name: "代码审查代理", + created_at: 1_742_288_400, + updated_at: 1_742_288_560, + session_type: "sub_agent", + task_summary: "检查 team runtime 侧栏遗漏的交互入口。", + role_hint: "reviewer", + runtime_status: "running", + }, + { + id: "child-2", + name: "文档校对代理", + created_at: 1_742_288_410, + updated_at: 1_742_288_550, + session_type: "sub_agent", + task_summary: "核对 roadmap 的阶段完成度。", + role_hint: "writer", + runtime_status: "completed", + }, + { + id: "child-3", + name: "数据整理代理", + created_at: 1_742_288_420, + updated_at: 1_742_288_540, + session_type: "sub_agent", + task_summary: "汇总运行日志中的关键告警。", + role_hint: "analyst", + runtime_status: "queued", + }, + { + id: "child-4", + name: "回归验证代理", + created_at: 1_742_288_430, + updated_at: 1_742_288_530, + session_type: "sub_agent", + task_summary: "确认恢复链路和 UI 状态推进。", + role_hint: "qa", + runtime_status: "running", + }, + ], + }); + + expect(container.textContent).toContain( + "已收起 · 4 个子代理 · 2 个运行中 · 1 个排队中 · 1 个已完成", + ); + expect(container.textContent).not.toContain("代码审查代理"); + expect(container.textContent).not.toContain("文档校对代理"); + expect(container.textContent).not.toContain("数据整理代理"); + expect(container.textContent).not.toContain("回归验证代理"); + + const expandTeamButton = container.querySelector( + 'button[aria-label="展开 Team Runtime"]', + ) as HTMLButtonElement | null; + expect(expandTeamButton).toBeTruthy(); + + act(() => { + expandTeamButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + expect(container.textContent).toContain("代码审查代理"); + expect(container.textContent).toContain("文档校对代理"); + expect(container.textContent).toContain("数据整理代理"); + expect(container.textContent).not.toContain("回归验证代理"); + expect(container.textContent).toContain("展开剩余 1 个子代理"); + + const expandMoreButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent?.includes("展开剩余 1 个子代理"), + ); + expect(expandMoreButton).toBeTruthy(); + + act(() => { + expandMoreButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + expect(container.textContent).toContain("回归验证代理"); + expect(container.textContent).toContain("收起子代理列表"); + + const collapseListButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent?.includes("收起子代理列表"), + ); + expect(collapseListButton).toBeTruthy(); + + act(() => { + collapseListButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + expect(container.textContent).not.toContain("回归验证代理"); + }); + + it("子线程同级子代理较多时应默认收起 Team Runtime", () => { + const container = renderSidebar({ + topics: [ + { + ...defaultTopics[0], + id: "child-1", + title: "实现 team sidebar", + sourceSessionId: "child-1", + }, + ], + currentTopicId: "child-1", + subagentParentContext: { + parent_session_id: "parent-1", + parent_session_name: "主线程", + role_hint: "implementer", + task_summary: "把真实 child session 投影到常驻侧栏。", + created_from_turn_id: "turn-42", + sibling_subagent_sessions: [ + { + id: "child-2", + name: "研究代理", + created_at: 1_742_288_430, + updated_at: 1_742_288_530, + session_type: "sub_agent", + task_summary: "比对 roadmap 与当前实现差异。", + role_hint: "researcher", + runtime_status: "queued", + }, + { + id: "child-3", + name: "验证代理", + created_at: 1_742_288_431, + updated_at: 1_742_288_531, + session_type: "sub_agent", + task_summary: "验证 team runtime 行为。", + role_hint: "qa", + runtime_status: "running", + }, + { + id: "child-4", + name: "文档代理", + created_at: 1_742_288_432, + updated_at: 1_742_288_532, + session_type: "sub_agent", + task_summary: "补齐回归说明。", + role_hint: "writer", + runtime_status: "completed", + }, + ], + }, + }); + + expect(container.textContent).toContain( + "已收起 · 3 个同级子代理 · 1 个运行中 · 1 个排队中 · 1 个已完成", + ); + expect(container.textContent).not.toContain("研究代理"); + expect(container.textContent).not.toContain("验证代理"); + expect(container.textContent).not.toContain("文档代理"); + + const expandButton = container.querySelector( + 'button[aria-label="展开 Team Runtime"]', + ) as HTMLButtonElement | null; + expect(expandButton).toBeTruthy(); + + act(() => { + expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("验证代理"); + expect(container.textContent).toContain("文档代理"); + expect(container.textContent).not.toContain("研究代理"); + expect(container.textContent).toContain("展开剩余 1 个同级子代理"); + }); + + it("子线程应展示父会话和同级子代理入口", () => { + const onOpenSubagentSession = vi.fn(); + const onReturnToParentSession = vi.fn(); + const container = renderSidebar({ + topics: [ + { + ...defaultTopics[0], + id: "child-1", + title: "实现 team sidebar", + sourceSessionId: "child-1", + }, + ], + currentTopicId: "child-1", + subagentParentContext: { + parent_session_id: "parent-1", + parent_session_name: "主线程", + role_hint: "implementer", + task_summary: "把真实 child session 投影到常驻侧栏。", + created_from_turn_id: "turn-42", + sibling_subagent_sessions: [ + { + id: "child-2", + name: "研究代理", + created_at: 1_742_288_430, + updated_at: 1_742_288_530, + session_type: "sub_agent", + task_summary: "比对 roadmap 与当前实现差异。", + role_hint: "researcher", + runtime_status: "queued", + }, + ], + }, + onOpenSubagentSession, + onReturnToParentSession, + }); + + expect(container.textContent).toContain("Team Runtime"); + expect(container.textContent).toContain("主线程"); + expect(container.textContent).toContain("当前子代理"); + expect(container.textContent).toContain("实现 team sidebar"); + expect(container.textContent).toContain("研究代理"); + expect(container.textContent).toContain("排队中"); + + const returnButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent?.includes("主线程"), + ); + expect(returnButton).toBeTruthy(); + + act(() => { + returnButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onReturnToParentSession).toHaveBeenCalledTimes(1); + + const siblingButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.textContent?.includes("研究代理"), + ); + expect(siblingButton).toBeTruthy(); + + act(() => { + siblingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onOpenSubagentSession).toHaveBeenCalledWith("child-2"); + }); }); diff --git a/src/components/agent/chat/components/ChatSidebar.tsx b/src/components/agent/chat/components/ChatSidebar.tsx index ed2191a8e..639d91723 100644 --- a/src/components/agent/chat/components/ChatSidebar.tsx +++ b/src/components/agent/chat/components/ChatSidebar.tsx @@ -1,8 +1,12 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { + ArrowUpLeft, + Bot, ChevronDown, Clock3, + GitBranch, Globe, + ListTodo, Loader2, MoreHorizontal, PencilLine, @@ -21,6 +25,10 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; +import type { + AsterSubagentParentContext, + AsterSubagentSessionInfo, +} from "@/lib/api/agentRuntime"; import { deriveTaskLiveState, extractTaskPreviewFromMessages, @@ -32,6 +40,8 @@ import type { Message } from "../types"; const RECENT_TASK_WINDOW_MS = 1000 * 60 * 60 * 24 * 3; const OLDER_TASKS_INITIAL_COUNT = 8; +const TEAM_SECTION_INITIAL_CHILD_COUNT = 3; +const TEAM_SECTION_INITIAL_SIBLING_COUNT = 2; const PINNED_TASK_IDS_STORAGE_KEY = "lime_task_sidebar_pinned_ids"; const STATUS_META: Record< @@ -117,6 +127,10 @@ interface ChatSidebarProps { pendingActionCount?: number; queuedTurnCount?: number; workspaceError?: boolean; + childSubagentSessions?: AsterSubagentSessionInfo[]; + subagentParentContext?: AsterSubagentParentContext | null; + onOpenSubagentSession?: (sessionId: string) => void | Promise; + onReturnToParentSession?: () => void | Promise; } function isResumableStatusReason(statusReason?: TaskStatusReason) { @@ -344,6 +358,107 @@ function buildTaskSections(items: TaskCardViewModel[]) { ] satisfies TaskSection[]; } +const SUBAGENT_STATUS_META: Record< + NonNullable | "idle", + { + label: string; + badgeClassName: string; + } +> = { + idle: { + label: "待开始", + badgeClassName: + "border border-slate-200 bg-white text-slate-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300", + }, + queued: { + label: "排队中", + badgeClassName: + "border border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200", + }, + running: { + label: "运行中", + badgeClassName: + "border border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-200", + }, + completed: { + label: "已完成", + badgeClassName: + "border border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-200", + }, + failed: { + label: "失败", + badgeClassName: + "border border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-500/20 dark:bg-rose-500/10 dark:text-rose-200", + }, + aborted: { + label: "已中止", + badgeClassName: + "border border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-500/20 dark:bg-rose-500/10 dark:text-rose-200", + }, + closed: { + label: "已关闭", + badgeClassName: + "border border-slate-200 bg-slate-100 text-slate-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300", + }, +}; + +const TEAM_STATUS_SUMMARY_ORDER: Array< + NonNullable | "idle" +> = ["running", "queued", "completed", "failed", "aborted", "closed", "idle"]; + +function resolveSubagentStatusMeta( + status?: AsterSubagentSessionInfo["runtime_status"], +) { + return SUBAGENT_STATUS_META[status ?? "idle"]; +} + +function resolveSubagentSessionTypeLabel(value?: string) { + switch (value) { + case "sub_agent": + return "子代理"; + case "fork": + return "分支会话"; + case "user": + default: + return value?.trim() || "会话"; + } +} + +function resolveUnixDate(value?: number) { + if (!value) { + return null; + } + + const timestamp = new Date(value * 1000); + return Number.isNaN(timestamp.getTime()) ? null : timestamp; +} + +function buildCollapsedTeamSummary( + sessions: AsterSubagentSessionInfo[], + label: string, +) { + const counts = new Map< + NonNullable | "idle", + number + >(); + + for (const session of sessions) { + const key = session.runtime_status ?? "idle"; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + + const statusSummary = TEAM_STATUS_SUMMARY_ORDER.map((status) => { + const count = counts.get(status) ?? 0; + if (count <= 0) { + return null; + } + + return `${count} 个${SUBAGENT_STATUS_META[status].label}`; + }).filter((item): item is string => Boolean(item)); + + return ["已收起", label, ...statusSummary].join(" · "); +} + export const ChatSidebar: React.FC = ({ onNewChat, topics, @@ -357,6 +472,10 @@ export const ChatSidebar: React.FC = ({ pendingActionCount = 0, queuedTurnCount = 0, workspaceError = false, + childSubagentSessions = [], + subagentParentContext = null, + onOpenSubagentSession, + onReturnToParentSession, }) => { const [editingTopicId, setEditingTopicId] = useState(null); const [editTitle, setEditTitle] = useState(""); @@ -377,7 +496,13 @@ export const ChatSidebar: React.FC = ({ recent: false, older: false, }); + const [teamSectionCollapsedOverride, setTeamSectionCollapsedOverride] = useState< + boolean | null + >(null); + const [showAllChildSubagents, setShowAllChildSubagents] = useState(false); + const [showAllSiblingSubagents, setShowAllSiblingSubagents] = useState(false); const editInputRef = useRef(null); + const taskSectionAnchorRef = useRef(null); const currentTaskPreview = useMemo( () => resolveCurrentTaskPreview(currentMessages), @@ -438,6 +563,78 @@ export const ChatSidebar: React.FC = ({ topics, workspaceError, ]); + const currentTaskItem = useMemo( + () => taskItems.find((item) => item.id === currentTopicId) ?? null, + [currentTopicId, taskItems], + ); + const sortedChildSubagentSessions = useMemo( + () => + [...childSubagentSessions].sort( + (left, right) => right.updated_at - left.updated_at, + ), + [childSubagentSessions], + ); + const siblingSubagentSessions = useMemo( + () => + [...(subagentParentContext?.sibling_subagent_sessions ?? [])].sort( + (left, right) => right.updated_at - left.updated_at, + ), + [subagentParentContext?.sibling_subagent_sessions], + ); + const visibleChildSubagentSessions = useMemo( + () => + showAllChildSubagents + ? sortedChildSubagentSessions + : sortedChildSubagentSessions.slice(0, TEAM_SECTION_INITIAL_CHILD_COUNT), + [showAllChildSubagents, sortedChildSubagentSessions], + ); + const visibleSiblingSubagentSessions = useMemo( + () => + showAllSiblingSubagents + ? siblingSubagentSessions + : siblingSubagentSessions.slice(0, TEAM_SECTION_INITIAL_SIBLING_COUNT), + [showAllSiblingSubagents, siblingSubagentSessions], + ); + const hiddenChildSubagentCount = Math.max( + 0, + sortedChildSubagentSessions.length - visibleChildSubagentSessions.length, + ); + const hiddenSiblingSubagentCount = Math.max( + 0, + siblingSubagentSessions.length - visibleSiblingSubagentSessions.length, + ); + const shouldShowTeamSection = + Boolean(subagentParentContext) || sortedChildSubagentSessions.length > 0; + const teamSummarySessions = subagentParentContext + ? siblingSubagentSessions + : sortedChildSubagentSessions; + const shouldAutoCollapseTeamSection = subagentParentContext + ? siblingSubagentSessions.length > TEAM_SECTION_INITIAL_SIBLING_COUNT + : sortedChildSubagentSessions.length > TEAM_SECTION_INITIAL_CHILD_COUNT; + const teamSectionIdentity = subagentParentContext + ? `child:${subagentParentContext.parent_session_id}:${siblingSubagentSessions + .map((session) => session.id) + .join(",")}` + : `parent:${sortedChildSubagentSessions + .map((session) => session.id) + .join(",")}`; + const teamSectionCollapsed = + teamSectionCollapsedOverride ?? shouldAutoCollapseTeamSection; + const collapsedTeamSummary = useMemo( + () => + buildCollapsedTeamSummary( + teamSummarySessions, + subagentParentContext + ? `${siblingSubagentSessions.length} 个同级子代理` + : `${sortedChildSubagentSessions.length} 个子代理`, + ), + [ + siblingSubagentSessions, + sortedChildSubagentSessions, + subagentParentContext, + teamSummarySessions, + ], + ); const filteredTaskItems = useMemo(() => { const keyword = searchKeyword.trim().toLowerCase(); @@ -514,6 +711,24 @@ export const ChatSidebar: React.FC = ({ setShowAllOlder(false); }, [searchKeyword, statusFilter]); + useEffect(() => { + setTeamSectionCollapsedOverride(null); + setShowAllChildSubagents(false); + setShowAllSiblingSubagents(false); + }, [teamSectionIdentity]); + + useEffect(() => { + if (sortedChildSubagentSessions.length <= TEAM_SECTION_INITIAL_CHILD_COUNT) { + setShowAllChildSubagents(false); + } + }, [sortedChildSubagentSessions.length]); + + useEffect(() => { + if (siblingSubagentSessions.length <= TEAM_SECTION_INITIAL_SIBLING_COUNT) { + setShowAllSiblingSubagents(false); + } + }, [siblingSubagentSessions.length]); + useEffect(() => { if (typeof window === "undefined") { return; @@ -551,6 +766,16 @@ export const ChatSidebar: React.FC = ({ void onSwitchTopic(item.id); }; + const handleJumpToTaskSection = () => { + setTeamSectionCollapsedOverride(true); + setShowAllChildSubagents(false); + setShowAllSiblingSubagents(false); + taskSectionAnchorRef.current?.scrollIntoView({ + block: "start", + behavior: "smooth", + }); + }; + const handleSaveEdit = () => { if (editingTopicId && editTitle.trim() && onRenameTopic) { onRenameTopic(editingTopicId, editTitle.trim()); @@ -572,6 +797,65 @@ export const ChatSidebar: React.FC = ({ } }; + const renderSubagentSessionCard = ( + session: AsterSubagentSessionInfo, + options?: { + highlightCurrent?: boolean; + subtitle?: string; + }, + ) => { + const statusMeta = resolveSubagentStatusMeta(session.runtime_status); + const updatedAt = resolveUnixDate(session.updated_at); + const canOpen = Boolean(onOpenSubagentSession); + + return ( + + ); + }; + return (
-
-
- 任务 -
-
- {searchKeyword.trim() - ? `${filteredTaskItems.length} 条结果` - : `${topics.length} 条`} -
-
+
+
+ {shouldShowTeamSection ? ( +
+
+
+
+ +
+
+
+ Team Runtime +
+

+ {teamSectionCollapsed + ? collapsedTeamSummary + : subagentParentContext + ? "当前线程来自父会话,可直接返回主线程并切换同级子代理。" + : "这里展示真实 child session,而不是 synthetic timeline。"} +

+
+
+
+ + {subagentParentContext + ? "子线程" + : `${sortedChildSubagentSessions.length} 个子代理`} + + {hasAnyTasks ? ( + + ) : null} + +
+
-
- {!hasAnyTasks ? ( -
-
- + {teamSectionCollapsed ? null : subagentParentContext ? ( +
+ + +
+
+
+ {currentTaskItem?.title || "当前子代理"} +
+ + 当前子代理 + +
+
+ 来自父会话委派 + {subagentParentContext.role_hint ? ( + 角色 · {subagentParentContext.role_hint} + ) : null} + {currentTaskItem?.updatedAt ? ( + + 更新于 {formatRelativeTime(currentTaskItem.updatedAt)} + + ) : null} +
+ {subagentParentContext.task_summary ? ( +

+ {subagentParentContext.task_summary} +

+ ) : null} +
+ + {visibleSiblingSubagentSessions.length > 0 ? ( +
+
+
+ 同级子代理 +
+
+ {siblingSubagentSessions.length} 个 +
+
+ {visibleSiblingSubagentSessions.map((session) => + renderSubagentSessionCard(session), + )} + {hiddenSiblingSubagentCount > 0 ? ( + + ) : null} + {showAllSiblingSubagents && + siblingSubagentSessions.length > + TEAM_SECTION_INITIAL_SIBLING_COUNT ? ( + + ) : null} +
+ ) : null} +
+ ) : ( +
+ {visibleChildSubagentSessions.map((session) => + renderSubagentSessionCard(session, { + highlightCurrent: session.id === currentTopicId, + }), + )} + {hiddenChildSubagentCount > 0 ? ( + + ) : null} + {showAllChildSubagents && + sortedChildSubagentSessions.length > + TEAM_SECTION_INITIAL_CHILD_COUNT ? ( + + ) : null} +
+ )} +
+ ) : null} + +
+
+ 任务
-
- 还没有任务 +
+ {searchKeyword.trim() + ? `${filteredTaskItems.length} 条结果` + : `${topics.length} 条`}
-

- 从“新建任务”开始输入需求,创建后会出现在这里。 -

- ) : !hasFilteredResults ? ( -
-
- 没有匹配的任务 + + {!hasAnyTasks ? ( +
+
+ +
+
+ 还没有任务 +
+

+ 从“新建任务”开始输入需求,创建后会出现在这里。 +

-

- 试试搜索标题、执行摘要或状态关键词。 -

-
- ) : ( -
- {sections.map((section) => { + ) : !hasFilteredResults ? ( +
+
+ 没有匹配的任务 +
+

+ 试试搜索标题、执行摘要或状态关键词。 +

+
+ ) : ( +
+ {sections.map((section) => { const isOlderSection = section.key === "older"; const isResumableSection = section.key === "resumable"; const isSectionCollapsed = isResumableSection @@ -1008,9 +1495,10 @@ export const ChatSidebar: React.FC = ({ )} ); - })} -
- )} + })} +
+ )} +
diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index cc1a328cc..41d9dfb4b 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -31,6 +31,7 @@ import { import { buildRecommendationPrompt, getContextualRecommendations, + isTeamRuntimeRecommendation, } from "../utils/contextualRecommendations"; import { EmptyStateComposerPanel } from "./EmptyStateComposerPanel"; import { EmptyStateHero } from "./EmptyStateHero"; @@ -49,6 +50,7 @@ import { useActiveSkill } from "./Inputbar/hooks/useActiveSkill"; import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; import type { MessageImage } from "../types"; +import type { TeamDefinition } from "../utils/teamDefinitions"; import { isGeneralResearchTheme } from "../utils/generalAgentPrompt"; import { getClipboardImageCandidates, @@ -60,8 +62,34 @@ import capabilitySkillsPlaceholder from "@/assets/claw-home/capability-skills-pl import capabilityAutomationsPlaceholder from "@/assets/claw-home/capability-automations-placeholder.svg"; import capabilityAgentTeamsPlaceholder from "@/assets/claw-home/capability-agent-teams-placeholder.svg"; import capabilityBrowserAssistPlaceholder from "@/assets/claw-home/capability-browser-assist-placeholder.svg"; +import type { ModelSelectorProps } from "@/components/input-kit"; const SOCIAL_ARTICLE_SKILL_KEY = "social_post_with_cover"; +const CONFIG_LOAD_IDLE_TIMEOUT_MS = 1_500; +const CONFIG_LOAD_FALLBACK_DELAY_MS = 180; + +function scheduleDeferredConfigLoad(task: () => void): () => void { + if (typeof window === "undefined") { + task(); + return () => undefined; + } + + if (typeof window.requestIdleCallback === "function") { + const idleId = window.requestIdleCallback(() => task(), { + timeout: CONFIG_LOAD_IDLE_TIMEOUT_MS, + }); + return () => { + if (typeof window.cancelIdleCallback === "function") { + window.cancelIdleCallback(idleId); + } + }; + } + + const timeoutId = window.setTimeout(task, CONFIG_LOAD_FALLBACK_DELAY_MS); + return () => { + window.clearTimeout(timeoutId); + }; +} const backgroundOrbDrift = keyframes` 0%, 100% { @@ -171,6 +199,9 @@ interface EmptyStateProps { onTaskEnabledChange?: (enabled: boolean) => void; subagentEnabled?: boolean; onSubagentEnabledChange?: (enabled: boolean) => void; + selectedTeam?: TeamDefinition | null; + onSelectTeam?: (team: TeamDefinition | null) => void; + onEnableSuggestedTeam?: (suggestedPresetId?: string) => void; hasCanvasContent?: boolean; hasContentId?: boolean; selectedText?: string; @@ -196,6 +227,14 @@ interface EmptyStateProps { onProjectChange?: (projectId: string) => void; /** 打开设置 */ onOpenSettings?: () => void; + /** 是否跳过首页项目选择器的默认项目目录检查 */ + skipProjectSelectorWorkspaceReadyCheck?: boolean; + /** 是否延后首页项目列表加载到展开时 */ + deferProjectSelectorListLoad?: boolean; + /** 模型选择器后台预加载策略 */ + modelSelectorBackgroundPreload?: ModelSelectorProps["backgroundPreload"]; + /** 配置读取策略 */ + configLoadStrategy?: "immediate" | "idle"; } const ENTRY_THEME_ID = "social-media"; @@ -269,8 +308,7 @@ const THEME_WORKBENCH_COPY: Record< > = { general: { title: "青柠一下,灵感即来", - description: - "从一句想法,到成稿、成图、成片、成事。", + description: "从一句想法,到成稿、成图、成片、成事。", supportingDescription: "Claw 工作台会围绕一个目标持续对话、检索网页、补充素材,并把结果沉淀到右侧画布,而不是只停留在一次性提问。", }, @@ -349,6 +387,9 @@ export const EmptyState: React.FC = ({ onTaskEnabledChange, subagentEnabled = false, onSubagentEnabledChange, + selectedTeam = null, + onSelectTeam, + onEnableSuggestedTeam, hasCanvasContent = false, hasContentId = false, selectedText = "", @@ -363,6 +404,10 @@ export const EmptyState: React.FC = ({ projectId = null, onProjectChange, onOpenSettings, + skipProjectSelectorWorkspaceReadyCheck = false, + deferProjectSelectorListLoad = false, + modelSelectorBackgroundPreload = "immediate", + configLoadStrategy = "immediate", }) => { const { activeSkill, setActiveSkill, clearActiveSkill, wrapTextWithSkill } = useActiveSkill(); @@ -392,11 +437,27 @@ export const EmptyState: React.FC = ({ console.error("加载主题配置失败:", e); } }; - loadConfigPreferences(); + let cancelPendingLoad: () => void = () => undefined; + + if (configLoadStrategy === "idle") { + cancelPendingLoad = scheduleDeferredConfigLoad(() => { + void loadConfigPreferences(); + }); + } else { + void loadConfigPreferences(); + } // 监听配置变更事件 const handleConfigChange = () => { - loadConfigPreferences(); + if (configLoadStrategy === "idle") { + cancelPendingLoad(); + cancelPendingLoad = scheduleDeferredConfigLoad(() => { + void loadConfigPreferences(); + }); + return; + } + + void loadConfigPreferences(); }; window.addEventListener("theme-config-changed", handleConfigChange); window.addEventListener( @@ -410,8 +471,9 @@ export const EmptyState: React.FC = ({ "chat-appearance-config-changed", handleConfigChange, ); + cancelPendingLoad(); }; - }, []); + }, [configLoadStrategy]); // 过滤后的主题列表 const categories = ALL_CATEGORIES.filter((cat) => @@ -495,6 +557,7 @@ export const EmptyState: React.FC = ({ hasCanvasContent, hasContentId, selectedText: recommendationSelectedText, + subagentEnabled, }); }, [ activeTheme, @@ -505,6 +568,7 @@ export const EmptyState: React.FC = ({ hasCanvasContent, hasContentId, recommendationSelectedText, + subagentEnabled, ]); const selectedTextPreview = useMemo(() => { @@ -683,6 +747,13 @@ export const EmptyState: React.FC = ({ shortLabel: string, fullPrompt: string, ) => { + const looksLikeTeamRuntimePrompt = + activeTheme === "general" && + isTeamRuntimeRecommendation(shortLabel, fullPrompt); + if (looksLikeTeamRuntimePrompt) { + onSubagentEnabledChange?.(true); + } + const promptWithSelection = buildRecommendationPrompt( fullPrompt, selectedText, @@ -963,8 +1034,8 @@ export const EmptyState: React.FC = ({ [activeTheme, currentRecommendations], ); - const quickStartPresets = useMemo( - () => [ + const quickStartPresets = useMemo(() => { + const presets = [ { key: "generate-image", label: "生成配图", @@ -1014,9 +1085,10 @@ export const EmptyState: React.FC = ({ prompt: "请先进入研究模式,帮我围绕当前主题做信息收集、观点归纳、风险点识别和结论总结。", }, - ], - [], - ); + ]; + + return presets; + }, []); const composerPanel = ( = ({ executionStrategyLabel={executionStrategyLabel} setExecutionStrategy={setExecutionStrategy} onManageProviders={onManageProviders} + modelSelectorBackgroundPreload={modelSelectorBackgroundPreload} isGeneralTheme={isGeneralTheme} isEntryTheme={isEntryTheme} entryTaskType={entryTaskType} @@ -1073,6 +1146,9 @@ export const EmptyState: React.FC = ({ onTaskEnabledChange={onTaskEnabledChange} subagentEnabled={subagentEnabled} onSubagentEnabledChange={onSubagentEnabledChange} + selectedTeam={selectedTeam} + onSelectTeam={onSelectTeam} + onEnableSuggestedTeam={onEnableSuggestedTeam} webSearchEnabled={webSearchEnabled} onWebSearchEnabledChange={onWebSearchEnabledChange} pendingImages={pendingImages} @@ -1097,44 +1173,47 @@ export const EmptyState: React.FC = ({ /> ); - const headerControls = - onProjectChange ? ( -
-
- - {onOpenSettings ? ( - <> - + const headerControls = onProjectChange ? ( +
+
+ + {onOpenSettings ? ( + <> + - ) : null; +
+ ) : null; return ( diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx index 1fd1adc6c..10e3c5c53 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx @@ -20,6 +20,10 @@ vi.mock("./Inputbar/components/SkillSelector", () => ({ SkillSelector: () =>
, })); +vi.mock("./Inputbar/components/TeamSelector", () => ({ + TeamSelector: () =>
, +})); + const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; beforeEach(() => { @@ -166,4 +170,63 @@ describe("EmptyStateComposerPanel", () => { expect(onRemoveImage).toHaveBeenCalledWith(0); }); + + it("复杂任务应显示 Team 建议并支持开启多代理", () => { + const onSubagentEnabledChange = vi.fn(); + const container = renderPanel({ + isGeneralTheme: true, + input: + "请帮我分析这个 Rust GUI 多代理实现差异,拆分任务并行推进,再补回归测试和最终汇总结论。", + onSubagentEnabledChange, + }); + + expect(container.textContent).toContain("当前任务更适合 Team 协作"); + expect(container.textContent).toContain("建议角色:分析"); + + const enableTeamButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("启用 Team")); + + expect(enableTeamButton).toBeTruthy(); + + act(() => { + enableTeamButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onSubagentEnabledChange).toHaveBeenCalledWith(true); + }); + + it("继续单代理后应隐藏当前输入对应的 Team 建议", () => { + const container = renderPanel({ + isGeneralTheme: true, + input: + "请把任务拆成多个子任务分别分析、实现、验证,并在最后统一汇总输出。", + onSubagentEnabledChange: vi.fn(), + }); + + const continueButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("继续单代理"), + ); + + expect(continueButton).toBeTruthy(); + + act(() => { + continueButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + expect(container.textContent).not.toContain("当前任务更适合 Team 协作"); + }); + + it("开启 Team mode 后应显示 TeamSelector", () => { + const container = renderPanel({ + isGeneralTheme: true, + subagentEnabled: true, + }); + + expect( + container.querySelector('[data-testid="empty-state-team-selector"]'), + ).toBeTruthy(); + }); }); diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx index 217ab55bb..53c59309f 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from "react"; +import React, { useMemo, useRef, useState } from "react"; import styled, { keyframes } from "styled-components"; import { ArrowRight, @@ -30,9 +30,11 @@ import { } from "@/components/ui/popover"; import { Badge } from "@/components/ui/badge"; import { ChatModelSelector } from "./ChatModelSelector"; +import { TeamSuggestionBar } from "./TeamSuggestionBar"; import { CharacterMention } from "./Inputbar/components/CharacterMention"; import { SkillBadge } from "./Inputbar/components/SkillBadge"; import { SkillSelector } from "./Inputbar/components/SkillSelector"; +import { TeamSelector } from "./Inputbar/components/TeamSelector"; import { CREATION_MODE_CONFIG } from "./constants"; import type { CreationMode, @@ -43,6 +45,7 @@ import type { import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; import type { MessageImage } from "../types"; +import type { TeamDefinition } from "../utils/teamDefinitions"; import iconXhs from "@/assets/platforms/xhs.png"; import iconGzh from "@/assets/platforms/gzh.png"; @@ -57,6 +60,8 @@ import { EMPTY_STATE_SELECT_TRIGGER_CLASSNAME, getEmptyStateIconToolButtonClassName, } from "./emptyStateSurfaceTokens"; +import type { ModelSelectorProps } from "@/components/input-kit"; +import { getTeamSuggestion } from "../utils/teamSuggestion"; const composerReveal = keyframes` from { @@ -479,6 +484,7 @@ interface EmptyStateComposerPanelProps { strategy: "react" | "code_orchestrated" | "auto", ) => void; onManageProviders?: () => void; + modelSelectorBackgroundPreload?: ModelSelectorProps["backgroundPreload"]; isGeneralTheme: boolean; isEntryTheme: boolean; entryTaskType: EntryTaskType; @@ -519,6 +525,9 @@ interface EmptyStateComposerPanelProps { onTaskEnabledChange?: (enabled: boolean) => void; subagentEnabled: boolean; onSubagentEnabledChange?: (enabled: boolean) => void; + selectedTeam?: TeamDefinition | null; + onSelectTeam?: (team: TeamDefinition | null) => void; + onEnableSuggestedTeam?: (suggestedPresetId?: string) => void; webSearchEnabled: boolean; onWebSearchEnabledChange?: (enabled: boolean) => void; pendingImages: MessageImage[]; @@ -541,6 +550,7 @@ export function EmptyStateComposerPanel({ executionStrategyLabel, setExecutionStrategy, onManageProviders, + modelSelectorBackgroundPreload = "immediate", isGeneralTheme, isEntryTheme, entryTaskType, @@ -581,6 +591,9 @@ export function EmptyStateComposerPanel({ onTaskEnabledChange, subagentEnabled, onSubagentEnabledChange, + selectedTeam, + onSelectTeam, + onEnableSuggestedTeam, webSearchEnabled, onWebSearchEnabledChange, pendingImages, @@ -590,6 +603,9 @@ export function EmptyStateComposerPanel({ }: EmptyStateComposerPanelProps) { const textareaRef = useRef(null); const imageInputRef = useRef(null); + const [dismissedSuggestionKey, setDismissedSuggestionKey] = useState< + string | null + >(null); const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { @@ -601,6 +617,31 @@ export function EmptyStateComposerPanel({ const getPlatformIcon = (value: string) => PLATFORM_ICON_MAP[value]; const getPlatformLabel = (value: string) => PLATFORM_LABEL_MAP[value] || value; + const suggestionKey = `${activeTheme}:${input.trim().toLowerCase()}`; + const teamSuggestion = useMemo( + () => + getTeamSuggestion({ + input, + activeTheme, + subagentEnabled, + }), + [activeTheme, input, subagentEnabled], + ); + const shouldShowTeamSuggestion = + isGeneralTheme && + Boolean(onSubagentEnabledChange) && + teamSuggestion.shouldSuggest && + dismissedSuggestionKey !== suggestionKey; + + const handleEnableTeamSuggestion = () => { + onSubagentEnabledChange?.(true); + onEnableSuggestedTeam?.(teamSuggestion.suggestedPresetId); + setDismissedSuggestionKey(suggestionKey); + }; + + const handleContinueSingleAgent = () => { + setDismissedSuggestionKey(suggestionKey); + }; return ( @@ -714,6 +755,17 @@ export function EmptyStateComposerPanel({ ) : null} + {shouldShowTeamSuggestion ? ( + + ) : null} + {isGeneralTheme ? ( @@ -728,6 +780,14 @@ export function EmptyStateComposerPanel({ onRefreshSkills={onRefreshSkills} /> ) : null} + {subagentEnabled ? ( + onSelectTeam?.(team)} + /> + ) : null} {activeTheme === "social-media" ? ( diff --git a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx index 4d8f19387..7a8a90249 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.test.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.test.tsx @@ -1,6 +1,7 @@ import { act, type ComponentProps } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentRuntimeToolInventory } from "@/lib/api/agentRuntime"; import { HarnessStatusPanel } from "./HarnessStatusPanel"; import type { HarnessSessionState } from "../utils/harnessState"; @@ -65,12 +66,14 @@ function renderPanel( root.render( { ( globalThis as typeof globalThis & { @@ -154,8 +327,26 @@ describe("HarnessStatusPanel", () => { expect(document.body.textContent).not.toContain("收起详情"); expect(panel?.className).toContain("flex"); expect(panel?.className).toContain("h-full"); + expect(panel?.children.length).toBe(2); expect(scrollArea?.className).toContain("flex-1"); expect(scrollArea?.className).toContain("min-h-0"); + expect(panel?.querySelector(".sticky.top-0")).toBeNull(); + }); + + it("弹窗模式应让前置概览跟随滚动区,而不是固定在顶部", () => { + const { container } = renderPanel({ + layout: "dialog", + leadContent:
通用 Agent 运行概览
, + }); + const panel = container.querySelector( + '[data-testid="harness-status-panel"]', + ) as HTMLDivElement | null; + const scrollArea = container.querySelector( + '[data-testid="harness-status-panel"] > .relative.overflow-auto', + ) as HTMLDivElement | null; + + expect(panel?.children.length).toBe(2); + expect(scrollArea?.textContent).toContain("通用 Agent 运行概览"); }); it("应支持自定义标题说明与前置运行概览内容", () => { @@ -209,6 +400,70 @@ describe("HarnessStatusPanel", () => { expect(document.body.textContent).toContain("等待首个模型事件"); }); + it("存在真实 child session 时应优先展示 Team 会话摘要,并将旧 scheduler 降级为兼容轨迹", () => { + renderPanel({ + childSubagentSessions: [ + { + id: "child-1", + name: "研究代理", + created_at: 1_710_000_000, + updated_at: 1_710_000_200, + session_type: "sub_agent", + runtime_status: "running", + latest_turn_status: "running", + task_summary: "并行整理竞品与证据链", + role_hint: "explorer", + }, + { + id: "child-2", + name: "实现代理", + created_at: 1_710_000_010, + updated_at: 1_710_000_220, + session_type: "sub_agent", + runtime_status: "queued", + latest_turn_status: "queued", + task_summary: "起草第一版落地方案", + role_hint: "executor", + }, + ], + compatSubagentRuntime: { + isRunning: true, + progress: { + total: 2, + completed: 1, + failed: 0, + running: 1, + pending: 0, + skipped: 0, + cancelled: false, + currentTasks: ["legacy-task-1"], + percentage: 50, + }, + events: [{ type: "started", totalTasks: 2 }], + result: null, + error: null, + recentActivity: [ + { + id: "compat:1:started", + summary: "开始调度 2 个子任务", + }, + ], + hasSignals: true, + }, + }); + + expect(document.body.textContent).toContain("Team 运行中"); + expect(document.body.textContent).toContain("Team 会话"); + expect(document.body.textContent).toContain("当前 Team 会话"); + expect(document.body.textContent).toContain("真实 Team 会话"); + expect(document.body.textContent).toContain("兼容回退"); + expect(document.body.textContent).toContain("Fallback"); + expect(document.body.textContent).not.toContain("兼容调度进度"); + expect(document.body.textContent).not.toContain("兼容调度轨迹"); + expect(document.body.textContent).toContain("研究代理"); + expect(document.body.textContent).toContain("实现代理"); + }); + it("仅有计划摘要兜底时也应在工作台显示已就绪计划状态", () => { renderPanel({ harnessState: createHarnessState({ @@ -735,21 +990,17 @@ describe("HarnessStatusPanel", () => { const copyPathButton = Array.from( document.body.querySelectorAll("button"), ).find((button) => button.textContent?.includes("复制路径")); - const revealButton = Array.from(document.body.querySelectorAll("button")).find( - (button) => button.textContent?.includes("定位文件"), - ); + const revealButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("定位文件")); const openPathButton = Array.from( document.body.querySelectorAll("button"), ).find((button) => button.textContent?.includes("系统打开")); await act(async () => { - copyPathButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); + copyPathButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); revealButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - openPathButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); + openPathButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await Promise.resolve(); }); @@ -850,4 +1101,65 @@ describe("HarnessStatusPanel", () => { expect(onOpenPath).toHaveBeenCalledWith("/tmp/workspace/context/brief.md"); }); + + it("存在工具库存时应展示工具与权限区块及来源统计", () => { + renderPanel({ + toolInventory: createToolInventory(), + }); + + expect(document.body.textContent).toContain("工具与权限"); + expect(document.body.textContent).toContain("工具库存"); + expect(document.body.textContent).toContain("运行时覆盖"); + expect(document.body.textContent).toContain("持久化覆盖"); + expect(document.body.textContent).toContain("默认策略"); + expect(document.body.textContent).toContain("Catalog 工具"); + }); + + it("工具库存应支持按来源筛选 catalog 条目", () => { + renderPanel({ + toolInventory: createToolInventory(), + }); + + const runtimeFilterButton = document.body.querySelector( + 'button[aria-label="工具库存筛选:运行时覆盖"]', + ) as HTMLButtonElement | null; + + act(() => { + runtimeFilterButton?.click(); + }); + + const inventorySection = document.body.querySelector( + '[data-harness-section="inventory"]', + ) as HTMLElement | null; + + expect(inventorySection?.textContent).toContain("Catalog 工具"); + expect(inventorySection?.textContent).toContain("1 / 3"); + expect(inventorySection?.textContent).toContain("bash"); + expect(inventorySection?.textContent).not.toContain("write"); + }); + + it("工具库存加载失败时应展示错误并支持手动刷新", () => { + const onRefreshToolInventory = vi.fn(); + + renderPanel({ + toolInventoryLoading: true, + toolInventoryError: "读取失败", + onRefreshToolInventory, + }); + + expect(document.body.textContent).toContain( + "正在同步当前工具库存与权限策略", + ); + expect(document.body.textContent).toContain("读取失败"); + + const refreshButton = document.body.querySelector( + 'button[aria-label="刷新工具库存"]', + ) as HTMLButtonElement | null; + + act(() => { + refreshButton?.click(); + }); + + expect(onRefreshToolInventory).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/agent/chat/components/HarnessStatusPanel.tsx b/src/components/agent/chat/components/HarnessStatusPanel.tsx index da2763d34..96ea7a180 100644 --- a/src/components/agent/chat/components/HarnessStatusPanel.tsx +++ b/src/components/agent/chat/components/HarnessStatusPanel.tsx @@ -34,10 +34,12 @@ import { } from "lucide-react"; import { toast } from "sonner"; import type { - SchedulerEvent, - SchedulerExecutionResult, - SchedulerProgress, -} from "@/lib/api/subAgentScheduler"; + AgentRuntimeToolInventory, + AgentRuntimeToolInventoryCatalogEntry, + AgentRuntimeToolInventoryRegistryEntry, + AgentToolExecutionPolicySource, + AsterSubagentSessionInfo, +} from "@/lib/api/agentRuntime"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -72,6 +74,7 @@ import { classifySearchQuerySemantic, summarizeSearchQuerySemantics, } from "../utils/searchQueryGrouping"; +import type { CompatSubagentRuntimeSnapshot } from "../utils/compatSubagentRuntime"; interface HarnessEnvironmentSummary { skillsCount: number; @@ -93,19 +96,19 @@ export interface HarnessFilePreviewResult { interface HarnessStatusPanelProps { harnessState: HarnessSessionState; - subAgentRuntime: { - isRunning: boolean; - progress: SchedulerProgress | null; - events: SchedulerEvent[]; - result: SchedulerExecutionResult | null; - error: string | null; - }; + compatSubagentRuntime: CompatSubagentRuntimeSnapshot; environment: HarnessEnvironmentSummary; layout?: "default" | "sidebar" | "dialog"; onLoadFilePreview?: (path: string) => Promise; onOpenFile?: (fileName: string, content: string) => void; onRevealPath?: (path: string) => Promise; onOpenPath?: (path: string) => Promise; + childSubagentSessions?: AsterSubagentSessionInfo[]; + onOpenSubagentSession?: (sessionId: string) => void; + toolInventory?: AgentRuntimeToolInventory | null; + toolInventoryLoading?: boolean; + toolInventoryError?: string | null; + onRefreshToolInventory?: () => void; title?: string; description?: string; toggleLabel?: string; @@ -129,9 +132,11 @@ interface PreviewDialogState { type FileFilterValue = "all" | HarnessFileKind; type OutputFilterValue = "all" | "path" | "offload" | "truncated" | "summary"; type FileDisplayMode = "timeline" | "grouped"; +type ToolInventoryFilterValue = "all" | "runtime" | "persisted" | "default"; type HarnessSectionKey = | "runtime" + | "inventory" | "approvals" | "writes" | "files" @@ -183,6 +188,108 @@ function formatTime(value?: Date): string { }); } +function formatUnixTimestamp(value?: number): string { + if (!value) { + return "未知"; + } + + return new Date(value * 1000).toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function resolveSubagentRuntimeStatusLabel( + status?: AsterSubagentSessionInfo["runtime_status"], +): string { + switch (status) { + case "queued": + return "排队中"; + case "running": + return "运行中"; + case "completed": + return "已完成"; + case "failed": + return "失败"; + case "aborted": + return "已中止"; + case "idle": + default: + return "待开始"; + } +} + +function resolveSubagentRuntimeStatusVariant( + status?: AsterSubagentSessionInfo["runtime_status"], +): ComponentProps["variant"] { + switch (status) { + case "running": + return "default"; + case "completed": + return "secondary"; + case "failed": + case "aborted": + return "destructive"; + case "queued": + case "idle": + default: + return "outline"; + } +} + +function resolveSubagentSessionTypeLabel(value?: string): string { + switch (value) { + case "sub_agent": + return "子代理"; + case "fork": + return "分支会话"; + case "user": + default: + return value?.trim() || "会话"; + } +} + +function summarizeChildSubagentSessions( + sessions: AsterSubagentSessionInfo[], +): { + total: number; + running: number; + queued: number; + active: number; + settled: number; + failed: number; +} { + const running = sessions.filter( + (session) => session.runtime_status === "running", + ).length; + const queued = sessions.filter( + (session) => session.runtime_status === "queued", + ).length; + const failed = sessions.filter( + (session) => + session.runtime_status === "failed" || + session.runtime_status === "aborted", + ).length; + const settled = sessions.filter( + (session) => + session.runtime_status === "completed" || + session.runtime_status === "failed" || + session.runtime_status === "aborted" || + session.runtime_status === "closed", + ).length; + + return { + total: sessions.length, + running, + queued, + active: running + queued, + settled, + failed, + }; +} + function formatSize(value?: number): string | null { if (!value || value <= 0) { return null; @@ -452,6 +559,190 @@ function formatWriteSourceLabel(source?: string): string { } } +function formatExecutionSourceLabel( + source: AgentToolExecutionPolicySource, +): string { + switch (source) { + case "runtime": + return "运行时覆盖"; + case "persisted": + return "持久化覆盖"; + case "default": + default: + return "默认策略"; + } +} + +function resolveExecutionSourceVariant( + source: AgentToolExecutionPolicySource, +): ComponentProps["variant"] { + switch (source) { + case "runtime": + return "default"; + case "persisted": + return "secondary"; + case "default": + default: + return "outline"; + } +} + +function formatExecutionWarningPolicyLabel(value: string): string { + switch (value) { + case "shell_command_risk": + return "命令风险告警"; + case "none": + default: + return "无告警"; + } +} + +function formatExecutionRestrictionProfileLabel(value: string): string { + switch (value) { + case "workspace_path_required": + return "必须提供工作区路径"; + case "workspace_path_optional": + return "可选工作区路径"; + case "workspace_absolute_path_required": + return "必须提供绝对工作区路径"; + case "workspace_shell_command": + return "工作区命令限制"; + case "analyze_image_input": + return "仅图像输入"; + case "safe_https_url_required": + return "仅安全 HTTPS URL"; + case "none": + default: + return "无额外限制"; + } +} + +function formatExecutionSandboxProfileLabel(value: string): string { + switch (value) { + case "workspace_command": + return "工作区命令沙箱"; + case "none": + default: + return "无沙箱"; + } +} + +function formatToolLifecycleLabel(value: string): string { + switch (value) { + case "current": + return "现役"; + case "compat": + return "兼容"; + case "deprecated": + return "待清理"; + default: + return value; + } +} + +function formatToolPermissionPlaneLabel(value: string): string { + switch (value) { + case "session_allowlist": + return "会话白名单"; + case "parameter_restricted": + return "参数受限"; + case "caller_filtered": + return "调用方过滤"; + default: + return value; + } +} + +function formatToolSourceKindLabel(value: string): string { + switch (value) { + case "aster_builtin": + return "Aster 内置"; + case "lime_injected": + return "Lime 注入"; + case "browser_compatibility": + return "Browser Assist"; + default: + return value; + } +} + +function formatExtensionSourceKindLabel(value: string): string { + switch (value) { + case "mcp_bridge": + return "MCP Bridge"; + case "runtime_extension": + return "Runtime Extension"; + default: + return value; + } +} + +function collectCatalogExecutionSources( + entry: AgentRuntimeToolInventoryCatalogEntry, +): AgentToolExecutionPolicySource[] { + return [ + entry.execution_warning_policy_source, + entry.execution_restriction_profile_source, + entry.execution_sandbox_profile_source, + ]; +} + +function collectRegistryExecutionSources( + entry: AgentRuntimeToolInventoryRegistryEntry, +): AgentToolExecutionPolicySource[] { + return [ + entry.catalog_execution_warning_policy_source, + entry.catalog_execution_restriction_profile_source, + entry.catalog_execution_sandbox_profile_source, + ].filter((value): value is AgentToolExecutionPolicySource => Boolean(value)); +} + +function matchesCatalogToolInventoryFilter( + entry: AgentRuntimeToolInventoryCatalogEntry, + filter: ToolInventoryFilterValue, +): boolean { + const sources = collectCatalogExecutionSources(entry); + + switch (filter) { + case "runtime": + return sources.includes("runtime"); + case "persisted": + return sources.includes("persisted"); + case "default": + return sources.every((source) => source === "default"); + case "all": + default: + return true; + } +} + +function countCatalogToolsByInventoryFilter( + catalogTools: AgentRuntimeToolInventoryCatalogEntry[], + filter: ToolInventoryFilterValue, +): number { + return catalogTools.filter((entry) => + matchesCatalogToolInventoryFilter(entry, filter), + ).length; +} + +function buildToolInventorySourceStats( + catalogTools: AgentRuntimeToolInventoryCatalogEntry[], +): Record { + const stats: Record = { + default: 0, + persisted: 0, + runtime: 0, + }; + + for (const entry of catalogTools) { + for (const source of collectCatalogExecutionSources(entry)) { + stats[source] += 1; + } + } + + return stats; +} + function getActiveWriteDescription(write: HarnessActiveFileWrite): string { const parts = [ formatArtifactWritePhaseLabel(write.phase), @@ -462,31 +753,6 @@ function getActiveWriteDescription(write: HarnessActiveFileWrite): string { return parts.join(" · "); } -function summarizeSchedulerEvent(event: SchedulerEvent): string { - switch (event.type) { - case "started": - return `开始调度 ${event.totalTasks} 个子任务`; - case "taskStarted": - return `任务 ${event.taskId} 开始执行`; - case "taskCompleted": - return `任务 ${event.taskId} 已完成`; - case "taskFailed": - return `任务 ${event.taskId} 失败:${event.error}`; - case "taskRetry": - return `任务 ${event.taskId} 重试第 ${event.retryCount} 次`; - case "taskSkipped": - return `任务 ${event.taskId} 已跳过:${event.reason}`; - case "progress": - return `进度 ${event.progress.completed}/${event.progress.total}`; - case "completed": - return `调度完成,耗时 ${Math.round(event.durationMs / 1000)} 秒`; - case "cancelled": - return "调度已取消"; - default: - return (event as { type: string }).type; - } -} - async function openExternalUrl(url: string): Promise { try { await openExternal(url); @@ -895,6 +1161,132 @@ function SummaryCard({ ); } +function CompatSubagentFallbackCard({ + snapshot, + condensed = false, + onOpenUrl, +}: { + snapshot: CompatSubagentRuntimeSnapshot; + condensed?: boolean; + onOpenUrl: (url: string) => void | Promise; +}) { + if (!snapshot.hasSignals) { + return null; + } + + const statusVariant: ComponentProps["variant"] = snapshot.error + ? "destructive" + : snapshot.isRunning + ? "secondary" + : "outline"; + const statusLabel = snapshot.error + ? "异常" + : snapshot.isRunning + ? snapshot.progress + ? `${snapshot.progress.completed}/${snapshot.progress.total}` + : "运行中" + : snapshot.result + ? "已结束" + : `${snapshot.recentActivity.length} 条`; + const primarySummary = snapshot.progress + ? `进度 ${snapshot.progress.completed}/${snapshot.progress.total}${ + snapshot.progress.currentTasks.length > 0 + ? ` · 当前任务 ${snapshot.progress.currentTasks.join("、")}` + : "" + }` + : snapshot.recentActivity[0]?.summary || + snapshot.error || + snapshot.result?.mergedSummary || + "检测到兼容调度信号"; + const visibleActivity = condensed + ? snapshot.recentActivity.slice(0, 1) + : snapshot.recentActivity.slice(0, 3); + + return ( +
+
+
+
+ +
兼容回退
+ Fallback +
+
+ 仅用于承接旧 scheduler 信号,不作为 Team 主事实源。 +
+
+ {statusLabel} +
+ +
+ + + {visibleActivity.length > 0 && + visibleActivity[0]?.summary !== primarySummary ? ( +
+
+ 最近兼容轨迹 +
+
+ {visibleActivity.map((item) => ( + + ))} +
+
+ ) : null} + + {!condensed && + snapshot.result?.mergedSummary && + snapshot.result.mergedSummary !== primarySummary ? ( +
+
+ 兼容汇总 +
+ +
+ ) : null} + + {!condensed && snapshot.error && snapshot.error !== primarySummary ? ( +
+ +
+ ) : null} +
+
+ ); +} + +function InventoryStatCard({ + title, + value, + hint, +}: { + title: string; + value: string; + hint: string; +}) { + return ( +
+
{title}
+
{value}
+
{hint}
+
+ ); +} + function Section({ sectionKey, title, @@ -927,13 +1319,19 @@ function Section({ export function HarnessStatusPanel({ harnessState, - subAgentRuntime, + compatSubagentRuntime, environment, layout = "default", onLoadFilePreview, onOpenFile, onRevealPath, onOpenPath, + childSubagentSessions = [], + onOpenSubagentSession, + toolInventory, + toolInventoryLoading = false, + toolInventoryError = null, + onRefreshToolInventory, title = "Harness 运行面板", description = "展示最近文件活动、工具输出、审批与上下文装载情况。", toggleLabel = "详情", @@ -946,6 +1344,8 @@ export function HarnessStatusPanel({ const [outputFilter, setOutputFilter] = useState("all"); const [fileDisplayMode, setFileDisplayMode] = useState("timeline"); + const [toolInventoryFilter, setToolInventoryFilter] = + useState("all"); const [previewDialog, setPreviewDialog] = useState({ open: false, title: "", @@ -973,10 +1373,24 @@ export function HarnessStatusPanel({ target.scrollIntoView({ behavior: "smooth", block: "start" }); }, []); - const recentSchedulerEvents = useMemo( - () => subAgentRuntime.events.slice(-4).reverse(), - [subAgentRuntime.events], + const hasToolInventorySection = + toolInventoryLoading || Boolean(toolInventoryError) || Boolean(toolInventory); + const toolInventorySourceStats = useMemo( + () => buildToolInventorySourceStats(toolInventory?.catalog_tools || []), + [toolInventory], ); + const filteredCatalogTools = useMemo( + () => + (toolInventory?.catalog_tools || []).filter((entry) => + matchesCatalogToolInventoryFilter(entry, toolInventoryFilter), + ), + [toolInventory, toolInventoryFilter], + ); + const realTeamSummary = useMemo( + () => summarizeChildSubagentSessions(childSubagentSessions), + [childSubagentSessions], + ); + const hasCompatSchedulerSignals = compatSubagentRuntime.hasSignals; const fileFilterOptions = useMemo( () => @@ -1124,6 +1538,9 @@ export function HarnessStatusPanel({ if (harnessState.outputSignals.length > 0) { sections.push({ key: "outputs", label: "工具输出" }); } + if (hasToolInventorySection) { + sections.push({ key: "inventory", label: "工具与权限" }); + } if (harnessState.pendingApprovals.length > 0) { sections.push({ key: "approvals", label: "待审批" }); } @@ -1137,11 +1554,9 @@ export function HarnessStatusPanel({ sections.push({ key: "plan", label: "规划状态" }); } if ( - subAgentRuntime.isRunning || + realTeamSummary.total > 0 || harnessState.delegatedTasks.length > 0 || - recentSchedulerEvents.length > 0 || - subAgentRuntime.error || - subAgentRuntime.result + hasCompatSchedulerSignals ) { sections.push({ key: "delegation", label: "子任务委派" }); } @@ -1156,6 +1571,7 @@ export function HarnessStatusPanel({ return sections; }, [ environment.skillsCount, + hasToolInventorySection, harnessState.delegatedTasks.length, harnessState.activeFileWrites.length, harnessState.latestContextTrace.length, @@ -1165,10 +1581,8 @@ export function HarnessStatusPanel({ harnessState.plan.phase, harnessState.recentFileEvents.length, harnessState.runtimeStatus, - recentSchedulerEvents.length, - subAgentRuntime.error, - subAgentRuntime.isRunning, - subAgentRuntime.result, + hasCompatSchedulerSignals, + realTeamSummary.total, ]); const summaryCards = useMemo(() => { @@ -1196,6 +1610,40 @@ export function HarnessStatusPanel({ }); } + if (realTeamSummary.total > 0) { + cards.push({ + sectionKey: "delegation", + title: "Team 会话", + value: + realTeamSummary.active > 0 + ? `${realTeamSummary.active}/${realTeamSummary.total}` + : `${realTeamSummary.total}`, + hint: + realTeamSummary.active > 0 + ? `运行 ${realTeamSummary.running} · 排队 ${realTeamSummary.queued} · 已收敛 ${realTeamSummary.settled}` + : `已收敛 ${realTeamSummary.settled} · 失败 ${realTeamSummary.failed}`, + icon: Workflow, + }); + } + + if (hasToolInventorySection) { + cards.push({ + sectionKey: "inventory", + title: "工具库存", + value: toolInventoryLoading + ? "同步中" + : toolInventory + ? `${toolInventory.counts.registry_visible_total}` + : "异常", + hint: toolInventoryError + ? toolInventoryError + : toolInventory + ? `catalog ${toolInventory.counts.catalog_total} · MCP 可见 ${toolInventory.counts.mcp_tool_visible_total}` + : "等待拉取运行时库存", + icon: Wrench, + }); + } + cards.push( { sectionKey: "approvals", @@ -1246,6 +1694,7 @@ export function HarnessStatusPanel({ environment.activeContextCount, environment.contextEnabled, environment.contextItemsCount, + hasToolInventorySection, harnessState.activeFileWrites, harnessState.pendingApprovals.length, harnessState.plan.items, @@ -1253,6 +1702,15 @@ export function HarnessStatusPanel({ harnessState.plan.summaryText, harnessState.recentFileEvents, harnessState.runtimeStatus, + realTeamSummary.active, + realTeamSummary.failed, + realTeamSummary.queued, + realTeamSummary.running, + realTeamSummary.settled, + realTeamSummary.total, + toolInventory, + toolInventoryError, + toolInventoryLoading, ]); const openPreview = useCallback( @@ -1473,10 +1931,15 @@ export function HarnessStatusPanel({

{title}

- {subAgentRuntime.isRunning ? ( + {realTeamSummary.active > 0 ? ( - 子任务运行中 + Team 运行中 + + ) : compatSubagentRuntime.isRunning ? ( + + + 兼容调度中 ) : null}
@@ -1504,7 +1967,7 @@ export function HarnessStatusPanel({ ) : null}
- {leadContent ? ( + {!isDialogLayout && leadContent ? (
) : null} -
- {summaryCards.map((card) => ( - scrollToSection(card.sectionKey)} - compact={isDialogLayout} - /> - ))} -
+ )} + > + {summaryCards.map((card) => ( + scrollToSection(card.sectionKey)} + compact={false} + /> + ))} +
+ ) : null} {isDetailsExpanded ? (
+ {isDialogLayout && leadContent ? ( +
{leadContent}
+ ) : null} + + {isDialogLayout ? ( +
+ {summaryCards.map((card) => ( + scrollToSection(card.sectionKey)} + compact={true} + /> + ))} +
+ ) : null} + {availableSections.length > 0 ? ( -
+
{availableSections.map((item) => ( + ) : null} +
+ + {toolInventoryLoading ? ( +
+ + 正在同步当前工具库存与权限策略... +
+ ) : null} + + {toolInventoryError ? ( +
+ {toolInventoryError} +
+ ) : null} + + {toolInventory ? ( + <> +
+ + + + +
+ +
+ {( + [ + ["default", "默认策略"], + ["persisted", "持久化覆盖"], + ["runtime", "运行时覆盖"], + ] as Array< + [AgentToolExecutionPolicySource, string] + > + ).map(([source, label]) => ( + + ))} +
+ + {toolInventory.warnings.length > 0 ? ( +
+
+ 库存告警 +
+
+ {toolInventory.warnings.map((warning, index) => ( +
{warning}
+ ))} +
+
+ ) : null} + +
+
+
+ Catalog 工具 +
+ + {filteredCatalogTools.length} /{" "} + {toolInventory.catalog_tools.length} + +
+
+ {[ + { value: "all" as const, label: "全部" }, + { value: "runtime" as const, label: "运行时覆盖" }, + { + value: "persisted" as const, + label: "持久化覆盖", + }, + { value: "default" as const, label: "纯默认" }, + ].map((option) => { + const active = option.value === toolInventoryFilter; + const count = countCatalogToolsByInventoryFilter( + toolInventory.catalog_tools, + option.value, + ); + + return ( + + ); + })} +
+ + {filteredCatalogTools.length > 0 ? ( + filteredCatalogTools.map((entry) => ( +
+
+
+
+ + {entry.name} + + + {formatToolLifecycleLabel( + entry.lifecycle, + )} + + + {formatToolSourceKindLabel(entry.source)} + + + {formatToolPermissionPlaneLabel( + entry.permission_plane, + )} + + {entry.workspace_default_allow ? ( + + 默认允许 + + ) : null} +
+
+ {entry.profiles.map((profile) => ( + + {profile} + + ))} + {entry.capabilities.map((capability) => ( + + {capability} + + ))} +
+
+
+ +
+
+
+ Warning +
+
+ {formatExecutionWarningPolicyLabel( + entry.execution_warning_policy, + )} +
+
+ + {formatExecutionSourceLabel( + entry.execution_warning_policy_source, + )} + +
+
+
+
+ Restriction +
+
+ {formatExecutionRestrictionProfileLabel( + entry.execution_restriction_profile, + )} +
+
+ + {formatExecutionSourceLabel( + entry.execution_restriction_profile_source, + )} + +
+
+
+
+ Sandbox +
+
+ {formatExecutionSandboxProfileLabel( + entry.execution_sandbox_profile, + )} +
+
+ + {formatExecutionSourceLabel( + entry.execution_sandbox_profile_source, + )} + +
+
+
+
+ )) + ) : ( +
+ 当前筛选条件下暂无 catalog 工具。 +
+ )} +
+ +
+
+ Runtime Registry +
+ {toolInventory.registry_tools.length > 0 ? ( + toolInventory.registry_tools.map((entry) => ( +
+
+
+
+ + {entry.name} + + {entry.catalog_entry_name ? ( + + 映射 {entry.catalog_entry_name} + + ) : ( + + 未映射 catalog + + )} + {entry.visible_in_context ? ( + + 上下文可见 + + ) : null} + {entry.deferred_loading ? ( + + Deferred + + ) : null} + {!entry.caller_allowed ? ( + + Caller 拒绝 + + ) : null} +
+
+ {entry.description} +
+
+ {entry.allowed_callers.length > 0 ? ( + + callers: + {entry.allowed_callers.join(", ")} + + ) : ( + + callers:全部 + + )} + {entry.tags.map((tag) => ( + + {tag} + + ))} + + input_examples: + {entry.input_examples_count} + +
+
+
+ + {collectRegistryExecutionSources(entry).length > 0 ? ( +
+ {entry.catalog_execution_warning_policy && + entry.catalog_execution_warning_policy_source ? ( + + Warning: + {formatExecutionSourceLabel( + entry.catalog_execution_warning_policy_source, + )} + + ) : null} + {entry.catalog_execution_restriction_profile && + entry.catalog_execution_restriction_profile_source ? ( + + Restriction: + {formatExecutionSourceLabel( + entry.catalog_execution_restriction_profile_source, + )} + + ) : null} + {entry.catalog_execution_sandbox_profile && + entry.catalog_execution_sandbox_profile_source ? ( + + Sandbox: + {formatExecutionSourceLabel( + entry.catalog_execution_sandbox_profile_source, + )} + + ) : null} +
+ ) : null} +
+ )) + ) : ( +
+ 当前 runtime registry 为空。 +
+ )} +
+ + {toolInventory.extension_surfaces.length > 0 ? ( +
+
+ Extension Surfaces +
+ {toolInventory.extension_surfaces.map((entry) => ( +
+
+ + {entry.extension_name} + + + {formatExtensionSourceKindLabel( + entry.source_kind, + )} + + {entry.deferred_loading ? ( + Deferred + ) : null} + {entry.allowed_caller ? ( + + caller:{entry.allowed_caller} + + ) : null} +
+
+ {entry.description} +
+
+
+ 可用工具:{entry.available_tools.length} +
+
+ 常驻工具:{entry.always_expose_tools.length} +
+
+ 已加载:{entry.loaded_tools.length} +
+
+ 可搜索:{entry.searchable_tools.length} +
+
+
+ ))} +
+ ) : null} + + {toolInventory.extension_tools.length > 0 ? ( +
+
+ Extension Tools +
+ {toolInventory.extension_tools.map((entry) => ( +
+
+ + {entry.name} + + {entry.status} + + {formatExtensionSourceKindLabel( + entry.source_kind, + )} + + {entry.visible_in_context ? ( + + 上下文可见 + + ) : null} + {!entry.caller_allowed ? ( + + Caller 拒绝 + + ) : null} +
+
+ {entry.extension_name ? ( + + extension:{entry.extension_name} + + ) : null} + {entry.allowed_caller ? ( + + caller:{entry.allowed_caller} + + ) : null} + {entry.deferred_loading ? ( + Deferred + ) : null} +
+
+ {entry.description} +
+
+ ))} +
+ ) : null} + + {toolInventory.mcp_tools.length > 0 ? ( +
+
+ MCP Tools +
+ {toolInventory.mcp_tools.map((entry) => ( +
+
+ + {entry.name} + + + {entry.server_name} + + {entry.visible_in_context ? ( + + 上下文可见 + + ) : null} + {entry.always_visible ? ( + + Always Visible + + ) : null} + {entry.deferred_loading ? ( + Deferred + ) : null} + {!entry.caller_allowed ? ( + + Caller 拒绝 + + ) : null} +
+
+ {entry.description} +
+
+ {entry.allowed_callers.length > 0 ? ( + + callers: + {entry.allowed_callers.join(", ")} + + ) : ( + + callers:全部 + + )} + {entry.tags.map((tag) => ( + + {tag} + + ))} + + input_examples: + {entry.input_examples_count} + +
+
+ ))} +
+ ) : null} + + ) : !toolInventoryLoading && !toolInventoryError ? ( +
+ 当前尚未拿到工具库存快照。 +
+ ) : null} +
+ + ) : null} + {harnessState.pendingApprovals.length > 0 ? (
) : null} - {subAgentRuntime.isRunning || + {realTeamSummary.total > 0 || harnessState.delegatedTasks.length > 0 || - recentSchedulerEvents.length > 0 || - subAgentRuntime.error || - subAgentRuntime.result ? ( + hasCompatSchedulerSignals ? (
0 + ? `运行中 ${realTeamSummary.active}` + : realTeamSummary.total > 0 + ? `${realTeamSummary.total} 个会话` + : harnessState.delegatedTasks.length > 0 + ? `${harnessState.delegatedTasks.length} 条` + : undefined } registerRef={registerSectionRef} >
- {subAgentRuntime.progress ? ( + {realTeamSummary.total > 0 ? (
- 调度进度 + 当前 Team 会话
- - {subAgentRuntime.progress.completed}/ - {subAgentRuntime.progress.total} + + {realTeamSummary.total} 个
-
-
+
+ 运行中 {realTeamSummary.running} + 排队中 {realTeamSummary.queued} + 已收敛 {realTeamSummary.settled} + 失败 {realTeamSummary.failed}
- {subAgentRuntime.progress.currentTasks.length > 0 ? ( -
- 当前任务: - -
- ) : null}
) : null} @@ -2254,44 +3323,85 @@ export function HarnessStatusPanel({
))} - {recentSchedulerEvents.length > 0 ? ( -
-
- 最近调度事件 + {childSubagentSessions.length > 0 ? ( +
+
+ 真实 Team 会话
-
- {recentSchedulerEvents.map((event, index) => ( -
- + {childSubagentSessions.map((session) => ( +
+
+
+
+ + + {session.name} + + + {resolveSubagentRuntimeStatusLabel( + session.runtime_status, + )} + +
+
+ + 类型: + {resolveSubagentSessionTypeLabel( + session.session_type, + )} + + {session.role_hint ? ( + 角色:{session.role_hint} + ) : null} + {session.model ? ( + 模型:{session.model} + ) : null} + {session.provider_name ? ( + 提供方:{session.provider_name} + ) : null} + {session.origin_tool ? ( + 来源:{session.origin_tool} + ) : null} + 更新:{formatUnixTimestamp(session.updated_at)} +
+ {session.task_summary ? ( + + ) : null} +
+ {onOpenSubagentSession ? ( + + ) : null}
- ))} -
+
+ ))}
) : null} - {subAgentRuntime.error ? ( -
- -
- ) : null} - - {subAgentRuntime.result?.mergedSummary ? ( -
- -
- ) : null} + 0} + onOpenUrl={handleOpenExternalLink} + />
) : null} diff --git a/src/components/agent/chat/components/Inputbar/components/CharacterMention.test.tsx b/src/components/agent/chat/components/Inputbar/components/CharacterMention.test.tsx index 0608ff985..2048276b3 100644 --- a/src/components/agent/chat/components/Inputbar/components/CharacterMention.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/CharacterMention.test.tsx @@ -208,6 +208,17 @@ function typeAt(textarea: HTMLTextAreaElement) { }); } +async function typeAtAndWait(textarea: HTMLTextAreaElement) { + await act(async () => { + await import("./CharacterMentionPanel"); + }); + + typeAt(textarea); + await act(async () => { + await Promise.resolve(); + }); +} + function createSkill(name: string, key: string, installed: boolean): Skill { return { key, @@ -241,19 +252,19 @@ function createCharacter(name: string): Character { } describe("CharacterMention", () => { - it("输入 @ 当次应弹出提及面板(不依赖受控 value 同步)", () => { + it("输入 @ 当次应弹出提及面板(不依赖受控 value 同步)", async () => { const container = renderHarness({ characters: [createCharacter("测试角色")], syncValue: false, }); const textarea = getTextarea(container); - typeAt(textarea); + await typeAtAndWait(textarea); expect(document.body.textContent).toContain("测试角色"); }); - it("无角色和技能时仍显示空态,并可跳转技能设置", () => { + it("无角色和技能时仍显示空态,并可跳转技能设置", async () => { const onNavigateToSettings = vi.fn<() => void>(); const container = renderHarness({ characters: [], @@ -262,7 +273,7 @@ describe("CharacterMention", () => { }); const textarea = getTextarea(container); - typeAt(textarea); + await typeAtAndWait(textarea); expect(document.body.textContent).toContain("暂无可用角色或技能"); const settingsButton = Array.from(document.body.querySelectorAll("button")).find( @@ -276,7 +287,7 @@ describe("CharacterMention", () => { expect(onNavigateToSettings).toHaveBeenCalledTimes(1); }); - it("未提供 onSelectSkill 时,选择已安装技能应回填到输入框", () => { + it("未提供 onSelectSkill 时,选择已安装技能应回填到输入框", async () => { const onChangeSpy = vi.fn<(value: string) => void>(); const container = renderHarness({ skills: [createSkill("技能A", "skill-a", true)], @@ -284,7 +295,7 @@ describe("CharacterMention", () => { }); const textarea = getTextarea(container); - typeAt(textarea); + await typeAtAndWait(textarea); const skillButton = Array.from(document.body.querySelectorAll("button")).find( (button) => button.textContent?.includes("技能A"), diff --git a/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx b/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx index 596bc3a91..681c0ef49 100644 --- a/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx +++ b/src/components/agent/chat/components/Inputbar/components/CharacterMention.tsx @@ -4,15 +4,15 @@ * 在输入框中检测 @ 符号,显示角色和技能列表供选择 */ -import React, { useState, useEffect, useMemo, useRef, useCallback } from "react"; -import { User, Zap } from "lucide-react"; -import { - Command, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; +import React, { + Suspense, + lazy, + useState, + useEffect, + useMemo, + useRef, + useCallback, +} from "react"; import { Popover, PopoverContent, @@ -21,6 +21,14 @@ import { import type { Character } from "@/lib/api/memory"; import type { Skill } from "@/lib/api/skills"; import { toast } from "sonner"; +import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload"; + +const preloadCharacterMentionPanel = () => import("./CharacterMentionPanel"); + +const CharacterMentionPanel = lazy(async () => { + const module = await preloadCharacterMentionPanel(); + return { default: module.CharacterMentionPanel }; +}); interface CharacterMentionProps { /** 角色列表 */ @@ -57,6 +65,12 @@ export function CharacterMention({ const popoverRef = useRef(null); const commandRef = useRef(null); + useEffect(() => { + return scheduleIdleModulePreload(() => { + void preloadCharacterMentionPanel(); + }); + }, []); + // 过滤角色列表 const filteredCharacters = useMemo(() => { if (!mentionQuery) return characters; @@ -264,11 +278,6 @@ export function CharacterMention({ if (!showMentions) return null; - const hasFilteredResults = - filteredCharacters.length > 0 || - installedSkills.length > 0 || - availableSkills.length > 0; - return ( @@ -291,96 +300,35 @@ export function CharacterMention({ sideOffset={8} onOpenAutoFocus={(e) => e.preventDefault()} > - - - - {!hasFilteredResults && ( + {showMentions ? ( + -
暂无可用角色或技能
- {onNavigateToSettings && ( -
+ } + > + { setShowMentions(false); onNavigateToSettings(); - }} - > - 去技能设置 - - )} -
- )} - {filteredCharacters.length > 0 && ( - - {filteredCharacters.map((character) => ( - handleSelectCharacter(character)} - className="cursor-pointer" - > - -
-
{character.name}
- {character.description && ( -
- {character.description} -
- )} -
-
- ))} -
- )} - {installedSkills.length > 0 && ( - - {installedSkills.map((skill) => ( - handleSelectInstalledSkill(skill)} - className="cursor-pointer" - > - -
-
{skill.name}
- {skill.description && ( -
- {skill.description} -
- )} -
-
- ))} -
- )} - {availableSkills.length > 0 && ( - - {availableSkills.map((skill) => ( - handleSelectAvailableSkill(skill)} - className="cursor-pointer opacity-60" - > - -
-
{skill.name}
- {skill.description && ( -
- {skill.description} -
- )} -
-
- ))} -
- )} - - + } + : undefined + } + /> + + ) : null} ); diff --git a/src/components/agent/chat/components/Inputbar/components/CharacterMentionPanel.tsx b/src/components/agent/chat/components/Inputbar/components/CharacterMentionPanel.tsx new file mode 100644 index 000000000..3e93904a5 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/CharacterMentionPanel.tsx @@ -0,0 +1,132 @@ +import React from "react"; +import { User, Zap } from "lucide-react"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import type { Character } from "@/lib/api/memory"; +import type { Skill } from "@/lib/api/skills"; + +interface CharacterMentionPanelProps { + mentionQuery: string; + filteredCharacters: Character[]; + installedSkills: Skill[]; + availableSkills: Skill[]; + commandRef: React.RefObject; + onQueryChange: (query: string) => void; + onSelectCharacter: (character: Character) => void; + onSelectInstalledSkill: (skill: Skill) => void; + onSelectAvailableSkill: (skill: Skill) => void; + onNavigateToSettings?: () => void; +} + +export const CharacterMentionPanel: React.FC = ({ + mentionQuery, + filteredCharacters, + installedSkills, + availableSkills, + commandRef, + onQueryChange, + onSelectCharacter, + onSelectInstalledSkill, + onSelectAvailableSkill, + onNavigateToSettings, +}) => { + const hasFilteredResults = + filteredCharacters.length > 0 || + installedSkills.length > 0 || + availableSkills.length > 0; + + return ( + + + + {!hasFilteredResults ? ( +
+
暂无可用角色或技能
+ {onNavigateToSettings ? ( + + ) : null} +
+ ) : null} + {filteredCharacters.length > 0 ? ( + + {filteredCharacters.map((character) => ( + onSelectCharacter(character)} + className="cursor-pointer" + > + +
+
{character.name}
+ {character.description ? ( +
+ {character.description} +
+ ) : null} +
+
+ ))} +
+ ) : null} + {installedSkills.length > 0 ? ( + + {installedSkills.map((skill) => ( + onSelectInstalledSkill(skill)} + className="cursor-pointer" + > + +
+
{skill.name}
+ {skill.description ? ( +
+ {skill.description} +
+ ) : null} +
+
+ ))} +
+ ) : null} + {availableSkills.length > 0 ? ( + + {availableSkills.map((skill) => ( + onSelectAvailableSkill(skill)} + className="cursor-pointer opacity-60" + > + +
+
{skill.name}
+ {skill.description ? ( +
+ {skill.description} +
+ ) : null} +
+
+ ))} +
+ ) : null} +
+
+ ); +}; diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx index a02b444ab..ad44ba9c6 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx @@ -7,11 +7,13 @@ import type { MessageImage } from "../../../types"; import { CharacterMention } from "./CharacterMention"; import { InputbarCore } from "./InputbarCore"; import { SkillSelector } from "./SkillSelector"; +import { TeamSelector } from "./TeamSelector"; import { ThemeWorkbenchStatusPanel } from "./ThemeWorkbenchStatusPanel"; import { InputbarModelExtra } from "./InputbarModelExtra"; import { InputbarVisionCapabilityNotice } from "./InputbarVisionCapabilityNotice"; import { InputbarExecutionStrategySelect } from "./InputbarExecutionStrategySelect"; import { isGeneralResearchTheme } from "../../../utils/generalAgentPrompt"; +import type { TeamDefinition } from "../../../utils/teamDefinitions"; import type { ThemeWorkbenchGateState, ThemeWorkbenchQuickAction, @@ -36,6 +38,8 @@ interface InputbarComposerSectionProps { onNavigateToSettings?: () => void; onImportSkill?: () => void | Promise; onRefreshSkills?: () => void | Promise; + selectedTeam?: TeamDefinition | null; + onSelectTeam?: (team: TeamDefinition | null) => void; onSend: () => void; onToolClick: (tool: string) => void; activeTools: Record; @@ -53,6 +57,7 @@ interface InputbarComposerSectionProps { ) => void; topExtra?: React.ReactNode; queuedTurns: QueuedTurnSnapshot[]; + onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise; onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise; } @@ -76,6 +81,8 @@ export const InputbarComposerSection: React.FC< onNavigateToSettings, onImportSkill, onRefreshSkills, + selectedTeam, + onSelectTeam, onSend, onToolClick, activeTools, @@ -91,6 +98,7 @@ export const InputbarComposerSection: React.FC< setExecutionStrategy, topExtra, queuedTurns, + onPromoteQueuedTurn, onRemoveQueuedTurn, }) => { const showSkillSelector = @@ -174,6 +182,7 @@ export const InputbarComposerSection: React.FC< } activeTheme={activeTheme} queuedTurns={queuedTurns} + onPromoteQueuedTurn={onPromoteQueuedTurn} onRemoveQueuedTurn={onRemoveQueuedTurn} leftExtra={ <> @@ -189,6 +198,14 @@ export const InputbarComposerSection: React.FC< onRefreshSkills={onRefreshSkills} /> ) : null} + {activeTools["subagent_mode"] ? ( + onSelectTeam?.(team)} + /> + ) : null} void | Promise; onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise; } @@ -107,6 +108,7 @@ export const InputbarCore: React.FC = ({ visualVariant = "default", activeTheme, queuedTurns = [], + onPromoteQueuedTurn, onRemoveQueuedTurn, }) => { const [isComposerExpanded, setIsComposerExpanded] = useState(false); @@ -275,6 +277,7 @@ export const InputbarCore: React.FC = ({ {topExtra} diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.test.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.test.tsx new file mode 100644 index 000000000..8ffd68f8f --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.test.tsx @@ -0,0 +1,105 @@ +import React, { createRef } from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { InputbarOverlayShell } from "./InputbarOverlayShell"; + +vi.mock("../../TaskFiles", () => ({ + TaskFileList: () =>
, +})); + +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 renderShell( + props?: Partial>, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + ()} + onFileSelect={vi.fn()} + {...props} + />, + ); + }); + + mountedRoots.push({ root, container }); + return container; +} + +describe("InputbarOverlayShell", () => { + it("应把任务文件与额外浮层控件放进同一条透明 overlay row", () => { + const container = renderShell({ + overlayAccessory: ( + + ), + }); + + const row = container.querySelector( + '[data-testid="inputbar-secondary-controls"]', + ); + + expect(row).toBeTruthy(); + expect(getComputedStyle(row as HTMLElement).position).toBe("absolute"); + expect(getComputedStyle(row as HTMLElement).pointerEvents).toBe("none"); + expect(getComputedStyle(row as HTMLElement).zIndex).toBe("80"); + expect( + row?.querySelector('[data-testid="task-files-panel-area"]'), + ).toBeTruthy(); + expect( + row?.querySelector('[data-testid="team-inline-toggle"]'), + ).toBeTruthy(); + }); + + it("没有任务文件和额外控件时不应渲染 overlay row", () => { + const container = renderShell({ + taskFiles: [], + overlayAccessory: null, + }); + + expect( + container.querySelector('[data-testid="inputbar-secondary-controls"]'), + ).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx index 5919082d2..7fb6b9f98 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarOverlayShell.tsx @@ -1,4 +1,5 @@ import React, { type ChangeEvent, type RefObject } from "react"; +import styled from "styled-components"; import type { TaskFile } from "../../TaskFiles"; import type { A2UIResponse, A2UIFormData } from "@/components/content-creator/a2ui/types"; import { @@ -20,6 +21,7 @@ interface InputbarOverlayShellProps { taskFilesExpanded?: boolean; onToggleTaskFiles?: () => void; onTaskFileClick?: (file: TaskFile) => void; + overlayAccessory?: React.ReactNode; submissionNotice?: A2UISubmissionNoticeData | null; isSubmissionNoticeVisible: boolean; pendingA2UIForm?: A2UIResponse | null; @@ -28,6 +30,27 @@ interface InputbarOverlayShellProps { onFileSelect: (event: ChangeEvent) => void; } +const SecondaryControlsRow = styled.div.attrs({ + "data-testid": "inputbar-secondary-controls", +})` + position: absolute; + right: 8px; + bottom: calc(100% + 8px); + left: 8px; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: flex-end; + gap: 8px; + pointer-events: none; + z-index: 80; + + > * { + pointer-events: auto; + max-width: 100%; + } +`; + export const InputbarOverlayShell: React.FC = ({ showHintPopup, hintRoutes, @@ -38,6 +61,7 @@ export const InputbarOverlayShell: React.FC = ({ taskFilesExpanded = false, onToggleTaskFiles, onTaskFileClick, + overlayAccessory, submissionNotice, isSubmissionNoticeVisible, pendingA2UIForm, @@ -53,13 +77,18 @@ export const InputbarOverlayShell: React.FC = ({ onSelect={onHintSelect} /> ) : null} - + {taskFiles.length > 0 || overlayAccessory ? ( + + + {overlayAccessory} + + ) : null} {submissionNotice ? ( = []; + +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 renderQueuedTurnsPanel( + props?: Partial>, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ root, container }); + return container; +} + +describe("QueuedTurnsPanel", () => { + it("应展示立即执行按钮,并触发 promote 回调", async () => { + const onPromoteQueuedTurn = vi.fn().mockResolvedValue(true); + const container = renderQueuedTurnsPanel({ onPromoteQueuedTurn }); + + const promoteButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("立即执行")); + + expect(promoteButton).toBeTruthy(); + + await act(async () => { + promoteButton?.click(); + await Promise.resolve(); + }); + + expect(onPromoteQueuedTurn).toHaveBeenCalledWith("queued-1"); + }); + + it("移除按钮仍应触发 remove 回调", async () => { + const onRemoveQueuedTurn = vi.fn().mockResolvedValue(true); + const container = renderQueuedTurnsPanel({ onRemoveQueuedTurn }); + + const removeButton = container.querySelector( + 'button[aria-label="移除排队消息"]', + ); + + expect(removeButton).toBeTruthy(); + + await act(async () => { + removeButton?.click(); + await Promise.resolve(); + }); + + expect(onRemoveQueuedTurn).toHaveBeenCalledWith("queued-1"); + }); +}); diff --git a/src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.tsx b/src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.tsx index df921c851..3531db8ef 100644 --- a/src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/QueuedTurnsPanel.tsx @@ -1,17 +1,23 @@ import React, { useEffect, useState } from "react"; -import { X } from "lucide-react"; +import { Play, X } from "lucide-react"; import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime"; interface QueuedTurnsPanelProps { queuedTurns: QueuedTurnSnapshot[]; + onPromoteQueuedTurn?: (queuedTurnId: string) => void | Promise; onRemoveQueuedTurn?: (queuedTurnId: string) => void | Promise; } export const QueuedTurnsPanel: React.FC = ({ queuedTurns, + onPromoteQueuedTurn, onRemoveQueuedTurn, }) => { const [expandedTurnId, setExpandedTurnId] = useState(null); + const [pendingAction, setPendingAction] = useState<{ + queuedTurnId: string; + type: "promote" | "remove"; + } | null>(null); useEffect(() => { if ( @@ -26,6 +32,28 @@ export const QueuedTurnsPanel: React.FC = ({ return null; } + const runQueuedAction = async ( + queuedTurnId: string, + type: "promote" | "remove", + ) => { + const handler = + type === "promote" ? onPromoteQueuedTurn : onRemoveQueuedTurn; + if (!handler) { + return; + } + + setPendingAction({ queuedTurnId, type }); + try { + await handler(queuedTurnId); + } finally { + setPendingAction((current) => + current?.queuedTurnId === queuedTurnId && current.type === type + ? null + : current, + ); + } + }; + return (
@@ -42,6 +70,13 @@ export const QueuedTurnsPanel: React.FC = ({ : messageText; const isExpanded = expandedTurnId === item.queued_turn_id; const detailId = `queued-turn-detail-${item.queued_turn_id}`; + const isPromoting = + pendingAction?.queuedTurnId === item.queued_turn_id && + pendingAction.type === "promote"; + const isRemoving = + pendingAction?.queuedTurnId === item.queued_turn_id && + pendingAction.type === "remove"; + const isBusy = isPromoting || isRemoving; return (
= ({ ) : null}
- +
+ + +
); })} diff --git a/src/components/agent/chat/components/Inputbar/components/SkillSelector.test.tsx b/src/components/agent/chat/components/Inputbar/components/SkillSelector.test.tsx index e15f83d93..6c4c21eba 100644 --- a/src/components/agent/chat/components/Inputbar/components/SkillSelector.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/SkillSelector.test.tsx @@ -6,6 +6,10 @@ import { SkillSelector } from "./SkillSelector"; import type { Skill } from "@/lib/api/skills"; const mockToastInfo = vi.fn(); +const mockPopoverState = vi.hoisted(() => ({ + open: false, + setOpen: (_next: boolean) => {}, +})); vi.mock("sonner", () => ({ toast: { @@ -14,15 +18,35 @@ vi.mock("sonner", () => ({ })); vi.mock("@/components/ui/popover", () => ({ - Popover: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - PopoverTrigger: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ), - PopoverContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), + Popover: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + }) => { + mockPopoverState.open = Boolean(open); + mockPopoverState.setOpen = onOpenChange ?? (() => {}); + return
{children}
; + }, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => { + const child = React.Children.only(children) as React.ReactElement<{ + onClick?: React.MouseEventHandler; + }>; + + return React.cloneElement(child, { + onClick: (event: React.MouseEvent) => { + child.props.onClick?.(event); + mockPopoverState.setOpen(!mockPopoverState.open); + }, + }); + }, + PopoverContent: ({ children }: { children: React.ReactNode }) => + mockPopoverState.open ? ( +
{children}
+ ) : null, })); vi.mock("@/components/ui/command", () => { @@ -108,6 +132,8 @@ afterEach(() => { }); mounted.container.remove(); } + mockPopoverState.open = false; + mockPopoverState.setOpen = () => {}; vi.clearAllMocks(); }); @@ -135,8 +161,6 @@ function renderSkillSelector( isLoading: false, onSelectSkill: vi.fn(), onClearSkill: vi.fn(), - onImportSkill: vi.fn(), - onRefreshSkills: vi.fn(), }; act(() => { @@ -147,8 +171,28 @@ function renderSkillSelector( return container; } +async function preloadSkillSelectorPanel() { + await act(async () => { + await import("./SkillSelectorPanel"); + }); +} + +async function openSkillSelector(container: HTMLElement) { + await preloadSkillSelectorPanel(); + + const triggerButton = container.querySelector( + '[data-testid="skill-selector-trigger"]', + ) as HTMLButtonElement | null; + + expect(triggerButton).toBeTruthy(); + + await act(async () => { + triggerButton?.click(); + }); +} + describe("SkillSelector", () => { - it("选择已安装技能时应回调 onSelectSkill", () => { + it("选择已安装技能时应回调 onSelectSkill", async () => { const onSelectSkill = vi.fn<(skill: Skill) => void>(); const installedSkill = createSkill("写作助手", "writer", true); const container = renderSkillSelector({ @@ -156,6 +200,8 @@ describe("SkillSelector", () => { onSelectSkill, }); + await openSkillSelector(container); + const skillButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("写作助手"), ); @@ -168,7 +214,7 @@ describe("SkillSelector", () => { expect(onSelectSkill).toHaveBeenCalledWith(installedSkill); }); - it("存在已选技能时应支持清空", () => { + it("存在已选技能时应支持清空", async () => { const onClearSkill = vi.fn<() => void>(); const activeSkill = createSkill("研究助手", "research", true); const container = renderSkillSelector({ @@ -177,6 +223,8 @@ describe("SkillSelector", () => { onClearSkill, }); + await openSkillSelector(container); + expect(container.textContent).toContain("不使用技能"); const clearButton = Array.from(container.querySelectorAll("button")).find( @@ -191,13 +239,15 @@ describe("SkillSelector", () => { expect(onClearSkill).toHaveBeenCalledTimes(1); }); - it("点击未安装技能时应给出安装提示", () => { + it("点击未安装技能时应给出安装提示", async () => { const onNavigateToSettings = vi.fn<() => void>(); const container = renderSkillSelector({ skills: [createSkill("表格导入", "xlsx", false)], onNavigateToSettings, }); + await openSkillSelector(container); + const unavailableSkillButton = Array.from( container.querySelectorAll("button"), ).find((button) => button.textContent?.includes("表格导入")); @@ -223,6 +273,8 @@ describe("SkillSelector", () => { onImportSkill, }); + await openSkillSelector(container); + const importButton = container.querySelector( '[data-testid="skill-selector-import"]', ) as HTMLButtonElement | null; @@ -238,10 +290,14 @@ describe("SkillSelector", () => { it("点击底部刷新技能入口时应回调 onRefreshSkills", async () => { const onRefreshSkills = vi.fn<() => void>(); + const installedSkill = createSkill("写作助手", "writer", true); const container = renderSkillSelector({ + skills: [installedSkill], onRefreshSkills, }); + await openSkillSelector(container); + const refreshButton = container.querySelector( '[data-testid="skill-selector-refresh"]', ) as HTMLButtonElement | null; @@ -255,12 +311,15 @@ describe("SkillSelector", () => { expect(onRefreshSkills).toHaveBeenCalledTimes(1); }); - it("加载中且无技能时应显示加载状态", () => { + it("加载中且无技能时应显示加载状态", async () => { const container = renderSkillSelector({ isLoading: true, skills: [], + onRefreshSkills: vi.fn(), }); + await openSkillSelector(container); + expect(container.textContent).toContain("技能加载中"); }); }); diff --git a/src/components/agent/chat/components/Inputbar/components/SkillSelector.tsx b/src/components/agent/chat/components/Inputbar/components/SkillSelector.tsx index cf3891789..f28a77985 100644 --- a/src/components/agent/chat/components/Inputbar/components/SkillSelector.tsx +++ b/src/components/agent/chat/components/Inputbar/components/SkillSelector.tsx @@ -1,20 +1,12 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { - Check, - FolderOpen, - Loader2, - RefreshCw, - Settings2, - X, - Zap, -} from "lucide-react"; -import { - Command, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; +import React, { + Suspense, + lazy, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { Zap } from "lucide-react"; import { Popover, PopoverContent, @@ -23,6 +15,14 @@ import { import type { Skill } from "@/lib/api/skills"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; +import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload"; + +const preloadSkillSelectorPanel = () => import("./SkillSelectorPanel"); + +const SkillSelectorPanel = lazy(async () => { + const module = await preloadSkillSelectorPanel(); + return { default: module.SkillSelectorPanel }; +}); interface SkillSelectorProps { skills?: Skill[]; @@ -68,6 +68,12 @@ export const SkillSelector: React.FC = ({ const [refreshing, setRefreshing] = useState(false); const [autoRefreshTriggered, setAutoRefreshTriggered] = useState(false); + useEffect(() => { + return scheduleIdleModulePreload(() => { + void preloadSkillSelectorPanel(); + }); + }, []); + useEffect(() => { if (!open) { setQuery(""); @@ -200,179 +206,41 @@ export const SkillSelector: React.FC = ({ align="start" sideOffset={8} > - -
-
- 技能能力 -
-
- {activeSkill ? `当前已启用 ${activeSkill.name}` : "为当前任务挂载额外能力"} -
-
-
- - {canRefresh ? ( - - ) : null} -
- - {activeSkill && onClearSkill ? ( - - - -
-
不使用技能
-
- 当前已选:{activeSkill.name} -
-
-
-
- ) : null} - - {installedSkills.length > 0 ? ( - - {installedSkills.map((skill) => { - const selected = activeSkill?.key === skill.key; - return ( - handleSelectInstalledSkill(skill)} - className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50" - > - -
-
- - {skill.name} - - - /{skill.key} - -
- {skill.description ? ( -
- {skill.description} -
- ) : null} -
- {selected ? ( - - ) : null} -
- ); - })} -
- ) : null} - - {availableSkills.length > 0 ? ( - - {availableSkills.map((skill) => ( - handleSelectAvailableSkill(skill)} - className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 opacity-80 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50" - > - -
-
- - {skill.name} - - - /{skill.key} - -
- {skill.description ? ( -
- {skill.description} -
- ) : null} -
-
- ))} -
- ) : null} - - {!hasResults ? ( + {open ? ( + - {refreshBusy ? ( -
- -
技能加载中...
-
- ) : ( - <> -
暂无可用技能
- {onNavigateToSettings ? ( - - ) : null} - - )} + 加载中...
- ) : null} - - {canImport ? ( -
- -
- ) : null} - + } + > + void handleRefresh()} + onSelectInstalledSkill={handleSelectInstalledSkill} + onSelectAvailableSkill={handleSelectAvailableSkill} + onClearSkill={handleClearSkill} + onNavigateToSettings={ + onNavigateToSettings + ? () => { + setOpen(false); + onNavigateToSettings(); + } + : undefined + } + onImport={() => void handleImport()} + /> + + ) : null} ); diff --git a/src/components/agent/chat/components/Inputbar/components/SkillSelectorPanel.tsx b/src/components/agent/chat/components/Inputbar/components/SkillSelectorPanel.tsx new file mode 100644 index 000000000..ffa8a7ec1 --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/SkillSelectorPanel.tsx @@ -0,0 +1,228 @@ +import React from "react"; +import { + Check, + FolderOpen, + Loader2, + RefreshCw, + Settings2, + X, + Zap, +} from "lucide-react"; +import { + Command, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import type { Skill } from "@/lib/api/skills"; +import { cn } from "@/lib/utils"; + +interface SkillSelectorPanelProps { + activeSkill: Skill | null; + installedSkills: Skill[]; + availableSkills: Skill[]; + query: string; + canRefresh: boolean; + refreshBusy: boolean; + canImport: boolean; + importing: boolean; + hasResults: boolean; + onQueryChange: (query: string) => void; + onRefresh: () => void; + onSelectInstalledSkill: (skill: Skill) => void; + onSelectAvailableSkill: (skill: Skill) => void; + onClearSkill?: () => void; + onNavigateToSettings?: () => void; + onImport: () => void; +} + +export const SkillSelectorPanel: React.FC = ({ + activeSkill, + installedSkills, + availableSkills, + query, + canRefresh, + refreshBusy, + canImport, + importing, + hasResults, + onQueryChange, + onRefresh, + onSelectInstalledSkill, + onSelectAvailableSkill, + onClearSkill, + onNavigateToSettings, + onImport, +}) => ( + +
+
+ 技能能力 +
+
+ {activeSkill ? `当前已启用 ${activeSkill.name}` : "为当前任务挂载额外能力"} +
+
+
+ + {canRefresh ? ( + + ) : null} +
+ + {activeSkill && onClearSkill ? ( + + + +
+
不使用技能
+
+ 当前已选:{activeSkill.name} +
+
+
+
+ ) : null} + + {installedSkills.length > 0 ? ( + + {installedSkills.map((skill) => { + const selected = activeSkill?.key === skill.key; + return ( + onSelectInstalledSkill(skill)} + className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50" + > + +
+
+ + {skill.name} + + + /{skill.key} + +
+ {skill.description ? ( +
+ {skill.description} +
+ ) : null} +
+ {selected ? ( + + ) : null} +
+ ); + })} +
+ ) : null} + + {availableSkills.length > 0 ? ( + + {availableSkills.map((skill) => ( + onSelectAvailableSkill(skill)} + className="cursor-pointer rounded-xl border border-transparent px-3 py-2.5 opacity-80 data-[selected=true]:border-slate-200 data-[selected=true]:bg-slate-50" + > + +
+
+ + {skill.name} + + + /{skill.key} + +
+ {skill.description ? ( +
+ {skill.description} +
+ ) : null} +
+
+ ))} +
+ ) : null} + + {!hasResults ? ( +
+ {refreshBusy ? ( +
+ +
技能加载中...
+
+ ) : ( + <> +
暂无可用技能
+ {onNavigateToSettings ? ( + + ) : null} + + )} +
+ ) : null} +
+ {canImport ? ( +
+ +
+ ) : null} +
+); diff --git a/src/components/agent/chat/components/Inputbar/components/TaskFilesPanel.test.tsx b/src/components/agent/chat/components/Inputbar/components/TaskFilesPanel.test.tsx new file mode 100644 index 000000000..d18a0508d --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/TaskFilesPanel.test.tsx @@ -0,0 +1,106 @@ +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 { TaskFilesPanel } from "./TaskFilesPanel"; + +vi.mock("../../TaskFiles", () => ({ + TaskFileList: () =>
, +})); + +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() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ root, container }); + return container; +} + +describe("TaskFilesPanel", () => { + it("应作为 overlay row 内部的相对定位控件渲染", () => { + const container = renderPanel(); + const area = container.querySelector( + '[data-testid="task-files-panel-area"]', + ); + + expect(area).toBeTruthy(); + expect(getComputedStyle(area as HTMLElement).position).toBe("relative"); + }); + + it("点击触发按钮时应调用切换回调", () => { + const onToggle = vi.fn(); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ root, container }); + + const trigger = container.querySelector( + "[data-task-files-trigger]", + ); + + act(() => { + trigger?.click(); + }); + + expect(onToggle).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/agent/chat/components/Inputbar/components/TaskFilesPanel.tsx b/src/components/agent/chat/components/Inputbar/components/TaskFilesPanel.tsx index c8913da1e..981b9d1d8 100644 --- a/src/components/agent/chat/components/Inputbar/components/TaskFilesPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TaskFilesPanel.tsx @@ -10,13 +10,16 @@ interface TaskFilesPanelProps { onFileClick?: (file: TaskFile) => void; } -const Area = styled.div` +const Area = styled.div.attrs({ + "data-testid": "task-files-panel-area", +})` + position: relative; display: flex; justify-content: flex-end; - padding: 0 8px 8px 8px; - width: 100%; - max-width: none; + width: auto; + max-width: 100%; margin: 0; + padding: 0; `; const Wrapper = styled.div` diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx new file mode 100644 index 000000000..c79fbc48d --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx @@ -0,0 +1,104 @@ +import React, { + Suspense, + lazy, + useEffect, + useMemo, + useState, +} from "react"; +import { Users } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload"; +import type { TeamDefinition } from "../../../utils/teamDefinitions"; + +const preloadTeamSelectorPanel = () => import("./TeamSelectorPanel"); + +const TeamSelectorPanel = lazy(async () => { + const module = await preloadTeamSelectorPanel(); + return { default: module.TeamSelectorPanel }; +}); + +interface TeamSelectorProps { + activeTheme?: string; + input?: string; + selectedTeam?: TeamDefinition | null; + onSelectTeam: (team: TeamDefinition | null) => void; + triggerLabel?: string; + className?: string; +} + +export const TeamSelector: React.FC = ({ + activeTheme, + input, + selectedTeam = null, + onSelectTeam, + triggerLabel = "Team", + className, +}) => { + const [open, setOpen] = useState(false); + + useEffect(() => { + return scheduleIdleModulePreload(() => { + void preloadTeamSelectorPanel(); + }); + }, []); + + const resolvedLabel = useMemo(() => { + if (!selectedTeam?.label?.trim()) { + return triggerLabel; + } + return `Team · ${selectedTeam.label.trim()}`; + }, [selectedTeam?.label, triggerLabel]); + + return ( + + + + + + {open ? ( + + 加载中... +
+ } + > + { + onSelectTeam(team); + setOpen(false); + }} + onClose={() => setOpen(false)} + /> + + ) : null} + + + ); +}; diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx new file mode 100644 index 000000000..02144c44f --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx @@ -0,0 +1,758 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Check, + ChevronDown, + ChevronUp, + Copy, + Pencil, + Plus, + Sparkles, + Trash2, + Users, + X, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { + buildTeamDefinitionSummary, + cloneTeamDefinitionAsCustom, + createTeamDefinitionFromPreset, + listBuiltinTeamDefinitions, + normalizeTeamDefinition, + type TeamDefinition, + type TeamRoleDefinition, +} from "../../../utils/teamDefinitions"; +import { getTeamSuggestion } from "../../../utils/teamSuggestion"; +import { loadCustomTeams, saveCustomTeams } from "../../../utils/teamStorage"; + +interface TeamSelectorPanelProps { + activeTheme?: string; + input?: string; + selectedTeam?: TeamDefinition | null; + onSelectTeam: (team: TeamDefinition | null) => void; + onClose?: () => void; +} + +interface TeamDraft { + id?: string; + label: string; + description: string; + theme?: string; + presetId?: string; + roles: TeamRoleDefinition[]; +} + +function createBlankDraft(theme?: string): TeamDraft { + return { + label: "", + description: "", + theme, + presetId: undefined, + roles: [ + { + id: "planner", + label: "分析", + summary: "负责拆解任务、澄清边界与输出第一轮事实。", + }, + { + id: "executor", + label: "执行", + summary: "负责在明确范围内推进实现或产出草稿。", + }, + ], + }; +} + +function buildDraftFromTeam(team: TeamDefinition): TeamDraft { + return { + id: team.source === "custom" ? team.id : undefined, + label: team.label, + description: team.description, + theme: team.theme, + presetId: team.presetId, + roles: team.roles.map((role, index) => ({ + id: role.id || `role-${index + 1}`, + label: role.label, + summary: role.summary, + profileId: role.profileId, + roleKey: role.roleKey, + skillIds: role.skillIds ? [...role.skillIds] : [], + })), + }; +} + +function matchTeamQuery(team: TeamDefinition, query: string): boolean { + if (!query) { + return true; + } + + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return true; + } + + return ( + team.label.toLowerCase().includes(normalizedQuery) || + team.description.toLowerCase().includes(normalizedQuery) || + team.roles.some( + (role) => + role.label.toLowerCase().includes(normalizedQuery) || + role.summary.toLowerCase().includes(normalizedQuery), + ) + ); +} + +function TeamCard({ + team, + selected, + expanded, + selectedLabel, + badgeLabel, + onSelect, + onToggleDetail, + onCopy, + onEdit, + onDelete, +}: { + team: TeamDefinition; + selected: boolean; + expanded?: boolean; + selectedLabel?: string; + badgeLabel?: string; + onSelect: () => void; + onToggleDetail?: () => void; + onCopy?: () => void; + onEdit?: () => void; + onDelete?: () => void; +}) { + return ( +
+
+ +
+ {onToggleDetail ? ( + + ) : null} + {onCopy ? ( + + ) : null} + {onEdit ? ( + + ) : null} + {onDelete ? ( + + ) : null} +
+
+
+ ); +} + +export const TeamSelectorPanel: React.FC = ({ + activeTheme, + input, + selectedTeam = null, + onSelectTeam, + onClose, +}) => { + const [query, setQuery] = useState(""); + const [customTeams, setCustomTeams] = useState([]); + const [draft, setDraft] = useState(null); + const [expandedTeamId, setExpandedTeamId] = useState(null); + + useEffect(() => { + setCustomTeams(loadCustomTeams()); + }, []); + + const suggestion = useMemo( + () => + getTeamSuggestion({ + input: input || "", + activeTheme, + subagentEnabled: false, + }), + [activeTheme, input], + ); + + const recommendedTeam = useMemo( + () => + suggestion.shouldSuggest && suggestion.suggestedPresetId + ? createTeamDefinitionFromPreset(suggestion.suggestedPresetId) + : null, + [suggestion.shouldSuggest, suggestion.suggestedPresetId], + ); + + const builtinTeams = useMemo( + () => + listBuiltinTeamDefinitions().filter((team) => matchTeamQuery(team, query)), + [query], + ); + + const filteredCustomTeams = useMemo( + () => customTeams.filter((team) => matchTeamQuery(team, query)), + [customTeams, query], + ); + + const currentSelectionSummary = buildTeamDefinitionSummary(selectedTeam); + + const handleStartCreate = (base?: TeamDefinition | null) => { + setDraft(base ? buildDraftFromTeam(cloneTeamDefinitionAsCustom(base)) : createBlankDraft(activeTheme)); + }; + + const handleStartEdit = (team: TeamDefinition) => { + setDraft(buildDraftFromTeam(team)); + }; + + const handleSaveDraft = () => { + const normalized = normalizeTeamDefinition({ + id: draft?.id, + source: "custom", + label: draft?.label, + description: draft?.description, + theme: draft?.theme, + presetId: + draft?.presetId || + (draft?.id && customTeams.find((team) => team.id === draft.id)?.presetId), + roles: draft?.roles, + }); + + if (!normalized) { + toast.error("请至少填写 Team 名称和 1 个角色"); + return; + } + + const nextTeam = { + ...normalized, + source: "custom" as const, + updatedAt: Date.now(), + createdAt: + customTeams.find((team) => team.id === normalized.id)?.createdAt || + Date.now(), + }; + + const nextCustomTeams = [...customTeams.filter((team) => team.id !== nextTeam.id), nextTeam].sort( + (left, right) => (right.updatedAt || 0) - (left.updatedAt || 0), + ); + setCustomTeams(nextCustomTeams); + saveCustomTeams(nextCustomTeams); + setDraft(null); + onSelectTeam(nextTeam); + onClose?.(); + toast.success(`已保存 Team「${nextTeam.label}」`); + }; + + const handleDeleteCustom = (team: TeamDefinition) => { + const nextCustomTeams = customTeams.filter((item) => item.id !== team.id); + setCustomTeams(nextCustomTeams); + saveCustomTeams(nextCustomTeams); + setDraft((currentDraft) => (currentDraft?.id === team.id ? null : currentDraft)); + if (selectedTeam?.id === team.id) { + onSelectTeam(null); + } + toast.success(`已删除 Team「${team.label}」`); + }; + + const handleClearSelection = () => { + onSelectTeam(null); + onClose?.(); + }; + + const handleSelect = (team: TeamDefinition) => { + onSelectTeam(team); + onClose?.(); + }; + + const recommendedSelected = Boolean( + recommendedTeam && selectedTeam?.id === recommendedTeam.id, + ); + + return ( +
+
+
+
+
+ TEAM 配置 +
+
+ 只在当前任务适合拆分协作时,为主代理提供团队结构参考。 +
+
+ {selectedTeam ? ( + + ) : null} +
+ {selectedTeam ? ( +
+
+ + 当前已选 Team +
+
+ {selectedTeam.label} +
+ {currentSelectionSummary ? ( +
+ {currentSelectionSummary} +
+ ) : null} +
+ ) : null} +
+ +
+ setQuery(event.target.value)} + placeholder="搜索 Team、角色或职责" + className="border-slate-200 bg-white" + /> +
+ + {selectedTeam ? ( + + ) : null} +
+ + {recommendedTeam ? ( +
+
+ + 推荐 Team +
+ handleSelect(recommendedTeam)} + onToggleDetail={() => + setExpandedTeamId((currentId) => + currentId === recommendedTeam.id ? null : recommendedTeam.id, + ) + } + onCopy={() => handleStartCreate(recommendedTeam)} + /> +
+ ) : null} + +
+
+
+ 我的 Team +
+ +
+ {filteredCustomTeams.length > 0 ? ( +
+ {filteredCustomTeams.map((team) => ( + handleSelect(team)} + onToggleDetail={() => + setExpandedTeamId((currentId) => + currentId === team.id ? null : team.id, + ) + } + onCopy={() => handleStartCreate(team)} + onEdit={() => handleStartEdit(team)} + onDelete={() => handleDeleteCustom(team)} + /> + ))} +
+ ) : ( +
+
还没有自定义 Team。可以从推荐方案或系统模板复制一份后再改。
+ +
+ )} +
+ +
+
+ 系统模板 +
+
+ {builtinTeams.map((team) => ( + handleSelect(team)} + onToggleDetail={() => + setExpandedTeamId((currentId) => + currentId === team.id ? null : team.id, + ) + } + onCopy={() => handleStartCreate(team)} + /> + ))} +
+
+ + {draft ? ( +
+
+
+
+ {draft.id ? "编辑自定义 Team" : "新建自定义 Team"} +
+
+ 用于当前 Team mode 的角色分工建议,不会影响普通单代理任务。 +
+
+ +
+ +
+
+ + + setDraft((current) => + current + ? { + ...current, + label: event.target.value, + } + : current, + ) + } + placeholder="例如:前端联调团队" + className="border-slate-200 bg-white" + /> +
+ +
+ +