From dc37baff36b2d729dacd00688ae817359a84714e Mon Sep 17 00:00:00 2001 From: coso Date: Sat, 21 Mar 2026 23:10:41 +0800 Subject: [PATCH] release: v0.93.0 --- .github/workflows/release.yml | 4 + .gitignore | 1 + AGENTS.md | 1 + RELEASE_NOTES.md | 29 +- .../02.user-guide/17.runtime-agents.md | 164 + docs/content/06.development/3.building.md | 48 +- docs/content/index.md | 1 + package.json | 2 +- src-tauri/Cargo.lock | 204 +- src-tauri/Cargo.toml | 10 +- src-tauri/crates/agent/Cargo.toml | 2 + src-tauri/crates/agent/src/aster_state.rs | 92 + .../crates/agent/src/credential_bridge.rs | 43 +- src-tauri/crates/agent/src/event_converter.rs | 31 +- .../crates/agent/src/kiro_provider_adapter.rs | 360 + src-tauri/crates/agent/src/lib.rs | 22 + src-tauri/crates/agent/src/prompt/mod.rs | 5 + .../crates/agent/src/prompt/runtime_agents.rs | 144 + .../agent/src/provider_continuation_state.rs | 250 + src-tauri/crates/agent/src/provider_safety.rs | 302 + .../agent/src/runtime_projection_snapshot.rs | 145 + .../agent/src/session_state_snapshot.rs | 116 + .../crates/agent/src/turn_input_envelope.rs | 550 + src-tauri/crates/agent/src/turn_state.rs | 79 + src-tauri/crates/core/src/app_paths.rs | 34 +- src-tauri/crates/core/src/config/types.rs | 11 +- src-tauri/crates/core/src/openclaw_install.rs | 48 +- src-tauri/crates/core/src/workspace/types.rs | 162 + .../crates/providers/src/providers/kiro.rs | 169 +- .../src/translator/kiro/openai/request.rs | 14 +- src-tauri/crates/scheduler/src/executor.rs | 4 +- .../crates/services/src/model_service.rs | 57 +- .../services/src/update_check_service.rs | 189 +- src-tauri/src/agent/aster_agent.rs | 48 +- src-tauri/src/app/runner.rs | 73 +- src-tauri/src/commands/aster_agent_cmd.rs | 10953 ---------------- .../aster_agent_cmd/action_runtime.rs | 228 + .../aster_agent_cmd/browser_assist.rs | 254 + .../commands/aster_agent_cmd/command_api.rs | 59 + .../command_api/provider_api.rs | 149 + .../command_api/runtime_api.rs | 254 + .../command_api/session_api.rs | 76 + .../command_api/subagent_api.rs | 141 + src-tauri/src/commands/aster_agent_cmd/dto.rs | 415 + .../commands/aster_agent_cmd/mcp_bridge.rs | 197 + src-tauri/src/commands/aster_agent_cmd/mod.rs | 471 + .../aster_agent_cmd/prompt_context.rs | 336 + .../commands/aster_agent_cmd/reply_runtime.rs | 482 + .../commands/aster_agent_cmd/run_metadata.rs | 26 + .../run_metadata/provider_continuation.rs | 200 + .../run_metadata/request_metadata.rs | 169 + .../run_metadata/social_artifacts.rs | 519 + .../commands/aster_agent_cmd/runtime_turn.rs | 984 ++ .../aster_agent_cmd/session_runtime.rs | 157 + .../aster_agent_cmd/subagent_runtime.rs | 973 ++ .../src/commands/aster_agent_cmd/tests.rs | 1938 +++ .../commands/aster_agent_cmd/tool_runtime.rs | 226 + .../tool_runtime/browser_tools.rs | 268 + .../tool_runtime/creation_tools.rs | 784 ++ .../tool_runtime/search_bridge.rs | 333 + .../tool_runtime/social_tools.rs | 330 + .../tool_runtime/subagent_tools.rs | 973 ++ .../tool_runtime/workspace_tools.rs | 821 ++ src-tauri/src/commands/claw_solution_cmd.rs | 45 + src-tauri/src/commands/config_cmd.rs | 686 - .../src/commands/memory_management_cmd.rs | 35 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/persona_cmd.rs | 14 +- src-tauri/src/commands/theme_context_cmd.rs | 9 +- src-tauri/src/commands/update_cmd.rs | 536 +- .../dev_bridge/dispatcher/memory_runtime.rs | 117 + .../src/services/claw_solution_service.rs | 526 + .../services/memory_profile_prompt_service.rs | 71 +- .../memory_source_resolver_service.rs | 189 +- src-tauri/src/services/mod.rs | 2 + src-tauri/src/services/openclaw_service.rs | 301 +- .../runtime_agents_template_service.rs | 339 + src-tauri/tauri.conf.headless.json | 6 +- src-tauri/tauri.conf.json | 6 +- .../agent/chat/AgentChatHomeShell.test.tsx | 474 +- .../agent/chat/AgentChatHomeShell.tsx | 532 +- .../agent/chat/AgentChatWorkspace.tsx | 1192 +- .../ClawHomeSolutionsPanel.test.tsx | 126 + .../claw-solutions/ClawHomeSolutionsPanel.tsx | 54 + .../claw-solutions/actionDispatcher.test.ts | 129 + .../chat/claw-solutions/actionDispatcher.ts | 95 + .../agent/chat/claw-solutions/storage.test.ts | 67 + .../agent/chat/claw-solutions/storage.ts | 87 + .../agent/chat/claw-solutions/types.ts | 43 + .../chat/claw-solutions/useClawSolutions.ts | 153 + .../components/AgentThreadTimeline.test.tsx | 3 + .../chat/components/AgentThreadTimeline.tsx | 5 +- .../components/CanvasWorkbenchLayout.test.tsx | 323 +- .../chat/components/CanvasWorkbenchLayout.tsx | 469 +- .../chat/components/ChatSidebar.test.tsx | 38 + .../agent/chat/components/ChatSidebar.tsx | 17 +- .../agent/chat/components/EmptyState.test.tsx | 16 + .../agent/chat/components/EmptyState.tsx | 15 +- .../EmptyStateComposerPanel.test.tsx | 160 +- .../components/EmptyStateComposerPanel.tsx | 42 +- .../components/EmptyStateQuickActions.tsx | 96 +- .../components/InputbarComposerSection.tsx | 64 +- .../components/TeamModeEntryButton.tsx | 56 + .../Inputbar/components/TeamSelector.tsx | 30 +- .../components/TeamSelectorPanel.test.tsx | 57 + .../Inputbar/components/TeamSelectorPanel.tsx | 123 +- .../components/scheduleIdleModulePreload.ts | 8 + .../chat/components/Inputbar/index.test.tsx | 107 +- .../agent/chat/components/Inputbar/index.tsx | 12 + .../chat/components/MessageList.test.tsx | 45 + .../agent/chat/components/MessageList.tsx | 65 +- .../agent/chat/components/ProjectSelector.tsx | 3 +- .../components/TeamWorkbenchSummaryPanel.tsx | 362 + .../components/TeamWorkspaceBoard.test.tsx | 256 +- .../chat/components/TeamWorkspaceBoard.tsx | 574 +- .../components/TeamWorkspaceDock.test.tsx | 272 +- .../chat/components/TeamWorkspaceDock.tsx | 240 +- ...hemeWorkbenchEntryPromptAccessory.test.tsx | 118 + .../ThemeWorkbenchEntryPromptAccessory.tsx | 140 + .../agent/chat/homeShellEntry.test.ts | 130 + src/components/agent/chat/homeShellEntry.ts | 111 + .../agent/chat/hooks/agentChatHistory.test.ts | 43 + .../agent/chat/hooks/agentChatHistory.ts | 28 +- .../agent/chat/hooks/agentChatShared.test.ts | 34 + .../agent/chat/hooks/agentChatShared.ts | 19 +- .../chat/hooks/agentProjectStorage.test.tsx | 100 + .../agent/chat/hooks/agentProjectStorage.ts | 75 + .../agent/chat/hooks/handleSendTypes.ts | 35 + src/components/agent/chat/hooks/index.ts | 1 + .../agent/chat/hooks/useAgentContext.test.tsx | 139 + .../agent/chat/hooks/useAgentContext.ts | 11 + .../useBootstrapDispatchPreview.test.tsx | 156 + .../chat/hooks/useBootstrapDispatchPreview.ts | 159 + .../useHomeShellAgentPreferences.test.tsx | 146 + .../hooks/useHomeShellAgentPreferences.ts | 98 + .../hooks/useHomeShellProjectMemory.test.tsx | 115 + .../chat/hooks/useHomeShellProjectMemory.ts | 59 + .../chat/hooks/useHomeShellSkills.test.tsx | 137 + .../agent/chat/hooks/useHomeShellSkills.ts | 8 + .../agent/chat/hooks/useLimeSkills.test.tsx | 143 + .../agent/chat/hooks/useLimeSkills.ts | 127 + .../hooks/useRuntimeTeamFormation.test.tsx | 252 + .../chat/hooks/useRuntimeTeamFormation.ts | 210 + .../hooks/useSelectedTeamPreference.test.tsx | 194 +- .../chat/hooks/useSelectedTeamPreference.ts | 46 +- .../hooks/useTeamWorkspaceRuntime.test.tsx | 142 + .../chat/hooks/useTeamWorkspaceRuntime.ts | 202 +- ...useThemeScopedChatToolPreferences.test.tsx | 130 + .../useThemeScopedChatToolPreferences.ts | 35 + .../useThemeWorkbenchEntryPrompt.test.tsx | 200 + .../hooks/useThemeWorkbenchEntryPrompt.ts | 348 + ...eThemeWorkbenchEntryPromptActions.test.tsx | 199 + .../useThemeWorkbenchEntryPromptActions.ts | 71 + .../useThemeWorkbenchSendBoundary.test.tsx | 216 + .../hooks/useThemeWorkbenchSendBoundary.ts | 277 + .../chat/hooks/useTrayModelShortcuts.test.ts | 83 +- .../agent/chat/hooks/useTrayModelShortcuts.ts | 325 +- .../useWorkspaceProjectSelection.test.tsx | 166 + .../hooks/useWorkspaceProjectSelection.ts | 163 + src/components/agent/chat/index.test.tsx | 706 +- .../agent/chat/teamWorkspaceRuntime.test.ts | 93 + .../agent/chat/teamWorkspaceRuntime.ts | 334 +- .../agent/chat/utils/agentThreadGrouping.ts | 7 +- .../utils/clawWorkspaceProviderSelection.ts | 128 + .../chat/utils/harnessRequestMetadata.test.ts | 35 + .../chat/utils/internalImagePlaceholder.ts | 140 + .../agent/chat/utils/teamAutoGeneration.ts | 305 + .../agent/chat/utils/teamDefinitions.ts | 17 +- .../agent/chat/utils/teamStorage.ts | 209 +- .../canvas/document/DocumentToolbar.tsx | 9 + .../LayoutTransition.test.tsx | 59 +- .../LayoutTransition/LayoutTransition.tsx | 91 +- .../useLayoutTransition.test.tsx | 5 + .../LayoutTransition/useLayoutTransition.ts | 8 +- src/components/layout/CrashRecoveryPanel.tsx | 11 + src/components/projects/ProjectsPage.tsx | 5 +- .../settings-v2/general/memory/index.test.tsx | 47 + .../settings-v2/general/memory/index.tsx | 148 +- .../settings-v2/system/about/index.tsx | 145 +- .../hooks/useCreationDialogs.test.tsx | 16 +- .../workspace/hooks/useCreationDialogs.ts | 4 +- .../runtimeAgentsGuideService.test.ts | 155 + .../services/runtimeAgentsGuideService.ts | 170 + src/hooks/useProviderModels.ts | 52 +- src/lib/api/appUpdate.ts | 2 + src/lib/api/clawSolutions.test.ts | 75 + src/lib/api/clawSolutions.ts | 84 + src/lib/api/memoryRuntime.test.ts | 38 + src/lib/api/memoryRuntime.ts | 28 + src/lib/api/memoryRuntimeTypes.ts | 25 + src/lib/tauri-mock/core.ts | 212 + src/pages/update-notification.tsx | 2 +- src/types/workspace.ts | 34 + 193 files changed, 30459 insertions(+), 13754 deletions(-) create mode 100644 docs/content/02.user-guide/17.runtime-agents.md create mode 100644 src-tauri/crates/agent/src/kiro_provider_adapter.rs create mode 100644 src-tauri/crates/agent/src/prompt/runtime_agents.rs create mode 100644 src-tauri/crates/agent/src/provider_continuation_state.rs create mode 100644 src-tauri/crates/agent/src/provider_safety.rs create mode 100644 src-tauri/crates/agent/src/runtime_projection_snapshot.rs create mode 100644 src-tauri/crates/agent/src/session_state_snapshot.rs create mode 100644 src-tauri/crates/agent/src/turn_input_envelope.rs create mode 100644 src-tauri/crates/agent/src/turn_state.rs delete mode 100644 src-tauri/src/commands/aster_agent_cmd.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/action_runtime.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/browser_assist.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/command_api.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/command_api/provider_api.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/command_api/subagent_api.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/dto.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/mcp_bridge.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/mod.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/prompt_context.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/run_metadata.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/run_metadata/provider_continuation.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/run_metadata/social_artifacts.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/session_runtime.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/tests.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/tool_runtime/social_tools.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs create mode 100644 src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs create mode 100644 src-tauri/src/commands/claw_solution_cmd.rs create mode 100644 src-tauri/src/services/claw_solution_service.rs create mode 100644 src-tauri/src/services/runtime_agents_template_service.rs create mode 100644 src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.test.tsx create mode 100644 src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.tsx create mode 100644 src/components/agent/chat/claw-solutions/actionDispatcher.test.ts create mode 100644 src/components/agent/chat/claw-solutions/actionDispatcher.ts create mode 100644 src/components/agent/chat/claw-solutions/storage.test.ts create mode 100644 src/components/agent/chat/claw-solutions/storage.ts create mode 100644 src/components/agent/chat/claw-solutions/types.ts create mode 100644 src/components/agent/chat/claw-solutions/useClawSolutions.ts create mode 100644 src/components/agent/chat/components/Inputbar/components/TeamModeEntryButton.tsx create mode 100644 src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx create mode 100644 src/components/agent/chat/components/ThemeWorkbenchEntryPromptAccessory.test.tsx create mode 100644 src/components/agent/chat/components/ThemeWorkbenchEntryPromptAccessory.tsx create mode 100644 src/components/agent/chat/homeShellEntry.test.ts create mode 100644 src/components/agent/chat/homeShellEntry.ts create mode 100644 src/components/agent/chat/hooks/agentProjectStorage.test.tsx create mode 100644 src/components/agent/chat/hooks/agentProjectStorage.ts create mode 100644 src/components/agent/chat/hooks/handleSendTypes.ts create mode 100644 src/components/agent/chat/hooks/useAgentContext.test.tsx create mode 100644 src/components/agent/chat/hooks/useBootstrapDispatchPreview.test.tsx create mode 100644 src/components/agent/chat/hooks/useBootstrapDispatchPreview.ts create mode 100644 src/components/agent/chat/hooks/useHomeShellAgentPreferences.test.tsx create mode 100644 src/components/agent/chat/hooks/useHomeShellAgentPreferences.ts create mode 100644 src/components/agent/chat/hooks/useHomeShellProjectMemory.test.tsx create mode 100644 src/components/agent/chat/hooks/useHomeShellProjectMemory.ts create mode 100644 src/components/agent/chat/hooks/useHomeShellSkills.test.tsx create mode 100644 src/components/agent/chat/hooks/useHomeShellSkills.ts create mode 100644 src/components/agent/chat/hooks/useLimeSkills.test.tsx create mode 100644 src/components/agent/chat/hooks/useLimeSkills.ts create mode 100644 src/components/agent/chat/hooks/useRuntimeTeamFormation.test.tsx create mode 100644 src/components/agent/chat/hooks/useRuntimeTeamFormation.ts create mode 100644 src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.test.tsx create mode 100644 src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.ts create mode 100644 src/components/agent/chat/hooks/useThemeWorkbenchEntryPrompt.test.tsx create mode 100644 src/components/agent/chat/hooks/useThemeWorkbenchEntryPrompt.ts create mode 100644 src/components/agent/chat/hooks/useThemeWorkbenchEntryPromptActions.test.tsx create mode 100644 src/components/agent/chat/hooks/useThemeWorkbenchEntryPromptActions.ts create mode 100644 src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.test.tsx create mode 100644 src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.ts create mode 100644 src/components/agent/chat/hooks/useWorkspaceProjectSelection.test.tsx create mode 100644 src/components/agent/chat/hooks/useWorkspaceProjectSelection.ts create mode 100644 src/components/agent/chat/teamWorkspaceRuntime.test.ts create mode 100644 src/components/agent/chat/utils/clawWorkspaceProviderSelection.ts create mode 100644 src/components/agent/chat/utils/internalImagePlaceholder.ts create mode 100644 src/components/agent/chat/utils/teamAutoGeneration.ts create mode 100644 src/components/workspace/services/runtimeAgentsGuideService.test.ts create mode 100644 src/components/workspace/services/runtimeAgentsGuideService.ts create mode 100644 src/lib/api/clawSolutions.test.ts create mode 100644 src/lib/api/clawSolutions.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fe9f270c0..731d0b4e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,6 +43,10 @@ jobs: name: Windows-x64 runs-on: ${{ matrix.platform }} + env: + LIME_UPDATER_PUBLIC_KEY: ${{ secrets.LIME_UPDATER_PUBLIC_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} steps: - name: Checkout diff --git a/.gitignore b/.gitignore index 057db82a5..375328715 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,4 @@ lime-claw.png lime.db .codex-* +target-codex* \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index b7883ed03..666b7c917 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # AI Agent 指南 本文件为 AI Agent 在此代码库中工作时提供指导。 +仅用于开发 Lime 源码仓库本身,不作为 Lime 应用运行时 agent 会话的指令来源。 ## 基本规则 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 015f5db03..3bca07674 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,35 +1,34 @@ -## Lime v0.92.0 +## Lime v0.93.0 ### ✨ 主要更新 -- **Team Workspace 正式成型**:Agent 聊天页新增 Team Workspace 主工作台、建议栏、Dock 与 Home Shell,围绕多代理协作视图重组交互结构 -- **Team 配置与发布稳定性收尾**:补齐 Team Selector 自定义 Team 配置链路、当前 Team 展示与相关测试,并修复通知、Provider Runtime 与前端类型兼容问题,确保 `v0.92.0` 可稳定构建发布 -- **Team Selector 交互继续打磨**:扩展 Team 选择弹窗宽度,重构为模板列表 + 详情检查器布局,并补充执行会话失效后的恢复测试,减少工作台切换与继续执行时的状态错乱 -- **运行态与工具可视化增强**:`ToolCallDisplay`、Harness 状态面板、Runtime Strip、执行日志与子代理时间线继续增强,工具调用与运行态反馈更完整 -- **Aster Agent 运行时继续收口**:Rust 侧补齐 session store、subagent control、agent tools inventory / execution、runtime queue 及命令桥接,统一现役 Agent Runtime 路径 -- **治理与测试基建升级**:本地校验脚本、命令契约检查、workspace smoke 与治理报告继续完善,发布前自检链路更清晰 -- **Provider / 模型兼容性继续补强**:补充 Novita 与多种 OpenAI/Claude 兼容 provider 细节,推理内容与工具调用适配继续完善 +- **运行时 AGENTS 规则正式切换到 `.lime/` 体系**:新增全局、Workspace、本机私有模板生成能力,补齐设置入口、首次初始化提示与使用文档,仓库级 `AGENTS.md` 与应用运行时规则边界彻底分离 +- **Home Shell 与 Team Workbench 继续收口**:Agent 聊天页补齐 Home Shell 进入工作区链路、项目记忆读取、主题继续提示、团队摘要面板和 Team Runtime 偏好存储,围绕多代理主工作台进一步统一入口与状态反馈 +- **Claw Solutions 与浏览器协助入口落地**:新增网页研究、社媒起稿、前端概念、演示提纲、浏览器协助、多代理拆解等快捷方案,并根据模型可用性、浏览器能力和 Team 偏好给出准备状态 +- **更新检查与版本分发链路重构**:更新逻辑切到静态 `latest.json` 清单 + 本地缓存,About 页与独立更新提醒窗口补齐检查、下载、跳过和稍后提醒交互,发布工作流同步按 tag 注入版本并加载 release note +- **记忆、OpenClaw 与运行时底座继续补强**:补充记忆运行时 API 与设置页能力,强化 OpenClaw 安装/探测细节,并继续收口 Aster Agent 命令桥接、runtime state 与 workspace 类型 ### ⚠️ 兼容性说明 -- Agent 聊天页结构继续向 Team Workspace 与现役 Runtime API 收口,旧 compat 会话 / 子代理展示路径不再建议扩展 -- 工具面板、Harness 状态与时间线展示依赖新的事件元数据与运行时映射,历史 UI 分支需要逐步跟进 +- Lime 运行时会话默认读取 `~/.lime/AGENTS.md`、`/.lime/AGENTS.md` 与可选的 `.lime/AGENTS.local.md`,不再把仓库根 `AGENTS.md` 作为运行时规则来源 +- 更新检查依赖 GitHub Releases 的 `latest.json`、签名公钥与对应 release 资源;自托管或镜像发布时需要保持清单地址与下载链接一致 +- Agent 聊天页继续向 Team Workbench 与现役 Runtime API 收口,旧 compat 会话 / 子代理展示路径不再建议扩展 ### 🔗 依赖同步 -- `src-tauri/Cargo.toml` 中的 `aster-rust` 依赖固定到 `v0.20.0` -- 应用版本同步提升到 `v0.92.0`,覆盖 `package.json`、Tauri 配置与 Rust workspace 版本入口 +- `src-tauri/Cargo.toml` 中的 `aster-rust` 依赖固定到 `v0.20.1` +- 应用版本同步提升到 `v0.93.0`,覆盖 `package.json`、Tauri 配置与 Rust workspace 版本入口 ### 🧪 测试 - 发布前执行:`cd src-tauri && cargo test` -- 发布前执行:`cd src-tauri && cargo fmt --all --check` +- 发布前执行:`cd src-tauri && cargo fmt --all` - 发布前执行:`cd src-tauri && cargo clippy` - 发布前执行:`npm run lint` ### 📝 文档 -- 更新治理、测试、工具体系与 Aster 集成相关文档,补充当前现役架构与发布说明 +- 新增运行时 AGENTS 使用文档,补充构建 / 发布流程与当前现役架构说明 ### 📦 Windows 下载说明 @@ -39,4 +38,4 @@ --- -**完整变更**: v0.91.0...v0.92.0 +**完整变更**: v0.92.0...v0.93.0 diff --git a/docs/content/02.user-guide/17.runtime-agents.md b/docs/content/02.user-guide/17.runtime-agents.md new file mode 100644 index 000000000..9ce44a5df --- /dev/null +++ b/docs/content/02.user-guide/17.runtime-agents.md @@ -0,0 +1,164 @@ +--- +title: 运行时 AGENTS 规则 +description: 使用 `~/.lime/AGENTS.md` 与 Workspace `.lime/AGENTS.md` 为 Lime 运行时会话提供稳定指令 +navigation: + icon: i-heroicons-document-text +--- + +# 运行时 AGENTS 规则 + +Lime 应用运行时会话现在默认读取 `.lime/AGENTS.md` 体系,而不是仓库根的 `AGENTS.md`。 + +你也可以在「设置 → 记忆」里点击按钮,显式生成全局、Workspace 或本机私有模板文件。 +如果是本机私有模板,还可以继续点击按钮,把 `.lime/AGENTS.local.md` 一键加入 Workspace 的 `.gitignore`。 +首次创建新项目后,Lime 也会弹出一个非强制提示,允许你直接一键初始化这两个 Workspace 模板。 + +这让“开发 Lime 源码仓库本身的规则”和“Lime 应用实际运行时的规则”彻底分开: + +- 仓库根 `AGENTS.md`:给外部 AI 编辑器或源码协作使用 +- `~/.lime/AGENTS.md`:你的全局运行时偏好 +- `/.lime/AGENTS.md`:当前项目 / 工作区的运行时规则 + +## 加载顺序 + +Lime 运行时会话默认按下面顺序加载: + +1. 全局:`~/.lime/AGENTS.md` +2. 工作区:`/.lime/AGENTS.md` + +如果你保留默认记忆来源设置,Lime 还会继续把下面这个文件作为**本机私有补充**读取: + +- `/.lime/AGENTS.local.md` + +推荐做法: + +- 把长期个人偏好写进 `~/.lime/AGENTS.md` +- 把项目约束写进 `/.lime/AGENTS.md` +- 把不想提交到仓库的本机补充写进 `/.lime/AGENTS.local.md` + +## 什么时候用哪个文件 + +### `~/.lime/AGENTS.md` + +适合放所有项目都通用的偏好,例如: + +- 统一回复语言 +- 默认输出结构 +- 常用代码风格 +- 你长期偏好的解释方式 + +直接复制下面模板即可: + +```md +# 我的全局 Lime 运行时规则 + +## 回复习惯 + +- 默认使用中文简体 +- 先给结论,再给关键步骤 +- 没必要时保持简洁,不要过度展开 + +## 工程偏好 + +- 优先选择 KISS 方案 +- 优先修根因,不做表面补丁 +- 先说明影响范围,再做改动 + +## 代码风格 + +- 尽量沿用现有项目风格 +- 避免无关重构 +- 没有明确收益时,不新增抽象层 +``` + +## Workspace `.lime/AGENTS.md` + +适合放当前项目独有的规则,例如: + +- 仓库使用的语言 +- 文档与注释风格 +- 目录边界 +- 测试、构建、提交前检查要求 + +把下面内容保存为工作区内的 `.lime/AGENTS.md`: + +```md +# 当前项目运行时规则 + +## 项目背景 + +- 这是一个 React + Rust + Tauri 项目 +- 前端使用 TypeScript +- 回答和文档默认使用中文简体 + +## 修改原则 + +- 先读后写 +- 只改当前任务直接相关内容 +- 保持现有目录结构和命名习惯 + +## 验证要求 + +- 前端改动后优先跑相关前端测试 +- Rust 改动后优先跑相关单测 +- 若无法完整验证,需要明确说明未验证部分 + +## 禁止事项 + +- 不要把临时排障脚本提交进仓库 +- 不要修改与当前任务无关的配置 +- 不要默认进行 git commit 或 push +``` + +## `.lime/AGENTS.local.md` 示例 + +如果你想保留**只在自己机器生效**的补充规则,可以新建 `.lime/AGENTS.local.md`,并把它加入 `.gitignore`。 + +例如: + +```md +# 本机私有补充 + +- 优先使用本机已安装的 Node 与 Rust 工具链 +- 如需浏览器调试,优先使用本机开发配置 +- 涉及大体量编译时,先做定向测试再跑全量 +``` + +## 推荐目录结构 + +```text +workspace-root/ +├─ .lime/ +│ ├─ AGENTS.md +│ └─ AGENTS.local.md +├─ src/ +├─ src-tauri/ +└─ ... +``` + +你的全局文件位于: + +```text +~/.lime/AGENTS.md +``` + +## 注意事项 + +- Lime 运行时不会读取仓库根 `AGENTS.md` +- Workspace `.lime/AGENTS.md` 只读取当前 workspace,不会向父目录递归回溯 +- 如果团队要共享规则,请提交 `.lime/AGENTS.md` +- 如果规则只属于你自己,请放进 `.lime/AGENTS.local.md` + +## 推荐起步模板 + +如果你想先快速用起来,最小可用版本可以直接写: + +```md +# Lime 运行时规则 + +- 默认使用中文简体 +- 先给结论,再展开说明 +- 保持简洁,优先可执行建议 +- 修改代码时先读后写 +- 只改当前任务相关内容 +``` diff --git a/docs/content/06.development/3.building.md b/docs/content/06.development/3.building.md index 4039f8201..2f02a7224 100644 --- a/docs/content/06.development/3.building.md +++ b/docs/content/06.development/3.building.md @@ -113,6 +113,28 @@ pnpm tauri build pnpm tauri build --debug ``` +### 自动更新产物与签名 + +Lime 当前的桌面端升级流程分成两步: + +- 检查更新:客户端直接请求静态清单 `latest.json` +- 安装更新:客户端使用 Tauri updater 校验签名并安装对应平台包 + +`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json` 已启用 `createUpdaterArtifacts: true`,构建发布包时会额外生成 updater 需要的签名产物与 `latest.json`。 + +本地或 CI 构建发布版本时,至少需要准备以下环境变量: + +```bash +# updater 校验使用的公钥;编译时注入到桌面端 +export LIME_UPDATER_PUBLIC_KEY="..." + +# Tauri 生成 latest.json 和签名文件时使用的私钥 +export TAURI_SIGNING_PRIVATE_KEY="..." +export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="..." +``` + +如果缺少 `LIME_UPDATER_PUBLIC_KEY`,应用仍可读取 `latest.json` 显示新版本信息,但会降级为只能跳转发布页手动下载,无法执行应用内安装。 + ### 构建产物 | 平台 | 产物位置 | @@ -190,14 +212,15 @@ git push origin v1.0.1 - Push 到 main 分支触发构建 - 创建 tag 触发发布 +- Release 工作流会把 `LIME_UPDATER_PUBLIC_KEY`、`TAURI_SIGNING_PRIVATE_KEY`、`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 注入构建环境,用于生成可校验的 updater 清单与安装包签名 ### 构建矩阵 -| 平台 | 架构 | Runner | -| ------- | ----- | ------------- | -| macOS | arm64 | macos-latest | -| macOS | x64 | macos-13 | -| Windows | x64 | windows-2022 | +| 平台 | 架构 | Runner | +| ------- | ----- | ------------ | +| macOS | arm64 | macos-latest | +| macOS | x64 | macos-13 | +| Windows | x64 | windows-2022 | ## 调试 @@ -214,10 +237,10 @@ RUST_LOG=debug pnpm tauri dev ### 日志位置 -| 平台 | 路径 | -| ------- | -------------------------------- | -| macOS | `~/Library/Logs/Lime/` | -| Windows | `%APPDATA%\Lime\logs\` | +| 平台 | 路径 | +| ------- | ---------------------- | +| macOS | `~/Library/Logs/Lime/` | +| Windows | `%APPDATA%\Lime\logs\` | ## 常见问题 @@ -251,3 +274,10 @@ Windows 构建强烈建议签名: # 设置签名证书 export TAURI_SIGNING_PRIVATE_KEY="..." ``` + +如果要让桌面端“检查更新后直接安装”可用,还需要同时配置: + +```bash +export LIME_UPDATER_PUBLIC_KEY="..." +export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="..." +``` diff --git a/docs/content/index.md b/docs/content/index.md index d2d588b51..34dcf876d 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -33,6 +33,7 @@ Lime 是创作类 AI Agent 平台。 - [首页与工作台](/user-guide/dashboard) - [资源库](/user-guide/resources) +- [运行时 AGENTS 规则](/user-guide/runtime-agents) - [图片生成与编辑](/user-guide/image-generation) - [设置](/user-guide/settings) - [插件中心](/user-guide/plugins) diff --git a/package.json b/package.json index 2f2c365d8..d9d00df8a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "0.92.0", + "version": "0.93.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6b4cb7c3d..ebd73ef82 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -326,6 +326,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -369,7 +378,8 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "aster-core" -version = "0.20.0" +version = "0.20.1" +source = "git+https://github.com/astercloud/aster-rust?tag=v0.20.1#35f1bd92028e38df4edf5897f68a54369e929141" dependencies = [ "ahash", "anyhow", @@ -456,12 +466,13 @@ dependencies = [ "which 8.0.0", "winapi", "winreg 0.55.0", - "zip", + "zip 0.6.6", ] [[package]] name = "aster-models" -version = "0.20.0" +version = "0.20.1" +source = "git+https://github.com/astercloud/aster-rust?tag=v0.20.1#35f1bd92028e38df4edf5897f68a54369e929141" dependencies = [ "serde", "serde_json", @@ -2399,7 +2410,7 @@ dependencies = [ "dtoa-short", "itoa", "matches", - "phf 0.10.1", + "phf 0.8.0", "proc-macro2", "quote", "smallvec", @@ -2415,7 +2426,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.3", + "phf 0.8.0", "smallvec", ] @@ -2635,6 +2646,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -4336,7 +4358,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.56.0", ] [[package]] @@ -5062,7 +5084,7 @@ dependencies = [ [[package]] name = "lime" -version = "0.92.0" +version = "0.93.0" dependencies = [ "anyhow", "arboard", @@ -5142,6 +5164,7 @@ dependencies = [ "tauri-plugin-global-shortcut", "tauri-plugin-shell", "tauri-plugin-single-instance", + "tauri-plugin-updater", "tempfile", "thiserror 1.0.69", "tiktoken-rs", @@ -5160,14 +5183,16 @@ dependencies = [ "window-vibrancy 0.7.1", "windows 0.56.0", "winreg 0.52.0", - "zip", + "zip 0.6.6", ] [[package]] name = "lime-agent" -version = "0.92.0" +version = "0.93.0" dependencies = [ + "anyhow", "aster-core", + "async-stream", "async-trait", "chrono", "dirs 5.0.1", @@ -5192,7 +5217,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "0.92.0" +version = "0.93.0" dependencies = [ "chrono", "futures", @@ -5209,7 +5234,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "0.92.0" +version = "0.93.0" dependencies = [ "async-trait", "lime-core", @@ -5225,7 +5250,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "0.92.0" +version = "0.93.0" dependencies = [ "aster-models", "async-trait", @@ -5260,12 +5285,12 @@ dependencies = [ "url", "urlencoding", "uuid", - "zip", + "zip 0.6.6", ] [[package]] name = "lime-credential" -version = "0.92.0" +version = "0.93.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5300,7 +5325,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "0.92.0" +version = "0.93.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5321,7 +5346,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "0.92.0" +version = "0.93.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5341,7 +5366,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "0.92.0" +version = "0.93.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5373,7 +5398,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "0.92.0" +version = "0.93.0" dependencies = [ "async-trait", "lime-core", @@ -5392,7 +5417,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "0.92.0" +version = "0.93.0" dependencies = [ "anyhow", "async-stream", @@ -5446,7 +5471,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "0.92.0" +version = "0.93.0" dependencies = [ "aster-core", "async-stream", @@ -5491,7 +5516,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "0.92.0" +version = "0.93.0" dependencies = [ "axum 0.7.9", "futures", @@ -5506,7 +5531,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "0.92.0" +version = "0.93.0" dependencies = [ "anyhow", "aster-core", @@ -5543,12 +5568,12 @@ dependencies = [ "whoami", "winapi", "winreg 0.52.0", - "zip", + "zip 0.6.6", ] [[package]] name = "lime-skills" -version = "0.92.0" +version = "0.93.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5566,7 +5591,7 @@ dependencies = [ [[package]] name = "lime-terminal" -version = "0.92.0" +version = "0.93.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -5593,7 +5618,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "0.92.0" +version = "0.93.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5858,6 +5883,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -6266,7 +6297,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 2.0.2", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.117", @@ -6530,6 +6561,18 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-quartz-core" version = "0.2.2" @@ -6861,6 +6904,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "outref" version = "0.5.2" @@ -6993,7 +7050,9 @@ 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]] @@ -7002,9 +7061,7 @@ 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 +7165,12 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.10.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", + "phf_generator 0.8.0", + "phf_shared 0.8.0", "proc-macro-hack", "proc-macro2", "quote", @@ -7525,7 +7582,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.117", @@ -8018,15 +8075,20 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls 0.23.36", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper 1.0.2", "tokio", + "tokio-rustls 0.26.4", "tokio-util", "tower 0.5.3", "tower-http", @@ -8291,6 +8353,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.9", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -8992,7 +9081,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 6.0.0", + "dirs 4.0.0", ] [[package]] @@ -9961,6 +10050,39 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http 1.4.0", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.2", + "rustls 0.23.36", + "semver 1.0.27", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip 4.6.1", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -12741,6 +12863,18 @@ dependencies = [ "zstd 0.11.2+zstd.1.5.2", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0c467fb64..27d53f8b6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.92.0" +version = "0.93.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.20.0" } -aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.20.0" } +aster = { package = "aster-core", git = "https://github.com/astercloud/aster-rust", tag = "v0.20.1" } +aster-models = { git = "https://github.com/astercloud/aster-rust", tag = "v0.20.1" } # MCP (Model Context Protocol) rmcp = { version = "0.12.0", features = ["client", "transport-io", "transport-child-process"] } @@ -143,6 +143,7 @@ tauri-plugin-autostart = "2.3" tauri-plugin-dialog = "2.5.0" tauri-plugin-single-instance = "2.3" tauri-plugin-global-shortcut = "2.3" +tauri-plugin-updater = "2" # 测试 proptest = "1" @@ -191,7 +192,7 @@ version = "2.4" [package] name = "lime" -version = "0.92.0" +version = "0.93.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" @@ -235,6 +236,7 @@ tauri-plugin-autostart.workspace = true tauri-plugin-dialog.workspace = true tauri-plugin-single-instance.workspace = true tauri-plugin-global-shortcut.workspace = true +tauri-plugin-updater.workspace = true # 序列化 serde.workspace = true diff --git a/src-tauri/crates/agent/Cargo.toml b/src-tauri/crates/agent/Cargo.toml index c339f37ea..664641344 100644 --- a/src-tauri/crates/agent/Cargo.toml +++ b/src-tauri/crates/agent/Cargo.toml @@ -26,6 +26,8 @@ rusqlite.workspace = true uuid.workspace = true thiserror.workspace = true regex.workspace = true +anyhow.workspace = true +async-stream.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/src-tauri/crates/agent/src/aster_state.rs b/src-tauri/crates/agent/src/aster_state.rs index 3164710e7..00e2a7e70 100644 --- a/src-tauri/crates/agent/src/aster_state.rs +++ b/src-tauri/crates/agent/src/aster_state.rs @@ -32,6 +32,10 @@ use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; use crate::credential_bridge::{create_aster_provider, AsterProviderConfig, CredentialBridge}; +use crate::provider_continuation_state::{ + resolve_provider_continuation_capability, ProviderContinuationCapability, + ProviderContinuationCapable, ProviderContinuationState, +}; #[cfg(test)] use crate::queued_turn::QueuedTurnSnapshot; use lime_core::database::DbConnection; @@ -95,6 +99,23 @@ pub struct ProviderConfig { pub credential_uuid: Option, /// 是否强制 OpenAI provider 使用 Responses API pub force_responses_api: bool, + /// OAuth/本地 Provider 需要的凭证文件路径 + pub credential_path: Option, +} + +impl ProviderContinuationCapable for ProviderConfig { + fn provider_continuation_capability(&self) -> ProviderContinuationCapability { + resolve_provider_continuation_capability( + &self.provider_name, + self.provider_selector.as_deref(), + &self.model_name, + self.force_responses_api, + ) + } + + fn provider_continuation_state(&self) -> ProviderContinuationState { + ProviderContinuationState::history_replay_only() + } } /// Aster Agent 全局状态 @@ -247,6 +268,7 @@ impl AsterAgentState { .clone() .unwrap_or_else(|| format!("manual:{session_id}")), force_responses_api: config.force_responses_api, + credential_path: config.credential_path.clone(), }) .await .map_err(|e| format!("创建 Provider 失败: {e}"))?; @@ -326,6 +348,7 @@ impl AsterAgentState { base_url: aster_config.base_url.clone(), credential_uuid: Some(aster_config.credential_uuid.clone()), force_responses_api: aster_config.force_responses_api, + credential_path: aster_config.credential_path.clone(), }; let mut config_guard = self.current_provider_config.write().await; *config_guard = Some(config); @@ -613,6 +636,75 @@ mod tests { assert_eq!(snapshot.message_text, "restore body 1"); } + #[test] + fn test_provider_config_detects_previous_response_id_capability_for_codex_responses() { + let config = ProviderConfig { + provider_name: "openai".to_string(), + provider_selector: Some("openai".to_string()), + model_name: "gpt-5-codex".to_string(), + api_key: None, + base_url: None, + credential_uuid: None, + force_responses_api: false, + credential_path: None, + }; + + assert_eq!( + config.provider_continuation_capability(), + ProviderContinuationCapability::PreviousResponseId + ); + assert_eq!( + config.provider_continuation_state(), + ProviderContinuationState::HistoryReplayOnly + ); + } + + #[test] + fn test_provider_config_detects_previous_response_id_capability_when_forced() { + let config = ProviderConfig { + provider_name: "openai".to_string(), + provider_selector: Some("openai".to_string()), + model_name: "gpt-4o".to_string(), + api_key: None, + base_url: None, + credential_uuid: None, + force_responses_api: true, + credential_path: None, + }; + + assert_eq!( + config.provider_continuation_capability(), + ProviderContinuationCapability::PreviousResponseId + ); + assert_eq!( + config.provider_continuation_state(), + ProviderContinuationState::HistoryReplayOnly + ); + } + + #[test] + fn test_provider_config_detects_kiro_provider_session_token_capability() { + let config = ProviderConfig { + provider_name: "kiro".to_string(), + provider_selector: Some("kiro".to_string()), + model_name: "claude-3.7-sonnet".to_string(), + api_key: None, + base_url: None, + credential_uuid: None, + force_responses_api: false, + credential_path: None, + }; + + assert_eq!( + config.provider_continuation_capability(), + ProviderContinuationCapability::ProviderSessionToken + ); + assert_eq!( + config.provider_continuation_state(), + ProviderContinuationState::HistoryReplayOnly + ); + } + // ========================================================================= // Skills 集成测试 // ========================================================================= diff --git a/src-tauri/crates/agent/src/credential_bridge.rs b/src-tauri/crates/agent/src/credential_bridge.rs index 7b4fac2fb..6a9a3d5d4 100644 --- a/src-tauri/crates/agent/src/credential_bridge.rs +++ b/src-tauri/crates/agent/src/credential_bridge.rs @@ -22,6 +22,9 @@ use lime_services::api_key_provider_service::ApiKeyProviderService; use lime_services::provider_pool_service::ProviderPoolService; use std::sync::Arc; +use crate::kiro_provider_adapter::LimeKiroProvider; +use crate::provider_safety::wrap_provider_with_message_safety; + /// 凭证桥接错误 #[derive(Debug, Clone)] pub enum CredentialBridgeError { @@ -66,6 +69,8 @@ pub struct AsterProviderConfig { pub credential_uuid: String, /// 是否强制 OpenAI provider 使用 Responses API(用于 Codex 等兼容链路) pub force_responses_api: bool, + /// OAuth/本地 Provider 需要的凭证文件路径 + pub credential_path: Option, } /// 凭证池桥接器 @@ -212,8 +217,7 @@ impl CredentialBridge { let token = self .get_kiro_token(creds_file_path, db, &credential.uuid) .await?; - // Kiro 使用 CodeWhisperer API,映射到 bedrock provider - ("bedrock".to_string(), Some(token), None, false) + ("kiro".to_string(), Some(token), None, false) } // Gemini OAuth @@ -281,6 +285,10 @@ impl CredentialBridge { base_url, credential_uuid: credential.uuid.clone(), force_responses_api, + credential_path: match &credential.credential { + CredentialData::KiroOAuth { creds_file_path } => Some(creds_file_path.clone()), + _ => None, + }, }) } @@ -395,6 +403,27 @@ impl CredentialBridge { pub async fn create_aster_provider( config: &AsterProviderConfig, ) -> Result, CredentialBridgeError> { + if config.provider_name == "kiro" { + let model_config = ModelConfig::new(&config.model_name).map_err(|e| { + CredentialBridgeError::ProviderCreationFailed(format!("创建 ModelConfig 失败: {e}")) + })?; + + let credential_path = config.credential_path.clone().ok_or_else(|| { + CredentialBridgeError::ProviderCreationFailed( + "Kiro provider 缺少 credential_path".to_string(), + ) + })?; + + let provider = LimeKiroProvider::new(credential_path, model_config).map_err(|error| { + CredentialBridgeError::ProviderCreationFailed(format!( + "创建 Kiro Provider 失败: {}", + error + )) + })?; + + return Ok(wrap_provider_with_message_safety(Arc::new(provider))); + } + // 设置环境变量 set_provider_env_vars(config); @@ -406,6 +435,7 @@ pub async fn create_aster_provider( // 创建 Provider aster::providers::create(&config.provider_name, model_config) .await + .map(wrap_provider_with_message_safety) .map_err(|e| { CredentialBridgeError::ProviderCreationFailed(format!("创建 Provider 失败: {e}")) }) @@ -544,7 +574,7 @@ fn set_provider_env_vars(config: &AsterProviderConfig) { /// 将 Lime PoolProviderType 映射到 Aster Provider 名称 pub fn map_pool_type_to_aster(pool_type: &PoolProviderType) -> &'static str { match pool_type { - PoolProviderType::Kiro => "bedrock", + PoolProviderType::Kiro => "kiro", PoolProviderType::Gemini => "google", PoolProviderType::Antigravity => "google", PoolProviderType::OpenAI => "openai", @@ -574,7 +604,8 @@ fn map_provider_type_to_aster(provider_type: &str) -> &'static str { "openai" => "openai", "anthropic" | "claude" => "anthropic", "google" | "gemini" => "google", - "bedrock" | "kiro" => "bedrock", + "bedrock" => "bedrock", + "kiro" | "codewhisperer" => "kiro", "gcpvertexai" | "vertex" => "gcpvertexai", "codex" => "codex", "azure" | "azure-openai" => "azure", @@ -621,7 +652,7 @@ mod tests { "anthropic" ); assert_eq!(map_pool_type_to_aster(&PoolProviderType::Gemini), "google"); - assert_eq!(map_pool_type_to_aster(&PoolProviderType::Kiro), "bedrock"); + assert_eq!(map_pool_type_to_aster(&PoolProviderType::Kiro), "kiro"); } #[test] @@ -659,6 +690,7 @@ mod tests { base_url: Some("https://example.com/openai".to_string()), credential_uuid: "test-uuid".to_string(), force_responses_api: true, + credential_path: None, }; set_provider_env_vars(&config); @@ -717,6 +749,7 @@ mod tests { base_url: Some("https://open.bigmodel.cn/api/anthropic".to_string()), credential_uuid: "test-uuid".to_string(), force_responses_api: false, + credential_path: None, }; set_provider_env_vars(&config); diff --git a/src-tauri/crates/agent/src/event_converter.rs b/src-tauri/crates/agent/src/event_converter.rs index 615a8517f..41900a958 100644 --- a/src-tauri/crates/agent/src/event_converter.rs +++ b/src-tauri/crates/agent/src/event_converter.rs @@ -994,7 +994,9 @@ pub fn convert_item_runtime(item: ItemRuntime) -> AgentThreadItem { /// 将 Aster Message 转换为 TauriAgentEvent 列表 fn convert_message(message: Message) -> Vec { - let mut events = Vec::new(); + let mut events = vec![TauriAgentEvent::Message { + message: convert_to_tauri_message(&message), + }]; for content in &message.content { match content { @@ -1282,13 +1284,12 @@ mod tests { let message = Message::assistant().with_text("Hello, world!"); let events = convert_message(message); - assert_eq!(events.len(), 1); - match &events[0] { - TauriAgentEvent::TextDelta { text } => { - assert_eq!(text, "Hello, world!"); - } - _ => panic!("Expected TextDelta event"), - } + assert_eq!(events.len(), 2); + assert!(matches!(events[0], TauriAgentEvent::Message { .. })); + assert!(matches!( + &events[1], + TauriAgentEvent::TextDelta { text } if text == "Hello, world!" + )); } #[test] @@ -1767,4 +1768,18 @@ mod tests { Some(&serde_json::json!("/tmp/aster_tasks/task-1.log")) ); } + + #[test] + fn test_convert_message_emits_full_message_event_with_id() { + let message = Message::assistant().with_id("resp-1").with_text("hello"); + + let events = convert_agent_event(AgentEvent::Message(message)); + + assert!(events.iter().any( + |event| matches!(event, TauriAgentEvent::Message { message } if message.id.as_deref() == Some("resp-1")) + )); + assert!(events + .iter() + .any(|event| matches!(event, TauriAgentEvent::TextDelta { text } if text == "hello"))); + } } diff --git a/src-tauri/crates/agent/src/kiro_provider_adapter.rs b/src-tauri/crates/agent/src/kiro_provider_adapter.rs new file mode 100644 index 000000000..bba1be1e3 --- /dev/null +++ b/src-tauri/crates/agent/src/kiro_provider_adapter.rs @@ -0,0 +1,360 @@ +use anyhow::anyhow; +use aster::conversation::message::{Message, MessageContent}; +use aster::model::ModelConfig; +use aster::providers::base::{ + ConfigKey, MessageStream, ModelInfo, Provider, ProviderMetadata, ProviderUsage, Usage, +}; +use aster::providers::errors::ProviderError; +use aster::providers::formats::openai::{ + format_messages, format_tools, response_to_streaming_message, +}; +use aster::providers::utils::ImageFormat; +use aster::session_context::current_turn_context; +use async_stream::try_stream; +use async_trait::async_trait; +use futures::{pin_mut, StreamExt}; +use lime_core::models::openai::ChatCompletionRequest; +use lime_providers::providers::{KiroProvider, TokenManager}; +use lime_providers::streaming::converter::{StreamConverter, StreamFormat as LimeStreamFormat}; +use rmcp::model::{Role, Tool}; +use serde_json::json; +use uuid::Uuid; + +const KIRO_PROVIDER_NAME: &str = "kiro"; + +pub(crate) struct LimeKiroProvider { + credential_path: String, + model: ModelConfig, + name: String, +} + +impl LimeKiroProvider { + pub(crate) fn new( + credential_path: impl Into, + model: ModelConfig, + ) -> Result { + let credential_path = credential_path.into(); + if credential_path.trim().is_empty() { + return Err(ProviderError::ExecutionError( + "Kiro provider 缺少 credential_path".to_string(), + )); + } + + Ok(Self { + credential_path, + model, + name: KIRO_PROVIDER_NAME.to_string(), + }) + } + + async fn load_provider(&self) -> Result { + let mut provider = KiroProvider::new(); + provider + .load_credentials_from_path(&self.credential_path) + .await + .map_err(|error| { + ProviderError::Authentication(format!("加载 Kiro 凭证失败: {}", error)) + })?; + + provider.ensure_valid_token().await.map_err(|error| { + ProviderError::Authentication(format!("刷新 Kiro Token 失败: {}", error)) + })?; + + Ok(provider) + } + + fn normalize_optional_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + } + + fn resolve_conversation_id_from_turn_context() -> Option { + let turn_context = current_turn_context()?; + let provider_continuation = turn_context + .metadata + .get("provider_continuation")? + .as_object()?; + + if provider_continuation + .get("enabled") + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return None; + } + + if provider_continuation + .get("kind") + .and_then(serde_json::Value::as_str) + .map(str::trim) + != Some("provider_session_token") + { + return None; + } + + for key in [ + "session_token", + "sessionToken", + "provider_session_token", + "providerSessionToken", + "conversation_id", + "conversationId", + ] { + if let Some(value) = Self::normalize_optional_text( + provider_continuation + .get(key) + .and_then(serde_json::Value::as_str), + ) { + return Some(value); + } + } + + None + } + + fn resolve_or_create_conversation_id() -> String { + Self::resolve_conversation_id_from_turn_context() + .unwrap_or_else(|| Uuid::new_v4().to_string()) + } + + fn build_chat_request( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + stream: bool, + ) -> Result { + let mut openai_messages = vec![json!({ + "role": "system", + "content": system, + })]; + openai_messages.extend(format_messages(messages, &ImageFormat::OpenAi)); + + let tools_payload = format_tools(tools) + .map_err(|error| ProviderError::ExecutionError(error.to_string()))?; + + let mut payload = json!({ + "model": model_config.model_name, + "messages": openai_messages, + "stream": stream, + }); + + if !tools_payload.is_empty() { + payload["tools"] = json!(tools_payload); + } + + serde_json::from_value(payload).map_err(|error| { + ProviderError::ExecutionError(format!( + "构造 Kiro ChatCompletionRequest 失败: {}", + error + )) + }) + } + + fn attach_conversation_id(mut message: Message, conversation_id: &str) -> Message { + if message.role == Role::Assistant { + message.id = Some(conversation_id.to_string()); + } + message + } + + fn push_or_merge_content(target: &mut Vec, content: MessageContent) { + match (target.last_mut(), &content) { + (Some(MessageContent::Text(existing)), MessageContent::Text(incoming)) => { + existing.text.push_str(&incoming.text); + } + (Some(MessageContent::Thinking(existing)), MessageContent::Thinking(incoming)) => { + existing.thinking.push_str(&incoming.thinking); + } + _ => target.push(content), + } + } + + fn merge_message_chunk(target: &mut Message, chunk: Message) { + if target.id.is_none() { + target.id = chunk.id.clone(); + } + + for content in chunk.content { + Self::push_or_merge_content(&mut target.content, content); + } + } + + async fn stream_with_model_and_conversation( + &self, + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + conversation_id: String, + ) -> Result { + let request = Self::build_chat_request(model_config, system, messages, tools, true)?; + let provider = self.load_provider().await?; + let source_stream = provider + .call_api_stream_with_conversation_id(&request, Some(&conversation_id)) + .await + .map_err(Self::map_lime_provider_error)?; + + let model_name = model_config.model_name.clone(); + let openai_line_stream = Box::pin(try_stream! { + let mut source_stream = source_stream; + let mut converter = StreamConverter::with_model( + LimeStreamFormat::AwsEventStream, + LimeStreamFormat::OpenAiSse, + &model_name, + ); + + while let Some(chunk) = source_stream.next().await { + let chunk = chunk.map_err(|error| anyhow!(error.to_string()))?; + for event in converter.convert(&chunk) { + for line in event.lines() { + let line = line.trim_end_matches('\r'); + if !line.is_empty() { + yield line.to_string(); + } + } + } + } + + for event in converter.finish() { + for line in event.lines() { + let line = line.trim_end_matches('\r'); + if !line.is_empty() { + yield line.to_string(); + } + } + } + }); + + Ok(Box::pin(try_stream! { + let message_stream = response_to_streaming_message(openai_line_stream); + pin_mut!(message_stream); + + while let Some(item) = message_stream.next().await { + let (message, usage) = item.map_err(|error| { + ProviderError::RequestFailed(format!("解析 Kiro 流式响应失败: {}", error)) + })?; + + let message = message.map(|message| { + Self::attach_conversation_id(message, &conversation_id) + }); + + yield (message, usage); + } + })) + } + + fn map_lime_provider_error(error: lime_providers::providers::ProviderError) -> ProviderError { + match error { + lime_providers::providers::ProviderError::AuthenticationError(details) => { + ProviderError::Authentication(details) + } + lime_providers::providers::ProviderError::RateLimitError(details) => { + ProviderError::RateLimitExceeded { + details, + retry_delay: None, + } + } + lime_providers::providers::ProviderError::ServerError(details) => { + ProviderError::ServerError(details) + } + lime_providers::providers::ProviderError::RequestError(details) => { + ProviderError::RequestFailed(details) + } + lime_providers::providers::ProviderError::ParseError(details) + | lime_providers::providers::ProviderError::ConfigurationError(details) + | lime_providers::providers::ProviderError::Unknown(details) + | lime_providers::providers::ProviderError::TokenExpired(details) + | lime_providers::providers::ProviderError::NetworkError(details) => { + ProviderError::ExecutionError(details) + } + } + } +} + +#[async_trait] +impl Provider for LimeKiroProvider { + fn metadata() -> ProviderMetadata + where + Self: Sized, + { + ProviderMetadata::with_models( + KIRO_PROVIDER_NAME, + "Kiro", + "Lime 本地 Kiro/CodeWhisperer Provider 适配器", + "claude-sonnet-4-5", + vec![ModelInfo::new("claude-sonnet-4-5", 200_000)], + "", + vec![ConfigKey::new("KIRO_CREDENTIAL_PATH", true, true, None)], + ) + } + + fn get_name(&self) -> &str { + &self.name + } + + async fn complete_with_model( + &self, + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let conversation_id = Self::resolve_or_create_conversation_id(); + let mut stream = self + .stream_with_model_and_conversation( + model_config, + system, + messages, + tools, + conversation_id.clone(), + ) + .await?; + + let mut final_message = Message::assistant().with_id(conversation_id.clone()); + let mut final_usage = None; + + while let Some(item) = stream.next().await { + let (message, usage) = item?; + if let Some(message) = message { + Self::merge_message_chunk(&mut final_message, message); + } + if usage.is_some() { + final_usage = usage; + } + } + + let usage = final_usage.unwrap_or_else(|| { + ProviderUsage::new(model_config.model_name.clone(), Usage::default()) + }); + + Ok((final_message, usage)) + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + async fn stream( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + let conversation_id = Self::resolve_or_create_conversation_id(); + self.stream_with_model_and_conversation( + &self.model, + system, + messages, + tools, + conversation_id, + ) + .await + } + + fn supports_streaming(&self) -> bool { + true + } +} diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index 4ea4cd06c..495bcb06f 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -19,12 +19,17 @@ pub mod credential_bridge; pub mod durable_memory_fs; pub mod event_converter; pub mod hooks; +mod kiro_provider_adapter; pub mod lsp_bridge; pub mod mcp_bridge; pub mod prompt; +pub mod provider_continuation_state; +mod provider_safety; pub mod queued_turn; pub mod request_tool_policy; +pub mod runtime_projection_snapshot; pub mod runtime_queue; +pub mod session_state_snapshot; mod session_store; pub mod skill_execution; pub mod subagent_control; @@ -32,6 +37,8 @@ pub mod subagent_profiles; pub mod subagent_scheduler; pub mod tool_io_offload; pub mod tools; +pub mod turn_input_envelope; +pub mod turn_state; mod write_artifact_events; pub use ask_bridge::{create_ask_callback, extract_response as extract_ask_response}; @@ -56,6 +63,13 @@ pub use event_converter::{ pub use lime_mcp as mcp; pub use lsp_bridge::create_lsp_callback; pub use prompt::SystemPromptBuilder; +pub use prompt::{ + build_runtime_agents_prompt, merge_system_prompt_with_runtime_agents, + RUNTIME_AGENTS_PROMPT_MARKER, +}; +pub use provider_continuation_state::{ + ProviderContinuationCapability, ProviderContinuationCapable, ProviderContinuationState, +}; pub use queued_turn::QueuedTurnSnapshot; pub use request_tool_policy::{ execute_web_search_preflight_if_needed, merge_system_prompt_with_request_tool_policy, @@ -64,12 +78,14 @@ pub use request_tool_policy::{ ReplyAttemptError, RequestToolPolicy, RequestToolPolicyMode, StreamReplyExecution, WebSearchExecutionTracker, REQUEST_TOOL_POLICY_MARKER, }; +pub use runtime_projection_snapshot::RuntimeProjectionSnapshot; pub use runtime_queue::{ 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_state_snapshot::SessionStateSnapshot; pub use session_store::{ create_session_sync, delete_session, get_persisted_session_metadata_sync, get_runtime_session_detail, get_session_sync, list_sessions_sync, @@ -101,4 +117,10 @@ pub use subagent_scheduler::{ LimeScheduler, LimeSubAgentExecutor, SchedulerEventEmitter, SubAgentProgressEvent, SubAgentRole, }; pub use tools::{BrowserAction, BrowserTool, BrowserToolError, BrowserToolResult}; +pub use turn_input_envelope::{ + TurnDiagnosticsSnapshot, TurnInputEnvelope, TurnInputEnvelopeBuilder, TurnMessageHistorySource, + TurnPromptAugmentationStage, TurnPromptAugmentationStageKind, TurnProviderRoutingSnapshot, + TurnRequestToolPolicySnapshot, TurnSystemPromptSource, +}; +pub use turn_state::TurnState; pub use write_artifact_events::WriteArtifactEventEmitter; diff --git a/src-tauri/crates/agent/src/prompt/mod.rs b/src-tauri/crates/agent/src/prompt/mod.rs index a0e2fef37..78ab00582 100644 --- a/src-tauri/crates/agent/src/prompt/mod.rs +++ b/src-tauri/crates/agent/src/prompt/mod.rs @@ -9,6 +9,7 @@ pub mod builder; pub mod instruction_discovery; +pub mod runtime_agents; pub mod templates; pub use builder::SystemPromptBuilder; @@ -16,4 +17,8 @@ pub use instruction_discovery::{ clear_instruction_cache, discover_instructions, discover_instructions_cached, merge_instructions, InstructionLayer, InstructionSource, }; +pub use runtime_agents::{ + build_runtime_agents_prompt, merge_system_prompt_with_runtime_agents, + RUNTIME_AGENTS_PROMPT_MARKER, +}; pub use templates::*; diff --git a/src-tauri/crates/agent/src/prompt/runtime_agents.rs b/src-tauri/crates/agent/src/prompt/runtime_agents.rs new file mode 100644 index 000000000..28727f2ee --- /dev/null +++ b/src-tauri/crates/agent/src/prompt/runtime_agents.rs @@ -0,0 +1,144 @@ +//! Lime 运行时 AGENTS 指令加载 +//! +//! 仅用于 Lime 应用运行时会话: +//! - 全局:`~/.lime/AGENTS.md` +//! - 工作区:`/.lime/AGENTS.md` + +use lime_core::app_paths; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +pub const RUNTIME_AGENTS_PROMPT_MARKER: &str = "【Lime Runtime AGENTS 指令】"; + +pub fn merge_system_prompt_with_runtime_agents( + base_prompt: Option, + working_dir: Option<&Path>, +) -> Option { + let runtime_prompt = build_runtime_agents_prompt(working_dir); + match (base_prompt, runtime_prompt) { + (Some(base), Some(runtime)) => { + if base.contains(RUNTIME_AGENTS_PROMPT_MARKER) { + Some(base) + } else if base.trim().is_empty() { + Some(runtime) + } else { + Some(format!("{base}\n\n{runtime}")) + } + } + (Some(base), None) => Some(base), + (None, Some(runtime)) => Some(runtime), + (None, None) => None, + } +} + +pub fn build_runtime_agents_prompt(working_dir: Option<&Path>) -> Option { + let global_path = app_paths::best_effort_user_memory_path(); + let workspace_path = working_dir.map(|dir| dir.join(".lime").join("AGENTS.md")); + build_runtime_agents_prompt_with_paths(Some(global_path.as_path()), workspace_path.as_deref()) +} + +fn build_runtime_agents_prompt_with_paths( + global_path: Option<&Path>, + workspace_path: Option<&Path>, +) -> Option { + let mut sections = Vec::new(); + let mut seen = HashSet::::new(); + + if let Some((path, content)) = load_runtime_agents_layer(global_path, &mut seen) { + sections.push(format!( + "### 全局运行时指令 ({})\n{}", + path.display(), + content + )); + } + + if let Some((path, content)) = load_runtime_agents_layer(workspace_path, &mut seen) { + sections.push(format!( + "### Workspace 运行时指令 ({})\n{}", + path.display(), + content + )); + } + + if sections.is_empty() { + None + } else { + Some(format!( + "{RUNTIME_AGENTS_PROMPT_MARKER}\n以下内容来自 Lime 运行时 AGENTS 文件,请优先遵循:\n\n{}", + sections.join("\n\n") + )) + } +} + +fn load_runtime_agents_layer( + path: Option<&Path>, + seen: &mut HashSet, +) -> Option<(PathBuf, String)> { + let path = path?; + let normalized = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if !seen.insert(normalized.clone()) || !normalized.is_file() { + return None; + } + + let content = std::fs::read_to_string(&normalized).ok()?; + let trimmed = content.trim(); + if trimmed.is_empty() { + return None; + } + + Some((normalized, trimmed.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn should_build_prompt_with_global_and_workspace_layers() { + let tmp = TempDir::new().expect("create temp dir"); + let global_path = tmp.path().join("global").join("AGENTS.md"); + let workspace_path = tmp.path().join("workspace").join(".lime").join("AGENTS.md"); + fs::create_dir_all(global_path.parent().expect("global parent")).expect("create global"); + fs::create_dir_all(workspace_path.parent().expect("workspace parent")) + .expect("create workspace"); + fs::write(&global_path, "- 全局偏好").expect("write global agents"); + fs::write(&workspace_path, "- 工作区偏好").expect("write workspace agents"); + + let prompt = build_runtime_agents_prompt_with_paths( + Some(global_path.as_path()), + Some(workspace_path.as_path()), + ) + .expect("prompt should exist"); + + assert!(prompt.contains(RUNTIME_AGENTS_PROMPT_MARKER)); + assert!(prompt.contains("全局偏好")); + assert!(prompt.contains("工作区偏好")); + } + + #[test] + fn should_skip_duplicate_paths() { + let tmp = TempDir::new().expect("create temp dir"); + let path = tmp.path().join("shared").join("AGENTS.md"); + fs::create_dir_all(path.parent().expect("shared parent")).expect("create dir"); + fs::write(&path, "- 同一路径").expect("write agents"); + + let prompt = + build_runtime_agents_prompt_with_paths(Some(path.as_path()), Some(path.as_path())) + .expect("prompt should exist"); + + assert_eq!(prompt.matches("### ").count(), 1); + } + + #[test] + fn merge_should_append_runtime_agents_once() { + let merged = merge_system_prompt_with_runtime_agents( + Some(format!("{RUNTIME_AGENTS_PROMPT_MARKER}\n已有内容")), + None, + ) + .expect("merged prompt"); + + assert_eq!(merged.matches(RUNTIME_AGENTS_PROMPT_MARKER).count(), 1); + } +} diff --git a/src-tauri/crates/agent/src/provider_continuation_state.rs b/src-tauri/crates/agent/src/provider_continuation_state.rs new file mode 100644 index 000000000..0c1035829 --- /dev/null +++ b/src-tauri/crates/agent/src/provider_continuation_state.rs @@ -0,0 +1,250 @@ +use serde::{Deserialize, Serialize}; + +fn normalize_optional_text(value: Option) -> Option { + let text = value?; + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn normalize_identifier(value: Option<&str>) -> Option { + normalize_optional_text(value.map(str::to_string)).map(|value| value.to_ascii_lowercase()) +} + +fn is_openai_responses_model(model_name: &str) -> bool { + let normalized = model_name.trim().to_ascii_lowercase(); + normalized.starts_with("gpt-5") && normalized.contains("codex") +} + +fn is_kiro_session_provider(candidate: &str) -> bool { + let normalized = candidate.trim().to_ascii_lowercase(); + normalized.contains("kiro") || normalized.contains("codewhisperer") +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProviderContinuationCapability { + HistoryReplayOnly, + ProviderSessionToken, + PreviousResponseId, + StickyRoutingHint, +} + +impl Default for ProviderContinuationCapability { + fn default() -> Self { + Self::HistoryReplayOnly + } +} + +impl ProviderContinuationCapability { + pub fn supports_remote_continuation(self) -> bool { + !matches!(self, Self::HistoryReplayOnly) + } +} + +pub fn resolve_provider_continuation_capability( + provider_name: &str, + provider_selector: Option<&str>, + model_name: &str, + force_responses_api: bool, +) -> ProviderContinuationCapability { + let provider_name = normalize_identifier(Some(provider_name)); + let provider_selector = normalize_identifier(provider_selector); + let provider_candidates = [provider_selector.as_deref(), provider_name.as_deref()]; + + if provider_candidates + .iter() + .flatten() + .any(|candidate| candidate.contains("openai")) + && (force_responses_api || is_openai_responses_model(model_name)) + { + return ProviderContinuationCapability::PreviousResponseId; + } + + if provider_candidates + .iter() + .flatten() + .any(|candidate| is_kiro_session_provider(candidate)) + { + return ProviderContinuationCapability::ProviderSessionToken; + } + + ProviderContinuationCapability::HistoryReplayOnly +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ProviderContinuationState { + HistoryReplayOnly, + ProviderSessionToken { session_token: String }, + PreviousResponseId { previous_response_id: String }, + StickyRoutingHint { routing_hint: String }, +} + +impl Default for ProviderContinuationState { + fn default() -> Self { + Self::HistoryReplayOnly + } +} + +impl ProviderContinuationState { + pub fn history_replay_only() -> Self { + Self::HistoryReplayOnly + } + + pub fn provider_session_token(session_token: impl Into) -> Self { + match normalize_optional_text(Some(session_token.into())) { + Some(session_token) => Self::ProviderSessionToken { session_token }, + None => Self::HistoryReplayOnly, + } + } + + pub fn previous_response_id(previous_response_id: impl Into) -> Self { + match normalize_optional_text(Some(previous_response_id.into())) { + Some(previous_response_id) => Self::PreviousResponseId { + previous_response_id, + }, + None => Self::HistoryReplayOnly, + } + } + + pub fn sticky_routing_hint(routing_hint: impl Into) -> Self { + match normalize_optional_text(Some(routing_hint.into())) { + Some(routing_hint) => Self::StickyRoutingHint { routing_hint }, + None => Self::HistoryReplayOnly, + } + } + + pub fn kind(&self) -> &'static str { + match self { + Self::HistoryReplayOnly => "history_replay_only", + Self::ProviderSessionToken { .. } => "provider_session_token", + Self::PreviousResponseId { .. } => "previous_response_id", + Self::StickyRoutingHint { .. } => "sticky_routing_hint", + } + } + + pub fn matches_capability(&self, capability: ProviderContinuationCapability) -> bool { + match self { + Self::HistoryReplayOnly => true, + Self::ProviderSessionToken { .. } => { + capability == ProviderContinuationCapability::ProviderSessionToken + } + Self::PreviousResponseId { .. } => { + capability == ProviderContinuationCapability::PreviousResponseId + } + Self::StickyRoutingHint { .. } => { + capability == ProviderContinuationCapability::StickyRoutingHint + } + } + } +} + +pub trait ProviderContinuationCapable { + fn provider_continuation_capability(&self) -> ProviderContinuationCapability; + + fn provider_continuation_state(&self) -> ProviderContinuationState { + ProviderContinuationState::history_replay_only() + } +} + +#[cfg(test)] +mod tests { + use super::{ + resolve_provider_continuation_capability, ProviderContinuationCapability, + ProviderContinuationState, + }; + + #[test] + fn test_provider_continuation_state_defaults_to_history_replay_only() { + assert_eq!( + ProviderContinuationState::default(), + ProviderContinuationState::HistoryReplayOnly + ); + assert_eq!( + ProviderContinuationState::provider_session_token(" "), + ProviderContinuationState::HistoryReplayOnly + ); + } + + #[test] + fn test_provider_continuation_state_serializes_tagged_shape() { + let state = ProviderContinuationState::previous_response_id("resp-1"); + + assert_eq!(state.kind(), "previous_response_id"); + assert_eq!( + serde_json::to_value(&state).expect("serialize continuation state"), + serde_json::json!({ + "kind": "previous_response_id", + "previous_response_id": "resp-1" + }) + ); + } + + #[test] + fn test_provider_continuation_capability_distinguishes_optional_remote_modes() { + assert!(!ProviderContinuationCapability::HistoryReplayOnly.supports_remote_continuation()); + assert!(ProviderContinuationCapability::PreviousResponseId.supports_remote_continuation()); + assert!(ProviderContinuationCapability::ProviderSessionToken.supports_remote_continuation()); + } + + #[test] + fn test_provider_continuation_state_matches_capability() { + assert!(ProviderContinuationState::previous_response_id("resp-1") + .matches_capability(ProviderContinuationCapability::PreviousResponseId)); + assert!(!ProviderContinuationState::previous_response_id("resp-1") + .matches_capability(ProviderContinuationCapability::ProviderSessionToken)); + assert!(ProviderContinuationState::history_replay_only() + .matches_capability(ProviderContinuationCapability::ProviderSessionToken)); + } + + #[test] + fn test_resolve_provider_continuation_capability_detects_openai_responses_routes() { + assert_eq!( + resolve_provider_continuation_capability( + "openai", + Some("openai"), + "gpt-5-codex", + false + ), + ProviderContinuationCapability::PreviousResponseId + ); + assert_eq!( + resolve_provider_continuation_capability( + "openai", + Some("deepseek"), + "deepseek-r1", + false + ), + ProviderContinuationCapability::HistoryReplayOnly + ); + } + + #[test] + fn test_resolve_provider_continuation_capability_detects_kiro_provider_session_token() { + assert_eq!( + resolve_provider_continuation_capability("kiro", Some("kiro"), "claude-3.7", false), + ProviderContinuationCapability::ProviderSessionToken + ); + assert_eq!( + resolve_provider_continuation_capability( + "kiro", + Some("codewhisperer"), + "claude-3.7", + false + ), + ProviderContinuationCapability::ProviderSessionToken + ); + } + + #[test] + fn test_resolve_provider_continuation_capability_respects_force_responses_api() { + assert_eq!( + resolve_provider_continuation_capability("openai", Some("openai"), "gpt-4o", true), + ProviderContinuationCapability::PreviousResponseId + ); + } +} diff --git a/src-tauri/crates/agent/src/provider_safety.rs b/src-tauri/crates/agent/src/provider_safety.rs new file mode 100644 index 000000000..4d937c3ce --- /dev/null +++ b/src-tauri/crates/agent/src/provider_safety.rs @@ -0,0 +1,302 @@ +use aster::conversation::message::{Message, MessageContent}; +use aster::model::ModelConfig; +use aster::providers::base::{ + LeadWorkerProviderTrait, MessageStream, Provider, ProviderMetadata, ProviderUsage, +}; +use aster::providers::errors::ProviderError; +use aster::providers::RetryConfig; +use async_trait::async_trait; +use rmcp::model::Tool; +use std::collections::HashSet; +use std::sync::Arc; + +pub(crate) fn wrap_provider_with_message_safety(provider: Arc) -> Arc { + Arc::new(MessageSafeProvider { inner: provider }) +} + +fn normalize_provider_messages(messages: &[Message]) -> Vec { + let mut normalized_messages: Vec = messages.to_vec(); + let mut valid_request_ids = HashSet::new(); + let mut matched_request_ids = HashSet::new(); + let mut removed_invalid_requests = 0_usize; + let mut removed_invalid_responses = 0_usize; + + for message in &mut normalized_messages { + let mut next_content = Vec::with_capacity(message.content.len()); + + for content in message.content.drain(..) { + match &content { + MessageContent::ToolRequest(request) => { + if message.role != rmcp::model::Role::Assistant || request.tool_call.is_err() { + removed_invalid_requests += 1; + continue; + } + valid_request_ids.insert(request.id.clone()); + next_content.push(content); + } + MessageContent::FrontendToolRequest(request) => { + if message.role != rmcp::model::Role::Assistant || request.tool_call.is_err() { + removed_invalid_requests += 1; + continue; + } + valid_request_ids.insert(request.id.clone()); + next_content.push(content); + } + MessageContent::ToolResponse(response) => { + if message.role != rmcp::model::Role::User + || !valid_request_ids.contains(&response.id) + || matched_request_ids.contains(&response.id) + { + removed_invalid_responses += 1; + continue; + } + matched_request_ids.insert(response.id.clone()); + next_content.push(content); + } + _ => next_content.push(content), + } + } + + message.content = next_content; + } + + normalized_messages.iter_mut().for_each(|message| { + message.content.retain(|content| match content { + MessageContent::ToolRequest(request) => matched_request_ids.contains(&request.id), + MessageContent::FrontendToolRequest(request) => { + matched_request_ids.contains(&request.id) + } + MessageContent::ToolResponse(response) => matched_request_ids.contains(&response.id), + _ => true, + }); + }); + + normalized_messages.retain(|message| !message.content.is_empty()); + + if removed_invalid_requests > 0 || removed_invalid_responses > 0 { + tracing::warn!( + removed_invalid_requests, + removed_invalid_responses, + "[ProviderSafety] 已在 provider 请求前归一化工具消息链" + ); + } + + normalized_messages +} + +struct MessageSafeProvider { + inner: Arc, +} + +#[async_trait] +impl Provider for MessageSafeProvider { + fn metadata() -> ProviderMetadata + where + Self: Sized, + { + ProviderMetadata::empty() + } + + fn get_name(&self) -> &str { + self.inner.get_name() + } + + async fn complete_with_model( + &self, + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + let normalized_messages = normalize_provider_messages(messages); + self.inner + .complete_with_model(model_config, system, &normalized_messages, tools) + .await + } + + fn get_model_config(&self) -> ModelConfig { + self.inner.get_model_config() + } + + fn retry_config(&self) -> RetryConfig { + self.inner.retry_config() + } + + async fn fetch_supported_models(&self) -> Result>, ProviderError> { + self.inner.fetch_supported_models().await + } + + async fn fetch_recommended_models(&self) -> Result>, ProviderError> { + self.inner.fetch_recommended_models().await + } + + async fn map_to_canonical_model( + &self, + provider_model: &str, + ) -> Result, ProviderError> { + self.inner.map_to_canonical_model(provider_model).await + } + + fn supports_embeddings(&self) -> bool { + self.inner.supports_embeddings() + } + + async fn supports_cache_control(&self) -> bool { + self.inner.supports_cache_control().await + } + + async fn create_embeddings(&self, texts: Vec) -> Result>, ProviderError> { + self.inner.create_embeddings(texts).await + } + + fn as_lead_worker(&self) -> Option<&dyn LeadWorkerProviderTrait> { + self.inner.as_lead_worker() + } + + async fn stream( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + let normalized_messages = normalize_provider_messages(messages); + self.inner.stream(system, &normalized_messages, tools).await + } + + fn supports_streaming(&self) -> bool { + self.inner.supports_streaming() + } + + fn get_active_model_name(&self) -> String { + self.inner.get_active_model_name() + } + + async fn configure_oauth(&self) -> Result<(), ProviderError> { + self.inner.configure_oauth().await + } +} + +#[cfg(test)] +mod tests { + use super::normalize_provider_messages; + use aster::conversation::message::{Message, MessageContent}; + use rmcp::model::{CallToolRequestParam, CallToolResult, ErrorCode, ErrorData}; + use rmcp::object; + + fn valid_tool_response() -> CallToolResult { + CallToolResult { + content: vec![], + structured_content: None, + is_error: Some(false), + meta: None, + } + } + + fn invalid_tool_call_error(message: &str) -> ErrorData { + ErrorData { + code: ErrorCode::INTERNAL_ERROR, + message: std::borrow::Cow::Owned(message.to_string()), + data: None, + } + } + + #[test] + fn normalize_provider_messages_should_preserve_valid_tool_chain() { + let messages = vec![ + Message::user().with_text("帮我读一下项目结构"), + Message::assistant() + .with_text("我先检查目录。") + .with_tool_request( + "tool-1", + Ok(CallToolRequestParam { + name: "read_dir".into(), + arguments: Some(object!({"path": "."})), + }), + ), + Message::user().with_tool_response("tool-1", Ok(valid_tool_response())), + Message::assistant().with_text("目录读取完成。"), + ]; + + let normalized = normalize_provider_messages(&messages); + + assert_eq!(normalized, messages); + } + + #[test] + fn normalize_provider_messages_should_remove_orphan_tool_response() { + let messages = vec![ + Message::user().with_text("继续"), + Message::user().with_tool_response("orphan-tool", Ok(valid_tool_response())), + Message::assistant().with_text("我继续整理。"), + ]; + + let normalized = normalize_provider_messages(&messages); + + assert_eq!(normalized.len(), 2); + assert!(normalized.iter().all(|message| { + message + .content + .iter() + .all(|content| !matches!(content, MessageContent::ToolResponse(_))) + })); + } + + #[test] + fn normalize_provider_messages_should_drop_invalid_tool_request_and_following_response() { + let messages = vec![ + Message::assistant() + .with_text("我先尝试调用工具。") + .with_tool_request( + "broken-tool", + Err(invalid_tool_call_error("工具参数解析失败")), + ), + Message::user().with_tool_response("broken-tool", Ok(valid_tool_response())), + Message::assistant().with_text("工具失败后我继续主线程编排。"), + ]; + + let normalized = normalize_provider_messages(&messages); + + assert_eq!(normalized.len(), 2); + assert_eq!(normalized[0].as_concat_text(), "我先尝试调用工具。"); + assert_eq!( + normalized[1].as_concat_text(), + "工具失败后我继续主线程编排。" + ); + assert!(normalized.iter().all(|message| { + message.content.iter().all(|content| { + !matches!( + content, + MessageContent::ToolRequest(_) | MessageContent::ToolResponse(_) + ) + }) + })); + } + + #[test] + fn normalize_provider_messages_should_drop_invalid_frontend_tool_request_and_response() { + let messages = vec![ + Message::assistant() + .with_text("我先请求前端动作。") + .with_frontend_tool_request( + "frontend-tool", + Err(invalid_tool_call_error("前端工具参数无效")), + ), + Message::user().with_tool_response("frontend-tool", Ok(valid_tool_response())), + Message::assistant().with_text("前端工具失败后继续推进。"), + ]; + + let normalized = normalize_provider_messages(&messages); + + assert_eq!(normalized.len(), 2); + assert_eq!(normalized[0].as_concat_text(), "我先请求前端动作。"); + assert_eq!(normalized[1].as_concat_text(), "前端工具失败后继续推进。"); + assert!(normalized.iter().all(|message| { + message.content.iter().all(|content| { + !matches!( + content, + MessageContent::FrontendToolRequest(_) | MessageContent::ToolResponse(_) + ) + }) + })); + } +} diff --git a/src-tauri/crates/agent/src/runtime_projection_snapshot.rs b/src-tauri/crates/agent/src/runtime_projection_snapshot.rs new file mode 100644 index 000000000..7c8a971cd --- /dev/null +++ b/src-tauri/crates/agent/src/runtime_projection_snapshot.rs @@ -0,0 +1,145 @@ +use aster::session::SessionRuntimeSnapshot; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeProjectionSnapshot { + pub session_id: String, + pub has_runtime_snapshot: bool, + pub thread_count: usize, + pub primary_thread_id: Option, + pub turn_count: usize, + pub item_count: usize, + pub latest_turn_id: Option, +} + +impl RuntimeProjectionSnapshot { + pub fn from_snapshot( + session_id: impl Into, + snapshot: Option<&SessionRuntimeSnapshot>, + ) -> Self { + let session_id = session_id.into(); + let Some(snapshot) = snapshot else { + return Self { + session_id, + has_runtime_snapshot: false, + thread_count: 0, + primary_thread_id: None, + turn_count: 0, + item_count: 0, + latest_turn_id: None, + }; + }; + + let thread_count = snapshot.threads.len(); + let primary_thread_id = snapshot + .threads + .first() + .map(|thread| thread.thread.id.clone()); + let turn_count = snapshot + .threads + .iter() + .map(|thread| thread.turns.len()) + .sum(); + let item_count = snapshot + .threads + .iter() + .map(|thread| thread.items.len()) + .sum(); + let latest_turn_id = 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| turn.id.clone()); + + Self { + session_id, + has_runtime_snapshot: true, + thread_count, + primary_thread_id, + turn_count, + item_count, + latest_turn_id, + } + } + + pub fn primary_thread_id(&self) -> Option<&str> { + self.primary_thread_id.as_deref() + } +} + +#[cfg(test)] +mod tests { + use super::RuntimeProjectionSnapshot; + use aster::session::{ + SessionRuntimeSnapshot, ThreadRuntime, ThreadRuntimeSnapshot, TurnRuntime, TurnStatus, + }; + use chrono::{Duration, Utc}; + + #[test] + fn test_runtime_projection_snapshot_reads_primary_thread_and_latest_turn() { + let now = Utc::now(); + let snapshot = SessionRuntimeSnapshot { + session_id: "session-1".to_string(), + threads: vec![ThreadRuntimeSnapshot { + thread: ThreadRuntime::new( + "thread-1", + "session-1", + std::path::PathBuf::from("/tmp/workspace"), + ), + turns: vec![ + TurnRuntime { + id: "turn-old".to_string(), + session_id: "session-1".to_string(), + thread_id: "thread-1".to_string(), + status: TurnStatus::Running, + input_text: Some("old".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: "session-1".to_string(), + thread_id: "thread-1".to_string(), + status: TurnStatus::Completed, + input_text: Some("new".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(), + }], + }; + + let projection = RuntimeProjectionSnapshot::from_snapshot("session-1", Some(&snapshot)); + + assert!(projection.has_runtime_snapshot); + assert_eq!(projection.primary_thread_id(), Some("thread-1")); + assert_eq!(projection.thread_count, 1); + assert_eq!(projection.turn_count, 2); + assert_eq!(projection.item_count, 0); + assert_eq!(projection.latest_turn_id.as_deref(), Some("turn-new")); + } + + #[test] + fn test_runtime_projection_snapshot_handles_missing_snapshot() { + let projection = RuntimeProjectionSnapshot::from_snapshot("session-2", None); + + assert!(!projection.has_runtime_snapshot); + assert_eq!(projection.primary_thread_id(), None); + assert_eq!(projection.turn_count, 0); + } +} diff --git a/src-tauri/crates/agent/src/session_state_snapshot.rs b/src-tauri/crates/agent/src/session_state_snapshot.rs new file mode 100644 index 000000000..a3970719f --- /dev/null +++ b/src-tauri/crates/agent/src/session_state_snapshot.rs @@ -0,0 +1,116 @@ +use serde::{Deserialize, Serialize}; + +use crate::session_store::PersistedSessionMetadata; + +fn normalize_optional_text(value: Option) -> Option { + let text = value?; + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionStateSnapshot { + pub session_id: String, + pub exists: bool, + pub system_prompt: Option, + pub working_dir: Option, + pub execution_strategy: Option, +} + +impl SessionStateSnapshot { + pub fn from_persisted_metadata( + session_id: impl Into, + metadata: Option, + ) -> Self { + match metadata { + Some(metadata) => Self { + session_id: session_id.into(), + exists: true, + system_prompt: normalize_optional_text(metadata.system_prompt), + working_dir: normalize_optional_text(metadata.working_dir), + execution_strategy: normalize_optional_text(metadata.execution_strategy), + }, + None => Self { + session_id: session_id.into(), + exists: false, + system_prompt: None, + working_dir: None, + execution_strategy: None, + }, + } + } + + pub fn has_persisted_session(&self) -> bool { + self.exists + } + + pub fn system_prompt(&self) -> Option<&str> { + self.system_prompt.as_deref() + } + + pub fn working_dir(&self) -> Option<&str> { + self.working_dir.as_deref() + } + + pub fn execution_strategy(&self) -> Option<&str> { + self.execution_strategy.as_deref() + } + + pub fn needs_working_dir_update(&self, working_dir: &str) -> bool { + let Some(current) = self.working_dir() else { + return false; + }; + let target = working_dir.trim(); + !target.is_empty() && current != target + } + + pub fn with_working_dir(mut self, working_dir: Option) -> Self { + self.working_dir = normalize_optional_text(working_dir); + self + } +} + +#[cfg(test)] +mod tests { + use super::SessionStateSnapshot; + use crate::session_store::PersistedSessionMetadata; + + #[test] + fn test_session_state_snapshot_normalizes_persisted_metadata() { + let snapshot = SessionStateSnapshot::from_persisted_metadata( + "session-1", + Some(PersistedSessionMetadata { + system_prompt: Some(" 你是助手 ".to_string()), + working_dir: Some(" /tmp/workspace ".to_string()), + execution_strategy: Some(" react ".to_string()), + }), + ); + + assert!(snapshot.has_persisted_session()); + assert_eq!(snapshot.system_prompt(), Some("你是助手")); + assert_eq!(snapshot.working_dir(), Some("/tmp/workspace")); + assert_eq!(snapshot.execution_strategy(), Some("react")); + } + + #[test] + fn test_session_state_snapshot_detects_working_dir_update() { + let snapshot = SessionStateSnapshot::from_persisted_metadata( + "session-2", + Some(PersistedSessionMetadata { + system_prompt: None, + working_dir: Some("/tmp/origin".to_string()), + execution_strategy: None, + }), + ); + + assert!(snapshot.needs_working_dir_update("/tmp/next")); + assert!(!snapshot.needs_working_dir_update("/tmp/origin")); + + let updated = snapshot.with_working_dir(Some("/tmp/next".to_string())); + assert_eq!(updated.working_dir(), Some("/tmp/next")); + } +} diff --git a/src-tauri/crates/agent/src/turn_input_envelope.rs b/src-tauri/crates/agent/src/turn_input_envelope.rs new file mode 100644 index 000000000..a51124699 --- /dev/null +++ b/src-tauri/crates/agent/src/turn_input_envelope.rs @@ -0,0 +1,550 @@ +use aster::session::TurnContextOverride; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::{ + provider_continuation_state::{ProviderContinuationCapability, ProviderContinuationState}, + request_tool_policy::RequestToolPolicy, +}; + +fn normalize_optional_string(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn prompt_len(prompt: &Option) -> Option { + prompt.as_ref().map(|value| value.chars().count()) +} + +fn build_provider_continuation_metadata( + provider_continuation: &ProviderContinuationState, +) -> Option { + match provider_continuation { + ProviderContinuationState::HistoryReplayOnly => None, + ProviderContinuationState::ProviderSessionToken { session_token } => { + Some(serde_json::json!({ + "enabled": true, + "kind": "provider_session_token", + "session_token": session_token, + })) + } + ProviderContinuationState::PreviousResponseId { + previous_response_id, + } => Some(serde_json::json!({ + "enabled": true, + "kind": "previous_response_id", + "previous_response_id": previous_response_id, + })), + ProviderContinuationState::StickyRoutingHint { routing_hint } => Some(serde_json::json!({ + "enabled": true, + "kind": "sticky_routing_hint", + "routing_hint": routing_hint, + })), + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TurnSystemPromptSource { + None, + Frontend, + Session, + Project, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TurnPromptAugmentationStageKind { + RuntimeAgents, + Memory, + WebSearch, + RequestToolPolicy, + Elicitation, + TeamPreference, + AutoContinue, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TurnPromptAugmentationStage { + pub stage: TurnPromptAugmentationStageKind, + pub input_present: bool, + pub input_len: Option, + pub output_present: bool, + pub output_len: Option, + pub changed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TurnRequestToolPolicySnapshot { + pub search_mode: String, + pub effective_web_search: bool, + pub required_tools: Vec, + pub allowed_tools: Vec, + pub disallowed_tools: Vec, +} + +impl From<&RequestToolPolicy> for TurnRequestToolPolicySnapshot { + fn from(policy: &RequestToolPolicy) -> Self { + Self { + search_mode: policy.search_mode.as_str().to_string(), + effective_web_search: policy.effective_web_search, + required_tools: policy.required_tools.clone(), + allowed_tools: policy.allowed_tools.clone(), + disallowed_tools: policy.disallowed_tools.clone(), + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TurnMessageHistorySource { + SessionStoreReplay, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TurnProviderRoutingSnapshot { + pub provider_name: String, + pub provider_selector: Option, + pub model_name: String, + pub credential_uuid: Option, + pub configured_from_request: bool, + pub used_inline_api_key: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TurnDiagnosticsSnapshot { + pub session_id: String, + pub workspace_id: String, + pub project_id: Option, + pub thread_id: Option, + pub turn_id: Option, + pub has_persisted_session: bool, + pub system_prompt_source: TurnSystemPromptSource, + pub base_system_prompt_len: Option, + pub final_system_prompt_len: Option, + pub prompt_augmentation_stages: Vec, + pub requested_execution_strategy: Option, + pub effective_execution_strategy: Option, + pub request_tool_policy: Option, + pub provider_routing: Option, + pub history_source: TurnMessageHistorySource, + pub provider_continuation_capability: ProviderContinuationCapability, + pub provider_continuation: ProviderContinuationState, + pub working_dir_set: bool, + pub effective_user_message_len: usize, + pub include_context_trace: bool, + pub has_turn_context_metadata: bool, + pub turn_context_metadata_keys: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TurnInputEnvelope { + session_id: String, + workspace_id: String, + project_id: Option, + thread_id: Option, + turn_id: Option, + has_persisted_session: bool, + system_prompt_source: TurnSystemPromptSource, + base_system_prompt: Option, + final_system_prompt: Option, + prompt_augmentation_stages: Vec, + requested_execution_strategy: Option, + effective_execution_strategy: Option, + request_tool_policy: Option, + provider_routing: Option, + history_source: TurnMessageHistorySource, + provider_continuation_capability: ProviderContinuationCapability, + provider_continuation: ProviderContinuationState, + working_dir: Option, + effective_user_message: String, + include_context_trace: bool, + turn_context_metadata: Option>, +} + +impl TurnInputEnvelope { + fn merged_turn_context_metadata(&self) -> Option> { + let mut metadata = self.turn_context_metadata.clone().unwrap_or_default(); + if let Some(provider_continuation) = + build_provider_continuation_metadata(&self.provider_continuation) + { + metadata.insert("provider_continuation".to_string(), provider_continuation); + } + if metadata.is_empty() { + None + } else { + Some(metadata) + } + } + + pub fn system_prompt(&self) -> Option<&str> { + self.final_system_prompt.as_deref() + } + + pub fn include_context_trace(&self) -> bool { + self.include_context_trace + } + + pub fn turn_context_override(&self) -> Option { + self.merged_turn_context_metadata() + .map(|metadata| TurnContextOverride { + metadata: metadata.into_iter().collect(), + ..TurnContextOverride::default() + }) + } + + pub fn diagnostics_snapshot(&self) -> TurnDiagnosticsSnapshot { + let mut turn_context_metadata_keys = self + .merged_turn_context_metadata() + .as_ref() + .map(|metadata| metadata.keys().cloned().collect::>()) + .unwrap_or_default(); + turn_context_metadata_keys.sort(); + + TurnDiagnosticsSnapshot { + session_id: self.session_id.clone(), + workspace_id: self.workspace_id.clone(), + project_id: self.project_id.clone(), + thread_id: self.thread_id.clone(), + turn_id: self.turn_id.clone(), + has_persisted_session: self.has_persisted_session, + system_prompt_source: self.system_prompt_source, + base_system_prompt_len: prompt_len(&self.base_system_prompt), + final_system_prompt_len: prompt_len(&self.final_system_prompt), + prompt_augmentation_stages: self.prompt_augmentation_stages.clone(), + requested_execution_strategy: self.requested_execution_strategy.clone(), + effective_execution_strategy: self.effective_execution_strategy.clone(), + request_tool_policy: self.request_tool_policy.clone(), + provider_routing: self.provider_routing.clone(), + history_source: self.history_source, + provider_continuation_capability: self.provider_continuation_capability, + provider_continuation: self.provider_continuation.clone(), + working_dir_set: self.working_dir.is_some(), + effective_user_message_len: self.effective_user_message.chars().count(), + include_context_trace: self.include_context_trace, + has_turn_context_metadata: self.merged_turn_context_metadata().is_some(), + turn_context_metadata_keys, + } + } +} + +pub struct TurnInputEnvelopeBuilder { + envelope: TurnInputEnvelope, +} + +impl TurnInputEnvelopeBuilder { + pub fn new(session_id: impl Into, workspace_id: impl Into) -> Self { + Self { + envelope: TurnInputEnvelope { + session_id: session_id.into(), + workspace_id: workspace_id.into(), + project_id: None, + thread_id: None, + turn_id: None, + has_persisted_session: false, + system_prompt_source: TurnSystemPromptSource::None, + base_system_prompt: None, + final_system_prompt: None, + prompt_augmentation_stages: Vec::new(), + requested_execution_strategy: None, + effective_execution_strategy: None, + request_tool_policy: None, + provider_routing: None, + history_source: TurnMessageHistorySource::SessionStoreReplay, + provider_continuation_capability: ProviderContinuationCapability::default(), + provider_continuation: ProviderContinuationState::default(), + working_dir: None, + effective_user_message: String::new(), + include_context_trace: false, + turn_context_metadata: None, + }, + } + } + + pub fn set_project_id(&mut self, project_id: Option) -> &mut Self { + self.envelope.project_id = normalize_optional_string(project_id); + self + } + + pub fn set_thread_id(&mut self, thread_id: impl Into) -> &mut Self { + self.envelope.thread_id = normalize_optional_string(Some(thread_id.into())); + self + } + + pub fn set_turn_id(&mut self, turn_id: impl Into) -> &mut Self { + self.envelope.turn_id = normalize_optional_string(Some(turn_id.into())); + self + } + + pub fn set_has_persisted_session(&mut self, has_persisted_session: bool) -> &mut Self { + self.envelope.has_persisted_session = has_persisted_session; + self + } + + pub fn set_base_system_prompt( + &mut self, + source: TurnSystemPromptSource, + prompt: Option, + ) -> &mut Self { + self.envelope.system_prompt_source = source; + self.envelope.base_system_prompt = prompt.clone(); + self.envelope.final_system_prompt = prompt; + self + } + + pub fn apply_prompt_stage( + &mut self, + stage: TurnPromptAugmentationStageKind, + output_prompt: Option, + ) -> &mut Self { + let input_prompt = self.envelope.final_system_prompt.clone(); + let stage_record = TurnPromptAugmentationStage { + stage, + input_present: input_prompt.is_some(), + input_len: prompt_len(&input_prompt), + output_present: output_prompt.is_some(), + output_len: prompt_len(&output_prompt), + changed: input_prompt != output_prompt, + }; + + self.envelope.prompt_augmentation_stages.push(stage_record); + self.envelope.final_system_prompt = output_prompt; + self + } + + pub fn set_requested_execution_strategy(&mut self, strategy: Option) -> &mut Self { + self.envelope.requested_execution_strategy = normalize_optional_string(strategy); + self + } + + pub fn set_effective_execution_strategy(&mut self, strategy: Option) -> &mut Self { + self.envelope.effective_execution_strategy = normalize_optional_string(strategy); + self + } + + pub fn set_request_tool_policy( + &mut self, + request_tool_policy: Option, + ) -> &mut Self { + self.envelope.request_tool_policy = request_tool_policy; + self + } + + pub fn set_include_context_trace(&mut self, include_context_trace: bool) -> &mut Self { + self.envelope.include_context_trace = include_context_trace; + self + } + + pub fn set_turn_context_metadata_from_value(&mut self, metadata: Option<&Value>) -> &mut Self { + self.envelope.turn_context_metadata = match metadata { + Some(Value::Object(map)) => Some(map.clone()), + _ => None, + }; + self + } + + pub fn set_provider_routing( + &mut self, + provider_routing: Option, + ) -> &mut Self { + self.envelope.provider_routing = provider_routing; + self + } + + pub fn set_provider_continuation_capability( + &mut self, + provider_continuation_capability: ProviderContinuationCapability, + ) -> &mut Self { + self.envelope.provider_continuation_capability = provider_continuation_capability; + self + } + + pub fn set_provider_continuation( + &mut self, + provider_continuation: ProviderContinuationState, + ) -> &mut Self { + self.envelope.provider_continuation = provider_continuation; + self + } + + pub fn set_working_dir(&mut self, working_dir: Option) -> &mut Self { + self.envelope.working_dir = normalize_optional_string(working_dir); + self + } + + pub fn set_effective_user_message( + &mut self, + effective_user_message: impl Into, + ) -> &mut Self { + self.envelope.effective_user_message = effective_user_message.into(); + self + } + + pub fn build(self) -> TurnInputEnvelope { + self.envelope + } +} + +#[cfg(test)] +mod tests { + use super::{ + TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind, TurnProviderRoutingSnapshot, + TurnRequestToolPolicySnapshot, TurnSystemPromptSource, + }; + use crate::provider_continuation_state::{ + ProviderContinuationCapability, ProviderContinuationState, + }; + use serde_json::json; + + #[test] + fn test_turn_input_envelope_records_prompt_diagnostics() { + let mut builder = TurnInputEnvelopeBuilder::new("session-1", "workspace-1"); + builder + .set_project_id(Some("project-1".to_string())) + .set_has_persisted_session(true) + .set_base_system_prompt(TurnSystemPromptSource::Session, Some("base".to_string())) + .apply_prompt_stage( + TurnPromptAugmentationStageKind::RuntimeAgents, + Some("base\nruntime".to_string()), + ) + .apply_prompt_stage( + TurnPromptAugmentationStageKind::Memory, + Some("base\nruntime".to_string()), + ) + .set_requested_execution_strategy(Some("auto".to_string())) + .set_effective_execution_strategy(Some("react".to_string())) + .set_request_tool_policy(Some(TurnRequestToolPolicySnapshot { + search_mode: "allowed".to_string(), + effective_web_search: true, + required_tools: vec!["WebSearch".to_string()], + allowed_tools: vec!["WebSearch".to_string(), "WebFetch".to_string()], + disallowed_tools: vec![], + })) + .set_provider_routing(Some(TurnProviderRoutingSnapshot { + provider_name: "openai".to_string(), + provider_selector: Some("openai".to_string()), + model_name: "gpt-5".to_string(), + credential_uuid: Some("cred-1".to_string()), + configured_from_request: true, + used_inline_api_key: false, + })) + .set_provider_continuation_capability( + ProviderContinuationCapability::PreviousResponseId, + ) + .set_provider_continuation(ProviderContinuationState::previous_response_id("resp-1")) + .set_working_dir(Some("/tmp/workspace".to_string())) + .set_effective_user_message("请继续分析") + .set_include_context_trace(true) + .set_turn_context_metadata_from_value(Some(&json!({ + "theme": "planning", + "task_mode_enabled": true + }))) + .set_thread_id("thread-1") + .set_turn_id("turn-1"); + + let envelope = builder.build(); + let diagnostics = envelope.diagnostics_snapshot(); + + assert_eq!( + diagnostics.system_prompt_source, + TurnSystemPromptSource::Session + ); + assert_eq!(diagnostics.base_system_prompt_len, Some(4)); + assert_eq!(diagnostics.final_system_prompt_len, Some(12)); + assert!(diagnostics.working_dir_set); + assert_eq!(diagnostics.effective_user_message_len, 5); + assert_eq!( + diagnostics + .provider_routing + .as_ref() + .map(|routing| routing.model_name.as_str()), + Some("gpt-5") + ); + assert_eq!( + diagnostics.provider_continuation, + ProviderContinuationState::previous_response_id("resp-1") + ); + assert_eq!( + diagnostics.provider_continuation_capability, + ProviderContinuationCapability::PreviousResponseId + ); + assert_eq!(diagnostics.prompt_augmentation_stages.len(), 2); + assert!(diagnostics.prompt_augmentation_stages[0].changed); + assert!(!diagnostics.prompt_augmentation_stages[1].changed); + assert_eq!( + diagnostics.turn_context_metadata_keys, + vec![ + "provider_continuation".to_string(), + "task_mode_enabled".to_string(), + "theme".to_string() + ] + ); + let turn_context = envelope.turn_context_override().expect("turn context"); + assert_eq!( + turn_context.metadata.get("provider_continuation"), + Some(&json!({ + "enabled": true, + "kind": "previous_response_id", + "previous_response_id": "resp-1" + })) + ); + } + + #[test] + fn test_turn_input_envelope_allows_stage_generated_prompt() { + let mut builder = TurnInputEnvelopeBuilder::new("session-2", "workspace-2"); + builder + .set_base_system_prompt(TurnSystemPromptSource::None, None) + .set_effective_user_message("runtime-only") + .apply_prompt_stage( + TurnPromptAugmentationStageKind::RuntimeAgents, + Some("runtime-only".to_string()), + ); + + let envelope = builder.build(); + let diagnostics = envelope.diagnostics_snapshot(); + + assert_eq!( + diagnostics.system_prompt_source, + TurnSystemPromptSource::None + ); + assert_eq!(diagnostics.base_system_prompt_len, None); + assert_eq!(diagnostics.final_system_prompt_len, Some(12)); + assert_eq!(envelope.system_prompt(), Some("runtime-only")); + } + + #[test] + fn test_turn_input_envelope_exposes_provider_continuation_without_user_metadata() { + let mut builder = TurnInputEnvelopeBuilder::new("session-3", "workspace-3"); + builder + .set_provider_continuation_capability( + ProviderContinuationCapability::PreviousResponseId, + ) + .set_provider_continuation(ProviderContinuationState::previous_response_id("resp-2")) + .set_effective_user_message("继续"); + + let envelope = builder.build(); + let diagnostics = envelope.diagnostics_snapshot(); + let turn_context = envelope.turn_context_override().expect("turn context"); + + assert!(diagnostics.has_turn_context_metadata); + assert_eq!( + diagnostics.turn_context_metadata_keys, + vec!["provider_continuation".to_string()] + ); + assert_eq!( + turn_context.metadata.get("provider_continuation"), + Some(&json!({ + "enabled": true, + "kind": "previous_response_id", + "previous_response_id": "resp-2" + })) + ); + } +} diff --git a/src-tauri/crates/agent/src/turn_state.rs b/src-tauri/crates/agent/src/turn_state.rs new file mode 100644 index 000000000..91e8fd2c0 --- /dev/null +++ b/src-tauri/crates/agent/src/turn_state.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; + +use crate::turn_input_envelope::TurnRequestToolPolicySnapshot; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TurnState { + pub session_id: String, + pub workspace_id: String, + pub thread_id: String, + pub turn_id: String, + pub requested_execution_strategy: String, + pub effective_execution_strategy: String, + pub request_tool_policy: TurnRequestToolPolicySnapshot, + pub include_context_trace: bool, + pub runtime_chat_mode: String, +} + +impl TurnState { + #[allow(clippy::too_many_arguments)] + pub fn new( + session_id: impl Into, + workspace_id: impl Into, + thread_id: impl Into, + turn_id: impl Into, + requested_execution_strategy: impl Into, + effective_execution_strategy: impl Into, + request_tool_policy: TurnRequestToolPolicySnapshot, + include_context_trace: bool, + runtime_chat_mode: impl Into, + ) -> Self { + Self { + session_id: session_id.into(), + workspace_id: workspace_id.into(), + thread_id: thread_id.into(), + turn_id: turn_id.into(), + requested_execution_strategy: requested_execution_strategy.into(), + effective_execution_strategy: effective_execution_strategy.into(), + request_tool_policy, + include_context_trace, + runtime_chat_mode: runtime_chat_mode.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::TurnState; + use crate::turn_input_envelope::TurnRequestToolPolicySnapshot; + + #[test] + fn test_turn_state_is_serializable_and_complete() { + let turn_state = TurnState::new( + "session-1", + "workspace-1", + "thread-1", + "turn-1", + "auto", + "react", + TurnRequestToolPolicySnapshot { + search_mode: "allowed".to_string(), + effective_web_search: true, + required_tools: vec!["WebSearch".to_string()], + allowed_tools: vec!["WebSearch".to_string(), "WebFetch".to_string()], + disallowed_tools: vec![], + }, + true, + "agent", + ); + + let value = serde_json::to_value(&turn_state).expect("serialize turn state"); + assert_eq!(value["thread_id"], serde_json::json!("thread-1")); + assert_eq!(value["turn_id"], serde_json::json!("turn-1")); + assert_eq!( + value["effective_execution_strategy"], + serde_json::json!("react") + ); + assert_eq!(value["runtime_chat_mode"], serde_json::json!("agent")); + } +} diff --git a/src-tauri/crates/core/src/app_paths.rs b/src-tauri/crates/core/src/app_paths.rs index cdf8eeb8d..e056c2bbf 100644 --- a/src-tauri/crates/core/src/app_paths.rs +++ b/src-tauri/crates/core/src/app_paths.rs @@ -98,7 +98,24 @@ pub fn resolve_lime_skill_roots() -> Result, String> { } pub fn resolve_user_memory_path() -> Result { - with_app_roots(resolve_user_memory_path_from_source_roots) + let preferred_root = compat_home_dir()?; + let mut legacy_roots = Vec::new(); + + for root in [ + preferred_data_dir()?, + legacy_app_data_dir()?, + legacy_home_dir()?, + ] { + if !legacy_roots.iter().any(|existing| existing == &root) { + legacy_roots.push(root); + } + } + + resolve_user_memory_path_from_source_roots(&preferred_root, &legacy_roots) +} + +pub fn best_effort_user_memory_path() -> PathBuf { + resolve_user_memory_path().unwrap_or_else(|_| fallback_user_memory_path()) } pub fn resolve_default_project_dir() -> Result { @@ -167,6 +184,13 @@ fn fallback_runtime_subdir(subdir: &str) -> PathBuf { fallback_app_data_dir().join(subdir) } +fn fallback_user_memory_path() -> PathBuf { + dirs::home_dir() + .map(|home| home.join(COMPAT_HOME_DIR_NAME)) + .unwrap_or_else(|| fallback_app_data_dir().join(COMPAT_HOME_DIR_NAME)) + .join("AGENTS.md") +} + fn resolve_project_skills_dir_from_cwd(cwd: &Path) -> PathBuf { cwd.join(".agents").join("skills") } @@ -782,8 +806,8 @@ mod tests { #[test] fn resolve_user_memory_path_copies_legacy_agents_file() { let temp = tempdir().unwrap(); - let preferred_root = temp.path().join("appdata").join("lime"); - let legacy_root = temp.path().join("home").join(".lime"); + let preferred_root = temp.path().join("home").join(".lime"); + let legacy_root = temp.path().join("appdata").join("lime"); fs::create_dir_all(&legacy_root).unwrap(); fs::write(legacy_root.join("AGENTS.md"), "legacy agents").unwrap(); @@ -797,8 +821,8 @@ mod tests { #[test] fn resolve_user_memory_path_copies_legacy_agent_file() { let temp = tempdir().unwrap(); - let preferred_root = temp.path().join("appdata").join("lime"); - let legacy_root = temp.path().join("home").join(".lime"); + let preferred_root = temp.path().join("home").join(".lime"); + let legacy_root = temp.path().join("appdata").join("lime"); fs::create_dir_all(&legacy_root).unwrap(); fs::write(legacy_root.join("AGENT.md"), "legacy agent").unwrap(); diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index ef9702ff7..fae833e3c 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -2463,7 +2463,10 @@ pub struct MemorySourcesConfig { /// 组织级策略文件(可选) #[serde(default, skip_serializing_if = "Option::is_none")] pub managed_policy_path: Option, - /// 项目级记忆文件相对路径列表(会按目录层级向上查找) + /// 项目级记忆文件相对路径列表 + /// + /// 默认使用 `.lime/AGENTS.md`,仅解析当前 workspace 根目录; + /// 非 `.lime/` 路径仍兼容按目录层级向上查找。 #[serde(default)] pub project_memory_paths: Vec, /// 项目规则目录相对路径列表(会按目录层级向上查找) @@ -2473,6 +2476,8 @@ pub struct MemorySourcesConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub user_memory_path: Option, /// 项目本地私有记忆文件(可选) + /// + /// 默认使用 `.lime/AGENTS.local.md`,仅解析当前 workspace 根目录。 #[serde(default, skip_serializing_if = "Option::is_none")] pub project_local_memory_path: Option, } @@ -2481,10 +2486,10 @@ impl Default for MemorySourcesConfig { fn default() -> Self { Self { managed_policy_path: None, - project_memory_paths: vec!["AGENTS.md".to_string(), ".agents/AGENTS.md".to_string()], + project_memory_paths: vec![".lime/AGENTS.md".to_string()], project_rule_dirs: vec![".agents/rules".to_string()], user_memory_path: None, - project_local_memory_path: Some("AGENTS.local.md".to_string()), + project_local_memory_path: Some(".lime/AGENTS.local.md".to_string()), } } } diff --git a/src-tauri/crates/core/src/openclaw_install.rs b/src-tauri/crates/core/src/openclaw_install.rs index 9847f1234..47ef78090 100644 --- a/src-tauri/crates/core/src/openclaw_install.rs +++ b/src-tauri/crates/core/src/openclaw_install.rs @@ -69,6 +69,21 @@ pub fn shell_path_assignment_for(platform: ShellPlatform, binary_path: &str) -> } } +pub fn shell_command_invocation_prefix_for(platform: ShellPlatform, binary_path: &str) -> String { + match platform { + ShellPlatform::Windows if windows_shell_requires_call(binary_path) => "call ".to_string(), + _ => String::new(), + } +} + +fn windows_shell_requires_call(binary_path: &str) -> bool { + Path::new(binary_path) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "cmd" | "bat")) + .unwrap_or(false) +} + fn shell_environment_prefix( platform: ShellPlatform, binary_path: &str, @@ -89,8 +104,9 @@ pub fn build_openclaw_cleanup_command( npm_prefix: Option<&str>, ) -> String { format!( - "{}{} uninstall -g openclaw @qingchencloud/openclaw-zh", + "{}{}{} uninstall -g openclaw @qingchencloud/openclaw-zh", shell_environment_prefix(platform, npm_path, npm_prefix), + shell_command_invocation_prefix_for(platform, npm_path), shell_command_escape_for(platform, npm_path) ) } @@ -106,8 +122,9 @@ pub fn build_openclaw_install_command( .map(|value| format!(" --registry={value}")) .unwrap_or_default(); format!( - "{}{} install -g {}{}", + "{}{}{} install -g {}{}", shell_environment_prefix(platform, npm_path, npm_prefix), + shell_command_invocation_prefix_for(platform, npm_path), shell_command_escape_for(platform, npm_path), package, registry_suffix @@ -225,8 +242,9 @@ mod tests { build_openclaw_cleanup_command, build_openclaw_install_command, build_winget_install_command, command_bin_dir_for, resolve_windows_dependency_install_plan, select_best_semver_candidate, select_preferred_path_candidate, shell_command_escape_for, - shell_npm_prefix_assignment_for, shell_path_assignment_for, windows_manual_install_message, - OpenClawInstallDependencyKind, ShellPlatform, WindowsDependencyInstallPlan, + shell_command_invocation_prefix_for, shell_npm_prefix_assignment_for, + shell_path_assignment_for, windows_manual_install_message, OpenClawInstallDependencyKind, + ShellPlatform, WindowsDependencyInstallPlan, }; use std::path::PathBuf; @@ -273,6 +291,22 @@ mod tests { ); } + #[test] + fn windows_cmd_scripts_use_call_invocation_prefix() { + assert_eq!( + shell_command_invocation_prefix_for( + ShellPlatform::Windows, + r"C:\Program Files\nodejs\npm.cmd" + ), + "call " + ); + assert!(shell_command_invocation_prefix_for( + ShellPlatform::Windows, + r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe" + ) + .is_empty()); + } + #[test] fn windows_cleanup_command_uses_cmd_compatible_syntax_without_true_fallback() { let command = build_openclaw_cleanup_command( @@ -286,7 +320,7 @@ mod tests { concat!( "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", - "\"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh" + "call \"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh" ) ); assert!(!command.contains("|| true")); @@ -307,7 +341,7 @@ mod tests { concat!( "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", - "\"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ", + "call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ", "--registry=https://registry.npmmirror.com" ) ); @@ -327,7 +361,7 @@ mod tests { command, concat!( "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", - "\"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest" + "call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest" ) ); assert!(!command.contains("--registry=")); diff --git a/src-tauri/crates/core/src/workspace/types.rs b/src-tauri/crates/core/src/workspace/types.rs index ff18f78d8..67838e2f5 100644 --- a/src-tauri/crates/core/src/workspace/types.rs +++ b/src-tauri/crates/core/src/workspace/types.rs @@ -182,6 +182,62 @@ impl Default for WorkspaceImageGenerationSettings { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WorkspaceTeamSelectionSource { + Builtin, + Custom, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceTeamSelectionReference { + pub id: String, + pub source: WorkspaceTeamSelectionSource, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceAgentTeamRoleSettings { + pub id: String, + pub label: String, + pub summary: String, + #[serde(skip_serializing_if = "Option::is_none", alias = "profile_id")] + pub profile_id: Option, + #[serde(skip_serializing_if = "Option::is_none", alias = "role_key")] + pub role_key: Option, + #[serde(skip_serializing_if = "Option::is_none", alias = "skill_ids")] + pub skill_ids: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceAgentCustomTeamSettings { + pub id: String, + pub label: String, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub theme: Option, + #[serde(skip_serializing_if = "Option::is_none", alias = "preset_id")] + pub preset_id: Option, + pub roles: Vec, + #[serde(skip_serializing_if = "Option::is_none", alias = "created_at")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none", alias = "updated_at")] + pub updated_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceAgentTeamSettings { + #[serde(skip_serializing_if = "Option::is_none", alias = "selected_team")] + pub selected_team: Option, + #[serde(default)] + pub disabled: bool, + #[serde(skip_serializing_if = "Option::is_none", alias = "custom_teams")] + pub custom_teams: Option>, +} + /// Workspace 级别设置 #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -204,6 +260,9 @@ pub struct WorkspaceSettings { /// 语音生成偏好 #[serde(skip_serializing_if = "Option::is_none", alias = "voice_generation")] pub voice_generation: Option, + /// Team 运行时偏好 + #[serde(skip_serializing_if = "Option::is_none", alias = "agent_team")] + pub agent_team: Option, } /// 项目统计信息 @@ -452,6 +511,30 @@ mod tests { "preferred_provider_id": "openai-tts", "preferred_model_id": "gpt-4o-mini-tts", "allow_fallback": false + }, + "agent_team": { + "selected_team": { + "id": "code-triage-team", + "source": "builtin" + }, + "custom_teams": [ + { + "id": "custom-team-1", + "label": "项目联调 Team", + "description": "用于当前项目的前端联调。", + "roles": [ + { + "id": "planner", + "label": "分析", + "summary": "先确认边界再安排执行", + "profile_id": "code-explorer", + "role_key": "explorer", + "skill_ids": ["source-grounding"] + } + ] + } + ], + "disabled": false } }"#, ) @@ -489,6 +572,26 @@ mod tests { Some("gpt-4o-mini-tts") ); assert!(!voice_generation.allow_fallback); + let agent_team = settings.agent_team.expect("应解析 Team 配置"); + let selected_team = agent_team.selected_team.expect("应解析 Team 选择"); + assert_eq!(selected_team.id, "code-triage-team"); + assert!(matches!( + selected_team.source, + WorkspaceTeamSelectionSource::Builtin + )); + assert!(!agent_team.disabled); + let custom_teams = agent_team.custom_teams.expect("应解析自定义 Team 列表"); + assert_eq!(custom_teams.len(), 1); + assert_eq!(custom_teams[0].label, "项目联调 Team"); + assert_eq!(custom_teams[0].roles.len(), 1); + assert_eq!( + custom_teams[0].roles[0].profile_id.as_deref(), + Some("code-explorer") + ); + assert_eq!( + custom_teams[0].roles[0].role_key.as_deref(), + Some("explorer") + ); } #[test] @@ -509,6 +612,30 @@ mod tests { preferred_model_id: Some("gpt-4o-mini-tts".to_string()), allow_fallback: false, }), + agent_team: Some(WorkspaceAgentTeamSettings { + selected_team: Some(WorkspaceTeamSelectionReference { + id: "code-triage-team".to_string(), + source: WorkspaceTeamSelectionSource::Builtin, + }), + custom_teams: Some(vec![WorkspaceAgentCustomTeamSettings { + id: "custom-team-1".to_string(), + label: "项目联调 Team".to_string(), + description: "用于当前项目的前端联调。".to_string(), + theme: Some("general".to_string()), + preset_id: Some("code-triage-team".to_string()), + roles: vec![WorkspaceAgentTeamRoleSettings { + id: "planner".to_string(), + label: "分析".to_string(), + summary: "先确认边界再安排执行".to_string(), + profile_id: Some("code-explorer".to_string()), + role_key: Some("explorer".to_string()), + skill_ids: Some(vec!["source-grounding".to_string()]), + }], + created_at: Some(1), + updated_at: Some(2), + }]), + disabled: false, + }), ..WorkspaceSettings::default() }; @@ -548,5 +675,40 @@ mod tests { .and_then(|item| item.as_str()), Some("gpt-4o-mini-tts") ); + assert_eq!( + value + .get("agentTeam") + .and_then(|item| item.get("selectedTeam")) + .and_then(|item| item.get("id")) + .and_then(|item| item.as_str()), + Some("code-triage-team") + ); + assert_eq!( + value + .get("agentTeam") + .and_then(|item| item.get("disabled")) + .and_then(|item| item.as_bool()), + Some(false) + ); + assert_eq!( + value + .get("agentTeam") + .and_then(|item| item.get("customTeams")) + .and_then(|item| item.get(0)) + .and_then(|item| item.get("label")) + .and_then(|item| item.as_str()), + Some("项目联调 Team") + ); + assert_eq!( + value + .get("agentTeam") + .and_then(|item| item.get("customTeams")) + .and_then(|item| item.get(0)) + .and_then(|item| item.get("roles")) + .and_then(|item| item.get(0)) + .and_then(|item| item.get("profileId")) + .and_then(|item| item.as_str()), + Some("code-explorer") + ); } } diff --git a/src-tauri/crates/providers/src/providers/kiro.rs b/src-tauri/crates/providers/src/providers/kiro.rs index 32cda1535..4cbaf08d2 100644 --- a/src-tauri/crates/providers/src/providers/kiro.rs +++ b/src-tauri/crates/providers/src/providers/kiro.rs @@ -5,7 +5,7 @@ // 使用新的 translator 模块替代旧的 converter use crate::providers::traits::{CredentialProvider, ProviderResult}; use crate::translator::kiro::anthropic::request::convert_anthropic_to_codewhisperer; -use crate::translator::kiro::openai::request::convert_openai_to_codewhisperer; +use crate::translator::kiro::openai::request::convert_openai_to_codewhisperer_with_conversation_id; use async_trait::async_trait; use lime_core::models::anthropic::AnthropicMessagesRequest; use lime_core::models::openai::*; @@ -984,6 +984,14 @@ impl KiroProvider { pub async fn call_api( &self, request: &ChatCompletionRequest, + ) -> Result> { + self.call_api_with_conversation_id(request, None).await + } + + pub async fn call_api_with_conversation_id( + &self, + request: &ChatCompletionRequest, + conversation_id: Option<&str>, ) -> Result> { let token = self .credentials @@ -997,7 +1005,11 @@ impl KiroProvider { None }; - let cw_request = convert_openai_to_codewhisperer(request, profile_arn.clone()); + let cw_request = convert_openai_to_codewhisperer_with_conversation_id( + request, + profile_arn.clone(), + conversation_id, + ); let url = self.get_base_url(); // 安全修复:仅在 LIME_DEBUG=1 时写入请求调试文件,兼容旧的 PROXYCAST_DEBUG。 @@ -1086,6 +1098,88 @@ impl KiroProvider { Ok(resp) } + + pub async fn call_api_stream_with_conversation_id( + &self, + request: &ChatCompletionRequest, + conversation_id: Option<&str>, + ) -> Result { + let token = self + .credentials + .access_token + .as_ref() + .ok_or_else(|| ProviderError::AuthenticationError("No access token".to_string()))?; + + let profile_arn = if self.credentials.auth_method.as_deref() == Some("social") { + self.credentials.profile_arn.clone() + } else { + None + }; + + let cw_request = convert_openai_to_codewhisperer_with_conversation_id( + request, + profile_arn.clone(), + conversation_id, + ); + let url = self.get_base_url(); + + // 生成基于凭证的唯一 Machine ID + let machine_id = generate_machine_id_from_credentials( + profile_arn.as_deref(), + self.credentials.client_id.as_deref(), + ); + let kiro_version = get_kiro_version(); + let (os_name, node_version) = get_system_runtime_info(); + + tracing::info!( + "[KIRO_STREAM] 发起流式请求: url={} machine_id={}...", + url, + &machine_id[..16] + ); + + let resp = self + .client + .post(&url) + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .header("Accept", "application/vnd.amazon.eventstream") + .header("amz-sdk-invocation-id", uuid::Uuid::new_v4().to_string()) + .header("amz-sdk-request", "attempt=1; max=1") + .header("x-amzn-kiro-agent-mode", "vibe") + .header( + "x-amz-user-agent", + format!("aws-sdk-js/1.0.0 KiroIDE-{kiro_version}-{machine_id}"), + ) + .header( + "user-agent", + format!( + "aws-sdk-js/1.0.0 ua/2.1 os/{os_name} lang/js md/nodejs#{node_version} api/codewhispererruntime#1.0.0 m/E KiroIDE-{kiro_version}-{machine_id}" + ), + ) + // 注意:不要设置 Connection: close,否则会导致流式响应无法工作 + .json(&cw_request) + .send() + .await + .map_err(|e| { + tracing::error!("[KIRO_STREAM] 请求发送失败: {}", e); + ProviderError::from_reqwest_error(&e) + })?; + + tracing::info!("[KIRO_STREAM] 收到响应: status={}", resp.status()); + + // 检查响应状态 + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + tracing::error!("[KIRO_STREAM] 请求失败: {} - {}", status, body); + return Err(ProviderError::from_http_status(status.as_u16(), &body)); + } + + tracing::info!("[KIRO_STREAM] 流式响应开始: status={}", status); + + // 将 reqwest 响应转换为 StreamResponse + Ok(reqwest_stream_to_stream_response(resp)) + } } fn merge_credentials(target: &mut KiroCredentials, source: &KiroCredentials) { @@ -1183,77 +1277,8 @@ impl StreamingProvider for KiroProvider { &self, request: &ChatCompletionRequest, ) -> Result { - let token = self - .credentials - .access_token - .as_ref() - .ok_or_else(|| ProviderError::AuthenticationError("No access token".to_string()))?; - - let profile_arn = if self.credentials.auth_method.as_deref() == Some("social") { - self.credentials.profile_arn.clone() - } else { - None - }; - - let cw_request = convert_openai_to_codewhisperer(request, profile_arn.clone()); - let url = self.get_base_url(); - - // 生成基于凭证的唯一 Machine ID - let machine_id = generate_machine_id_from_credentials( - profile_arn.as_deref(), - self.credentials.client_id.as_deref(), - ); - let kiro_version = get_kiro_version(); - let (os_name, node_version) = get_system_runtime_info(); - - tracing::info!( - "[KIRO_STREAM] 发起流式请求: url={} machine_id={}...", - url, - &machine_id[..16] - ); - - let resp = self - .client - .post(&url) - .header("Authorization", format!("Bearer {token}")) - .header("Content-Type", "application/json") - .header("Accept", "application/vnd.amazon.eventstream") - .header("amz-sdk-invocation-id", uuid::Uuid::new_v4().to_string()) - .header("amz-sdk-request", "attempt=1; max=1") - .header("x-amzn-kiro-agent-mode", "vibe") - .header( - "x-amz-user-agent", - format!("aws-sdk-js/1.0.0 KiroIDE-{kiro_version}-{machine_id}"), - ) - .header( - "user-agent", - format!( - "aws-sdk-js/1.0.0 ua/2.1 os/{os_name} lang/js md/nodejs#{node_version} api/codewhispererruntime#1.0.0 m/E KiroIDE-{kiro_version}-{machine_id}" - ), - ) - // 注意:不要设置 Connection: close,否则会导致流式响应无法工作 - .json(&cw_request) - .send() + self.call_api_stream_with_conversation_id(request, None) .await - .map_err(|e| { - tracing::error!("[KIRO_STREAM] 请求发送失败: {}", e); - ProviderError::from_reqwest_error(&e) - })?; - - tracing::info!("[KIRO_STREAM] 收到响应: status={}", resp.status()); - - // 检查响应状态 - let status = resp.status(); - if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - tracing::error!("[KIRO_STREAM] 请求失败: {} - {}", status, body); - return Err(ProviderError::from_http_status(status.as_u16(), &body)); - } - - tracing::info!("[KIRO_STREAM] 流式响应开始: status={}", status); - - // 将 reqwest 响应转换为 StreamResponse - Ok(reqwest_stream_to_stream_response(resp)) } fn supports_streaming(&self) -> bool { diff --git a/src-tauri/crates/providers/src/translator/kiro/openai/request.rs b/src-tauri/crates/providers/src/translator/kiro/openai/request.rs index 095b6515d..a24b1d832 100644 --- a/src-tauri/crates/providers/src/translator/kiro/openai/request.rs +++ b/src-tauri/crates/providers/src/translator/kiro/openai/request.rs @@ -130,6 +130,14 @@ struct ProcessedMessage { pub fn convert_openai_to_codewhisperer( request: &ChatCompletionRequest, profile_arn: Option, +) -> CodeWhispererRequest { + convert_openai_to_codewhisperer_with_conversation_id(request, profile_arn, None) +} + +pub fn convert_openai_to_codewhisperer_with_conversation_id( + request: &ChatCompletionRequest, + profile_arn: Option, + conversation_id: Option<&str>, ) -> CodeWhispererRequest { let model_map = get_model_map(); let cw_model = model_map @@ -137,7 +145,11 @@ pub fn convert_openai_to_codewhisperer( .map(|s| s.to_string()) .unwrap_or_else(|| DEFAULT_MODEL.to_string()); - let conversation_id = Uuid::new_v4().to_string(); + let conversation_id = conversation_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()); // 提取 system prompt 和消息 let mut system_prompt = String::new(); diff --git a/src-tauri/crates/scheduler/src/executor.rs b/src-tauri/crates/scheduler/src/executor.rs index 9bbdde51d..3c0a38d38 100644 --- a/src-tauri/crates/scheduler/src/executor.rs +++ b/src-tauri/crates/scheduler/src/executor.rs @@ -11,7 +11,7 @@ use lime_agent::request_tool_policy::{ merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy, stream_reply_with_policy, }; -use lime_agent::{AsterAgentState, SessionConfigBuilder}; +use lime_agent::{merge_system_prompt_with_runtime_agents, AsterAgentState, SessionConfigBuilder}; use lime_core::database::DbConnection; use serde_json::Value; use std::sync::Arc; @@ -175,7 +175,7 @@ impl AgentExecutor { let request_tool_policy = resolve_request_tool_policy(Self::resolve_bool_param(task, "web_search"), false); let merged_system_prompt = merge_system_prompt_with_request_tool_policy( - Self::resolve_system_prompt(task), + merge_system_prompt_with_runtime_agents(Self::resolve_system_prompt(task), None), &request_tool_policy, ); // 对齐主对话入口:执行前刷新一次 Skills 注册,避免运行期安装/更新后不可见。 diff --git a/src-tauri/crates/services/src/model_service.rs b/src-tauri/crates/services/src/model_service.rs index 38660e876..7e997b178 100644 --- a/src-tauri/crates/services/src/model_service.rs +++ b/src-tauri/crates/services/src/model_service.rs @@ -440,6 +440,7 @@ impl ModelService { ) -> Result>, String> { let conn = db.lock().map_err(|e| e.to_string())?; let credentials = ProviderPoolDao::get_all(&conn).map_err(|e| e.to_string())?; + drop(conn); let mut models_by_provider: HashMap> = HashMap::new(); @@ -448,13 +449,12 @@ impl ModelService { continue; } - let models = self.get_credential_models(db, &cred.uuid)?; let provider_key = cred.provider_type.to_string(); models_by_provider .entry(provider_key) .or_default() - .extend(models); + .extend(cred.supported_models); } // 去重 @@ -482,6 +482,17 @@ impl ModelService { #[cfg(test)] mod tests { use super::*; + use lime_core::database::dao::provider_pool::ProviderPoolDao; + use lime_core::database::schema; + use lime_core::models::provider_pool_model::{CredentialData, PoolProviderType}; + use rusqlite::Connection; + use std::sync::{Arc, Mutex}; + + fn setup_test_db() -> DbConnection { + let conn = Connection::open_in_memory().expect("open in-memory db"); + schema::create_tables(&conn).expect("create schema"); + Arc::new(Mutex::new(conn)) + } #[test] fn test_get_default_models_for_provider() { @@ -495,4 +506,46 @@ mod tests { assert!(!gemini_models.is_empty()); assert!(gemini_models.contains(&"gemini-2.5-flash".to_string())); } + + #[test] + fn test_get_all_available_models_uses_loaded_supported_models_without_relocking_db() { + let db = setup_test_db(); + let mut openai = ProviderCredential::new( + PoolProviderType::OpenAI, + CredentialData::OpenAIKey { + api_key: "sk-test".to_string(), + base_url: None, + }, + ); + openai.supported_models = vec!["gpt-4o".to_string(), "gpt-4.1".to_string()]; + + let mut gemini = ProviderCredential::new( + PoolProviderType::GeminiApiKey, + CredentialData::GeminiApiKey { + api_key: "gm-test".to_string(), + base_url: None, + excluded_models: Vec::new(), + }, + ); + gemini.supported_models = vec!["gemini-2.5-flash".to_string(), "gpt-4o".to_string()]; + + { + let conn = db.lock().expect("lock db for seed"); + ProviderPoolDao::insert(&conn, &openai).expect("insert openai credential"); + ProviderPoolDao::insert(&conn, &gemini).expect("insert gemini credential"); + } + + let models = ModelService::new() + .get_all_available_models(&db) + .expect("list available models"); + + assert_eq!( + models, + vec![ + "gemini-2.5-flash".to_string(), + "gpt-4.1".to_string(), + "gpt-4o".to_string(), + ] + ); + } } diff --git a/src-tauri/crates/services/src/update_check_service.rs b/src-tauri/crates/services/src/update_check_service.rs index 1a1197514..76e20b477 100644 --- a/src-tauri/crates/services/src/update_check_service.rs +++ b/src-tauri/crates/services/src/update_check_service.rs @@ -1,23 +1,5 @@ -//! 自动更新检查服务 -//! -//! 提供每日自动检查更新功能,支持: -//! - 定时检查(可配置间隔) -//! - 系统原生通知(macOS/Windows) -//! - 用户可控(启用/禁用、跳过版本) -//! -//! ## 平台适配 -//! - macOS: 使用 NSUserNotification / UNUserNotificationCenter -//! - Windows: 使用 Windows Toast Notification -//! -//! ## 使用示例 -//! ```rust,ignore -//! let service = UpdateCheckService::new(); -//! service.start_background_check(app_handle).await; -//! ``` - use serde::{Deserialize, Serialize}; use std::sync::Arc; -use std::time::Duration; use tokio::sync::RwLock; /// 更新检查结果 @@ -33,6 +15,10 @@ pub struct UpdateInfo { pub download_url: Option, /// 发布说明链接 pub release_notes_url: Option, + /// 发布说明摘要 + pub release_notes: Option, + /// 发布时间 + pub pub_date: Option, /// 检查时间(Unix 时间戳) pub checked_at: u64, /// 错误信息 @@ -53,26 +39,40 @@ pub struct UpdateCheckState { /// 更新检查服务 pub struct UpdateCheckService { state: Arc>, - github_api_url: String, } impl UpdateCheckService { const CURRENT_VERSION: &'static str = env!("CARGO_PKG_VERSION"); - const DEFAULT_GITHUB_API_URL: &'static str = - "https://api.github.com/repos/aiclientproxy/lime/releases/latest"; pub fn new() -> Self { Self { state: Arc::new(RwLock::new(UpdateCheckState::default())), - github_api_url: Self::DEFAULT_GITHUB_API_URL.to_string(), } } + pub fn current_version() -> &'static str { + Self::CURRENT_VERSION + } + /// 获取当前状态 pub async fn get_state(&self) -> UpdateCheckState { self.state.read().await.clone() } + /// 标记开始检查 + pub async fn begin_check(&self) { + let mut state = self.state.write().await; + state.is_checking = true; + } + + /// 写回检查结果 + pub async fn finish_check(&self, result: UpdateInfo) -> UpdateInfo { + let mut state = self.state.write().await; + state.is_checking = false; + state.last_result = Some(result.clone()); + result + } + /// 检查是否需要执行更新检查 pub fn should_check( last_check_timestamp: u64, @@ -101,119 +101,8 @@ impl UpdateCheckService { true } - - /// 执行更新检查 - pub async fn check_for_updates(&self) -> UpdateInfo { - { - let mut state = self.state.write().await; - state.is_checking = true; - } - - let result = self.do_check().await; - - { - let mut state = self.state.write().await; - state.is_checking = false; - state.last_result = Some(result.clone()); - } - - result - } - - async fn do_check(&self) -> UpdateInfo { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build(); - - let client = match client { - Ok(c) => c, - Err(e) => { - return UpdateInfo { - current_version: Self::CURRENT_VERSION.to_string(), - latest_version: None, - has_update: false, - download_url: None, - release_notes_url: None, - checked_at: now, - error: Some(format!("创建 HTTP 客户端失败: {e}")), - }; - } - }; - - match client - .get(&self.github_api_url) - .header("User-Agent", "Lime") - .header("Accept", "application/vnd.github.v3+json") - .send() - .await - { - Ok(response) => { - if response.status().is_success() { - match response.json::().await { - Ok(data) => { - let latest_version = data["tag_name"] - .as_str() - .unwrap_or("") - .trim_start_matches('v') - .to_string(); - - let download_url = data["html_url"].as_str().map(|s| s.to_string()); - let release_notes_url = download_url.clone(); - - let has_update = - Self::version_compare(Self::CURRENT_VERSION, &latest_version); - - UpdateInfo { - current_version: Self::CURRENT_VERSION.to_string(), - latest_version: Some(latest_version), - has_update, - download_url, - release_notes_url, - checked_at: now, - error: None, - } - } - Err(e) => UpdateInfo { - current_version: Self::CURRENT_VERSION.to_string(), - latest_version: None, - has_update: false, - download_url: None, - release_notes_url: None, - checked_at: now, - error: Some(format!("解析响应失败: {e}")), - }, - } - } else { - UpdateInfo { - current_version: Self::CURRENT_VERSION.to_string(), - latest_version: None, - has_update: false, - download_url: None, - release_notes_url: None, - checked_at: now, - error: Some(format!("GitHub API 请求失败: {}", response.status())), - } - } - } - Err(e) => UpdateInfo { - current_version: Self::CURRENT_VERSION.to_string(), - latest_version: None, - has_update: false, - download_url: None, - release_notes_url: None, - checked_at: now, - error: Some(format!("网络请求失败: {e}")), - }, - } - } - /// 版本比较:返回 true 如果 latest > current - fn version_compare(current: &str, latest: &str) -> bool { + pub fn version_compare(current: &str, latest: &str) -> bool { let current = current.trim_start_matches('v'); let latest = latest.trim_start_matches('v'); @@ -242,38 +131,8 @@ impl Default for UpdateCheckService { Self::new() } } - -/// 发送系统通知(跨平台) -/// -/// 使用 Tauri 的通知 API 发送原生系统通知 -/// -/// TODO: 此功能暂时禁用,需要迁移到 Tauri v2 的通知插件 API -/// 参考:https://v2.tauri.app/plugin/notification/ -// #[cfg(feature = "notification")] -// pub async fn send_update_notification( -// app_handle: &tauri::AppHandle, -// update_info: &UpdateInfo, -// ) -> Result<(), String> { -// use tauri_plugin_notification::NotificationExt; -// -// if !update_info.has_update { -// return Ok(()); -// } -// -// let latest = update_info.latest_version.as_deref().unwrap_or("未知版本"); -// -// app_handle -// .notification() -// .builder() -// .title("Lime 有新版本可用") -// .body(&format!( -// "新版本 {} 已发布,当前版本 {}", -// latest, update_info.current_version -// )) -// .show() -// .map_err(|e| format!("发送通知失败: {}", e)) -// } /// 更新检查服务状态包装器(用于 Tauri 状态管理) +#[derive(Clone)] pub struct UpdateCheckServiceState(pub Arc>); impl UpdateCheckServiceState { diff --git a/src-tauri/src/agent/aster_agent.rs b/src-tauri/src/agent/aster_agent.rs index 3fdd22abc..88330fee7 100644 --- a/src-tauri/src/agent/aster_agent.rs +++ b/src-tauri/src/agent/aster_agent.rs @@ -4,11 +4,19 @@ //! 处理消息发送、事件流转换,并桥接会话存储服务。 use crate::agent::aster_state::{AsterAgentState, SessionConfigBuilder}; +use crate::config::GlobalConfigManagerState; use crate::database::DbConnection; +use crate::services::memory_profile_prompt_service::{ + merge_system_prompt_with_memory_context, MemoryPromptContext, +}; use aster::conversation::message::Message; use futures::StreamExt; -use lime_agent::{convert_agent_event, TauriAgentEvent, WriteArtifactEventEmitter}; -use tauri::{AppHandle, Emitter}; +use lime_agent::{ + convert_agent_event, get_persisted_session_metadata_sync, + merge_system_prompt_with_runtime_agents, TauriAgentEvent, WriteArtifactEventEmitter, +}; +use std::path::Path; +use tauri::{AppHandle, Emitter, Manager}; pub use lime_agent::{ PersistedSessionMetadata, SessionDetail, SessionInfo, SessionTitlePreviewMessage, @@ -47,9 +55,39 @@ impl AsterAgentWrapper { let cancel_token = state.create_cancel_token(&session_id).await; let user_message = Message::user().with_text(&message); - let session_config = SessionConfigBuilder::new(&session_id) - .include_context_trace(true) - .build(); + let mut session_config_builder = + SessionConfigBuilder::new(&session_id).include_context_trace(true); + let persisted_session_metadata = get_persisted_session_metadata_sync(db, &session_id) + .ok() + .flatten(); + let persisted_prompt = persisted_session_metadata + .as_ref() + .and_then(|session| session.system_prompt.clone()); + let working_dir = persisted_session_metadata + .as_ref() + .and_then(|session| session.working_dir.as_deref()) + .filter(|path| !path.trim().is_empty()) + .map(Path::new); + + let merged_prompt = + if let Some(config_manager) = app.try_state::() { + let runtime_config = config_manager.config(); + merge_system_prompt_with_memory_context( + merge_system_prompt_with_runtime_agents(persisted_prompt, working_dir), + &runtime_config, + MemoryPromptContext { + working_dir, + active_relative_path: None, + }, + ) + } else { + merge_system_prompt_with_runtime_agents(persisted_prompt, working_dir) + }; + + if let Some(prompt) = merged_prompt { + session_config_builder = session_config_builder.system_prompt(prompt); + } + let session_config = session_config_builder.build(); let agent_arc = state.get_agent_arc(); let guard = agent_arc.read().await; diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index d8cb70759..b8a4e618d 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -17,6 +17,12 @@ use super::types::{AppState, TrayManagerState}; const MAIN_WINDOW_LABEL: &str = "main"; +fn compiled_updater_public_key() -> Option<&'static str> { + option_env!("LIME_UPDATER_PUBLIC_KEY") + .map(str::trim) + .filter(|value| !value.is_empty()) +} + fn should_minimize_to_tray(window_label: &str, minimize_to_tray: bool) -> bool { minimize_to_tray && window_label == MAIN_WINDOW_LABEL } @@ -127,7 +133,7 @@ pub fn run() { let shared_stats_clone = shared_stats.clone(); let shared_tokens_clone = shared_tokens.clone(); let shared_logger_clone = shared_logger.clone(); - let update_check_service_clone = update_check_service_state.0.clone(); + let update_check_service_clone = update_check_service_state.clone(); let gateway_tunnel_state = lime_gateway::tunnel::GatewayTunnelState::default(); let gateway_tunnel_state_for_setup = gateway_tunnel_state.clone(); let global_config_manager_for_setup = global_config_manager_state.clone(); @@ -226,6 +232,17 @@ pub fn run() { } }) .setup(move |app| { + #[cfg(desktop)] + if let Some(public_key) = compiled_updater_public_key() { + app.handle().plugin( + tauri_plugin_updater::Builder::new() + .pubkey(public_key) + .build(), + )?; + } else { + tracing::info!("[启动] 未注入 updater 公钥,跳过注册 updater 插件"); + } + // 启动时先最大化再显示,避免用户看到“先小窗后展开”的过程。 if let Some(main_window) = app.get_webview_window("main") { reveal_main_window(&main_window); @@ -1085,14 +1102,16 @@ pub fn run() { // Path utility commands commands::config_cmd::expand_path, commands::config_cmd::open_auth_dir, - commands::config_cmd::check_for_updates, - commands::config_cmd::download_update, // OpenClaw commands commands::openclaw_cmd::openclaw_check_installed, commands::openclaw_cmd::openclaw_get_environment_status, commands::openclaw_cmd::openclaw_check_node_version, commands::openclaw_cmd::openclaw_check_git_available, commands::openclaw_cmd::openclaw_get_node_download_url, + commands::claw_solution_cmd::claw_solution_list, + commands::claw_solution_cmd::claw_solution_detail, + commands::claw_solution_cmd::claw_solution_check_readiness, + commands::claw_solution_cmd::claw_solution_prepare, commands::openclaw_cmd::openclaw_get_git_download_url, commands::openclaw_cmd::openclaw_get_command_preview, commands::openclaw_cmd::openclaw_get_progress_logs, @@ -1384,28 +1403,28 @@ pub fn run() { commands::agent_cmd::agent_get_process_status, commands::agent_cmd::agent_generate_title, // Aster Agent commands - commands::aster_agent_cmd::aster_agent_init, - commands::aster_agent_cmd::aster_agent_status, - commands::aster_agent_cmd::aster_agent_reset, - commands::aster_agent_cmd::aster_agent_configure_provider, - 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, - commands::aster_agent_cmd::social_generate_cover_image_cmd, + commands::aster_agent_cmd::command_api::provider_api::aster_agent_init, + commands::aster_agent_cmd::command_api::provider_api::aster_agent_status, + commands::aster_agent_cmd::command_api::provider_api::aster_agent_reset, + commands::aster_agent_cmd::command_api::provider_api::aster_agent_configure_provider, + commands::aster_agent_cmd::command_api::provider_api::aster_agent_configure_from_pool, + commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_submit_turn, + commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_interrupt_turn, + commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_promote_queued_turn, + commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_remove_queued_turn, + commands::aster_agent_cmd::command_api::session_api::agent_runtime_create_session, + commands::aster_agent_cmd::command_api::session_api::agent_runtime_list_sessions, + commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_session, + commands::aster_agent_cmd::command_api::runtime_api::agent_runtime_get_tool_inventory, + commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_spawn_subagent, + commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_send_subagent_input, + commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_wait_subagents, + commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_resume_subagent, + commands::aster_agent_cmd::command_api::subagent_api::agent_runtime_close_subagent, + commands::aster_agent_cmd::command_api::session_api::agent_runtime_update_session, + commands::aster_agent_cmd::action_runtime::agent_runtime_delete_session, + commands::aster_agent_cmd::action_runtime::agent_runtime_respond_action, + commands::aster_agent_cmd::tool_runtime::social_tools::social_generate_cover_image_cmd, commands::theme_context_cmd::aster_agent_theme_context_search, // Models config commands commands::models_cmd::get_models_config, @@ -1564,6 +1583,8 @@ pub fn run() { commands::screenshot_cmd::send_screenshot_chat, // Update Check commands commands::update_cmd::check_update, + commands::update_cmd::check_for_updates, + commands::update_cmd::download_update, commands::update_cmd::get_update_check_settings, commands::update_cmd::set_update_check_settings, commands::update_cmd::get_update_notification_metrics, @@ -1736,6 +1757,8 @@ pub fn run() { commands::memory_management_cmd::memory_get_auto_index, commands::memory_management_cmd::memory_toggle_auto, commands::memory_management_cmd::memory_update_auto_note, + commands::memory_management_cmd::memory_scaffold_runtime_agents_template, + commands::memory_management_cmd::memory_ensure_workspace_local_agents_gitignore, // Unified Memory commands commands::unified_memory_cmd::unified_memory_list, commands::unified_memory_cmd::unified_memory_get, diff --git a/src-tauri/src/commands/aster_agent_cmd.rs b/src-tauri/src/commands/aster_agent_cmd.rs deleted file mode 100644 index 8327d584e..000000000 --- a/src-tauri/src/commands/aster_agent_cmd.rs +++ /dev/null @@ -1,10953 +0,0 @@ -//! Aster Agent 命令模块 -//! -//! 提供基于 Aster 框架的 Tauri 命令 -//! 这是新的对话系统实现,与 native_agent_cmd.rs 并行存在 -//! 支持从 Lime 凭证池自动选择凭证 - -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, 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::{ - browser_execute_action_global, ensure_managed_chrome_profile_global, BrowserActionRequest, - BrowserBackendType, -}; -use crate::config::{GlobalConfigManager, GlobalConfigManagerState}; -use crate::database::DbConnection; -use crate::mcp::{McpManagerState, McpServerConfig}; -use crate::services::agent_timeline_service::AgentTimelineRecorder; -use crate::services::automation_service::AutomationServiceState; -use crate::services::execution_tracker_service::{ExecutionTracker, RunFinishDecision, RunSource}; -use crate::services::memory_profile_prompt_service::{ - merge_system_prompt_with_memory_context, MemoryPromptContext, -}; -use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search; -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::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, 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::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, - TaskOutputTool, TaskTool, Tool, ToolContext, ToolError, ToolOptions, ToolResult, - MAX_OUTPUT_LENGTH, -}; -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::{ - merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy_with_mode, - stream_message_reply_with_policy, ReplyAttemptError, RequestToolPolicy, RequestToolPolicyMode, -}; -use lime_agent::{ - 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; -use lime_services::mcp_service::McpService; -use lime_services::video_generation_service::{ - CreateVideoGenerationRequest, VideoGenerationService, -}; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::{Duration, Instant}; -use tauri::{AppHandle, Emitter, State}; -use tokio_util::sync::CancellationToken; -use uuid::Uuid; - -const DEFAULT_BASH_TIMEOUT_SECS: u64 = 300; -const MAX_BASH_TIMEOUT_SECS: u64 = 1800; -const CODE_EXECUTION_EXTENSION_NAME: &str = "code_execution"; -const WORKSPACE_SANDBOX_ENABLED_ENV_KEYS: &[&str] = &[ - "LIME_WORKSPACE_SANDBOX_ENABLED", - "PROXYCAST_WORKSPACE_SANDBOX_ENABLED", -]; -const WORKSPACE_SANDBOX_STRICT_ENV_KEYS: &[&str] = &[ - "LIME_WORKSPACE_SANDBOX_STRICT", - "PROXYCAST_WORKSPACE_SANDBOX_STRICT", -]; -const WORKSPACE_SANDBOX_NOTIFY_ENV_KEYS: &[&str] = &[ - "LIME_WORKSPACE_SANDBOX_NOTIFY_ON_FALLBACK", - "PROXYCAST_WORKSPACE_SANDBOX_NOTIFY_ON_FALLBACK", -]; -const WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE: &str = "workspace_sandbox_fallback"; -const WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE: &str = "workspace_path_auto_created"; -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 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] = - &["LIME_FORCE_REACT_HINTS", "PROXYCAST_FORCE_REACT_HINTS"]; -const CODE_ORCHESTRATED_HINT_ENV_KEYS: &[&str] = &[ - "LIME_CODE_ORCHESTRATED_HINTS", - "PROXYCAST_CODE_ORCHESTRATED_HINTS", -]; -const BROWSER_PROFILE_KEY_ENV_KEYS: &[&str] = - &["LIME_BROWSER_PROFILE_KEY", "PROXYCAST_BROWSER_PROFILE_KEY"]; -const BROWSER_ASSIST_ALLOW_PATTERN: &str = "mcp__lime-browser__*"; -const BROWSER_ASSIST_DENY_PATTERNS: &[&str] = &["mcp__playwright__*", "browser_*", "playwright*"]; - -static SHARED_TASK_MANAGER: OnceLock> = OnceLock::new(); -static BROWSER_ASSIST_RUNTIME_HINTS: OnceLock< - tokio::sync::RwLock>, -> = OnceLock::new(); - -fn shared_task_manager() -> Arc { - SHARED_TASK_MANAGER - .get_or_init(|| Arc::new(TaskManager::new())) - .clone() -} - -fn shared_browser_assist_runtime_hints( -) -> &'static tokio::sync::RwLock> { - BROWSER_ASSIST_RUNTIME_HINTS.get_or_init(|| tokio::sync::RwLock::new(HashMap::new())) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct BrowserAssistRuntimeHint { - profile_key: String, - preferred_backend: Option, - auto_launch: bool, - launch_url: Option, -} - -#[derive(Debug, Clone, Copy)] -struct WorkspaceSandboxPolicy { - enabled: bool, - strict: bool, - notify_on_fallback: bool, -} - -#[derive(Debug)] -enum WorkspaceSandboxApplyOutcome { - Applied { - sandbox_type: String, - }, - DisabledByConfig, - UnavailableFallback { - warning_message: String, - notify_user: bool, - }, -} - -fn parse_bool_env(names: &[&str]) -> Option { - lime_core::env_compat::bool_var(names) -} - -fn resolve_workspace_sandbox_policy( - config_manager: &GlobalConfigManagerState, -) -> WorkspaceSandboxPolicy { - let config = config_manager.config(); - let mut policy = WorkspaceSandboxPolicy { - enabled: config.agent.workspace_sandbox.enabled, - strict: config.agent.workspace_sandbox.strict, - notify_on_fallback: config.agent.workspace_sandbox.notify_on_fallback, - }; - - if let Some(enabled) = parse_bool_env(WORKSPACE_SANDBOX_ENABLED_ENV_KEYS) { - policy.enabled = enabled; - } - if let Some(strict) = parse_bool_env(WORKSPACE_SANDBOX_STRICT_ENV_KEYS) { - policy.strict = strict; - } - if let Some(notify) = parse_bool_env(WORKSPACE_SANDBOX_NOTIFY_ENV_KEYS) { - policy.notify_on_fallback = notify; - } - - policy -} - -fn workspace_sandbox_platform_hint() -> &'static str { - #[cfg(target_os = "windows")] - { - "Windows 当前未检测到可用本地 sandbox 执行器,建议关闭该选项或使用非严格模式。" - } - #[cfg(target_os = "macos")] - { - "macOS 需提供 sandbox-exec。" - } - #[cfg(target_os = "linux")] - { - "Linux 需安装 bwrap 或 firejail。" - } - #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] - { - "当前平台暂未集成本地 sandbox 执行器,建议关闭该选项。" - } -} - -fn build_workspace_sandbox_warning_message(reason: &str) -> String { - format!("已启用 workspace 本地 sandbox,但当前环境不可用,已自动降级为普通执行。原因: {reason}") -} - -fn build_turn_context_override( - metadata: Option<&serde_json::Value>, -) -> Option { - let serde_json::Value::Object(map) = metadata?.clone() else { - return None; - }; - - Some(TurnContextOverride { - metadata: map.into_iter().collect(), - ..TurnContextOverride::default() - }) -} - -/// Aster Agent 状态信息 -#[derive(Debug, Serialize)] -pub struct AsterAgentStatus { - pub initialized: bool, - pub provider_configured: bool, - pub provider_name: Option, - pub model_name: Option, - /// 凭证 UUID(来自凭证池) - #[serde(skip_serializing_if = "Option::is_none")] - pub credential_uuid: Option, -} - -/// Provider 配置请求 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConfigureProviderRequest { - #[serde(default)] - pub provider_id: Option, - pub provider_name: String, - pub model_name: String, - #[serde(default)] - pub api_key: Option, - #[serde(default)] - pub base_url: Option, -} - -/// 从凭证池配置 Provider 的请求 -#[derive(Debug, Deserialize)] -pub struct ConfigureFromPoolRequest { - /// Provider 类型 (openai, anthropic, kiro, gemini 等) - pub provider_type: String, - /// 模型名称 - 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( - state: State<'_, AsterAgentState>, - db: State<'_, DbConnection>, -) -> Result { - tracing::info!("[AsterAgent] 初始化 Agent"); - - state.init_agent_with_db(&db).await?; - ensure_tool_search_tool_registered(state.inner()).await?; - - let provider_config = state.get_provider_config().await; - - tracing::info!("[AsterAgent] Agent 初始化成功"); - - Ok(AsterAgentStatus { - initialized: true, - provider_configured: provider_config.is_some(), - provider_name: provider_config.as_ref().map(|c| c.provider_name.clone()), - model_name: provider_config.as_ref().map(|c| c.model_name.clone()), - credential_uuid: provider_config.and_then(|c| c.credential_uuid), - }) -} - -/// 配置 Aster Agent 的 Provider -#[tauri::command] -pub async fn aster_agent_configure_provider( - state: State<'_, AsterAgentState>, - db: State<'_, DbConnection>, - request: ConfigureProviderRequest, - session_id: String, -) -> Result { - tracing::info!( - "[AsterAgent] 配置 Provider: {} / {}", - request.provider_name, - 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, - provider_configured: true, - provider_name: Some(config.provider_name), - model_name: Some(config.model_name), - credential_uuid: None, - }) -} - -/// 从凭证池配置 Aster Agent 的 Provider -/// -/// 自动从 Lime 凭证池选择可用凭证并配置 Aster Provider -#[tauri::command] -pub async fn aster_agent_configure_from_pool( - state: State<'_, AsterAgentState>, - db: State<'_, DbConnection>, - request: ConfigureFromPoolRequest, - session_id: String, -) -> Result { - tracing::info!( - "[AsterAgent] 从凭证池配置 Provider: {} / {}", - request.provider_type, - request.model_name - ); - - let aster_config = state - .configure_provider_from_pool( - &db, - &request.provider_type, - &request.model_name, - &session_id, - ) - .await?; - persist_session_provider_routing(&session_id, &request.provider_type).await?; - - Ok(AsterAgentStatus { - initialized: true, - provider_configured: true, - provider_name: Some(aster_config.provider_name), - model_name: Some(aster_config.model_name), - credential_uuid: Some(aster_config.credential_uuid), - }) -} - -/// 获取 Aster Agent 状态 -#[tauri::command] -pub async fn aster_agent_status( - state: State<'_, AsterAgentState>, -) -> Result { - let provider_config = state.get_provider_config().await; - Ok(AsterAgentStatus { - initialized: state.is_initialized().await, - provider_configured: provider_config.is_some(), - provider_name: provider_config.as_ref().map(|c| c.provider_name.clone()), - model_name: provider_config.as_ref().map(|c| c.model_name.clone()), - credential_uuid: provider_config.and_then(|c| c.credential_uuid), - }) -} - -/// 重置 Aster Agent -/// -/// 清除当前 Provider 配置,下次对话时会重新从凭证池选择凭证。 -/// 用于切换凭证后无需重启应用即可生效。 -#[tauri::command] -pub async fn aster_agent_reset( - state: State<'_, AsterAgentState>, -) -> Result { - tracing::info!("[AsterAgent] 重置 Agent Provider 配置"); - - // 清除当前 Provider 配置 - state.clear_provider_config().await; - - Ok(AsterAgentStatus { - initialized: state.is_initialized().await, - provider_configured: false, - provider_name: None, - model_name: None, - credential_uuid: None, - }) -} - -/// 发送消息请求参数 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AsterChatRequest { - pub message: String, - #[serde(alias = "sessionId")] - pub session_id: String, - #[serde(alias = "eventName")] - pub event_name: String, - #[serde(default)] - #[allow(dead_code)] - pub images: Option>, - /// Provider 配置(可选,如果未配置则使用当前配置) - #[serde(default, alias = "providerConfig")] - pub provider_config: Option, - /// 项目 ID(可选,用于注入项目上下文到 System Prompt) - #[serde(default, alias = "projectId")] - pub project_id: Option, - /// Workspace ID(必填,用于校验会话与工作区一致性) - #[serde(alias = "workspaceId")] - pub workspace_id: String, - /// 是否强制开启联网搜索工具策略 - #[serde(default, alias = "webSearch")] - pub web_search: Option, - /// 联网搜索模式(disabled / allowed / required) - #[serde(default, alias = "searchMode")] - pub search_mode: Option, - /// 执行策略(react / code_orchestrated / auto) - #[serde(default, alias = "executionStrategy")] - pub execution_strategy: Option, - /// 自动续写策略(用于文稿续写等场景) - #[serde(default, alias = "autoContinue")] - pub auto_continue: Option, - /// 前端传入的 System Prompt(可选,优先级低于项目上下文) - #[serde(default, alias = "systemPrompt")] - pub system_prompt: Option, - /// 请求级元数据(可选,用于 harness / 主题工作台状态对齐) - #[serde(default)] - pub metadata: Option, - /// 回合 ID(可选,由前端提供时透传到 Aster runtime) - #[serde(default, alias = "turnId")] - pub turn_id: Option, - /// 会话忙时是否进入后端队列 - #[serde(default, alias = "queueIfBusy")] - pub queue_if_busy: Option, - /// 队列项 ID(由前端或后端生成) - #[serde(default, alias = "queuedTurnId")] - pub queued_turn_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentTurnConfigSnapshot { - #[serde(default, alias = "providerConfig")] - pub provider_config: Option, - #[serde(default, alias = "executionStrategy")] - pub execution_strategy: Option, - #[serde(default, alias = "webSearch")] - pub web_search: Option, - #[serde(default, alias = "searchMode")] - pub search_mode: Option, - #[serde(default, alias = "autoContinue")] - pub auto_continue: Option, - #[serde(default, alias = "systemPrompt")] - pub system_prompt: Option, - #[serde(default)] - pub metadata: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentRuntimeSubmitTurnRequest { - pub message: String, - #[serde(alias = "sessionId")] - pub session_id: String, - #[serde(alias = "eventName")] - pub event_name: String, - #[serde(default)] - pub images: Option>, - #[serde(alias = "workspaceId")] - pub workspace_id: String, - #[serde(default, alias = "turnConfig")] - pub turn_config: Option, - #[serde(default, alias = "turnId")] - #[allow(dead_code)] - pub turn_id: Option, - #[serde(default, alias = "queueIfBusy")] - pub queue_if_busy: Option, - #[serde(default, alias = "queuedTurnId")] - pub queued_turn_id: Option, -} - -impl From for AsterChatRequest { - fn from(request: AgentRuntimeSubmitTurnRequest) -> Self { - let turn_config = request.turn_config; - Self { - message: request.message, - session_id: request.session_id, - event_name: request.event_name, - images: request.images, - provider_config: turn_config - .as_ref() - .and_then(|config| config.provider_config.clone()), - project_id: None, - workspace_id: request.workspace_id, - web_search: turn_config.as_ref().and_then(|config| config.web_search), - search_mode: turn_config.as_ref().and_then(|config| config.search_mode), - execution_strategy: turn_config - .as_ref() - .and_then(|config| config.execution_strategy), - auto_continue: turn_config - .as_ref() - .and_then(|config| config.auto_continue.clone()), - system_prompt: turn_config - .as_ref() - .and_then(|config| config.system_prompt.clone()), - metadata: turn_config.and_then(|config| config.metadata), - turn_id: request.turn_id, - queue_if_busy: request.queue_if_busy, - queued_turn_id: request.queued_turn_id, - } - } -} - -#[derive(Debug, Deserialize)] -pub struct AgentRuntimeInterruptTurnRequest { - #[serde(alias = "sessionId")] - pub session_id: String, - #[serde(default, alias = "turnId")] - #[allow(dead_code)] - pub turn_id: Option, -} - -#[derive(Debug, Deserialize)] -pub struct AgentRuntimeRemoveQueuedTurnRequest { - #[serde(alias = "sessionId")] - pub session_id: String, - #[serde(alias = "queuedTurnId")] - 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, - pub name: String, - pub created_at: i64, - pub updated_at: i64, - pub thread_id: String, - pub messages: Vec, - pub execution_strategy: Option, - pub turns: Vec, - pub items: Vec, - #[serde(default)] - 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 { - fn from_session_detail(detail: SessionDetail, queued_turns: Vec) -> Self { - Self { - id: detail.id, - name: detail.name, - created_at: detail.created_at, - updated_at: detail.updated_at, - thread_id: detail.thread_id, - messages: detail.messages, - execution_strategy: detail.execution_strategy, - turns: detail.turns, - 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 { - ToolConfirmation, - AskUser, - Elicitation, -} - -#[derive(Debug, Deserialize)] -pub struct AgentRuntimeRespondActionRequest { - #[serde(alias = "sessionId")] - pub session_id: String, - #[serde(alias = "requestId")] - pub request_id: String, - #[serde(alias = "actionType")] - pub action_type: AgentRuntimeActionType, - pub confirmed: bool, - #[serde(default)] - pub response: Option, - #[serde(default, alias = "userData")] - pub user_data: Option, - #[serde(default)] - pub metadata: Option, - #[serde(default, alias = "eventName")] - pub event_name: Option, -} - -#[derive(Debug, Deserialize)] -pub struct AgentRuntimeUpdateSessionRequest { - #[serde(alias = "sessionId")] - pub session_id: String, - #[serde(default)] - pub name: Option, - #[serde(default, alias = "executionStrategy")] - pub execution_strategy: Option, -} - -/// 自动续写参数 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct AutoContinuePayload { - /// 主开关 - pub enabled: bool, - /// 快速模式 - #[serde(default, alias = "fastModeEnabled")] - pub fast_mode_enabled: bool, - /// 续写长度:0=短、1=中、2=长 - #[serde(default, alias = "continuationLength")] - pub continuation_length: u8, - /// 灵敏度:0-100 - #[serde(default)] - pub sensitivity: u8, - /// 来源标识 - #[serde(default)] - pub source: Option, -} - -impl AutoContinuePayload { - fn normalized(mut self) -> Self { - self.continuation_length = self.continuation_length.min(2); - self.sensitivity = self.sensitivity.min(100); - self.source = self - .source - .as_ref() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - self - } - - fn length_instruction(&self) -> &'static str { - match self.continuation_length.min(2) { - 0 => "短(补全 1-2 段,聚焦核心信息)", - 1 => "中(补全 3-5 段,兼顾结构与细节)", - _ => "长(扩展为可发布草稿,结构完整)", - } - } - - fn sensitivity_instruction(&self) -> &'static str { - match self.sensitivity.min(100) { - 0..=33 => "低:优先稳健延续原文表达", - 34..=66 => "中:保持一致性并适度优化表达", - _ => "高:在不偏题前提下积极补充观点亮点", - } - } -} - -fn build_auto_continue_system_prompt(config: &AutoContinuePayload) -> String { - let mode_instruction = if config.fast_mode_enabled { - "快速模式:优先产出可用结果,减少解释与冗余。" - } else { - "标准模式:兼顾可读性、完整性与发布可用性。" - }; - let source = config - .source - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("document_canvas"); - - format!( - "{AUTO_CONTINUE_PROMPT_MARKER}\n\ -执行来源:{source}\n\ -执行要求:\n\ -1. 本轮任务是“基于已有文稿的续写”,不得重复已有内容。\n\ -2. 从现有结尾自然衔接,保持原文语气、受众和主题方向。\n\ -3. 续写长度:{}。\n\ -4. 灵敏度({}%):{}。\n\ -5. {}\n\ -6. 输出正文时不要显式提及你看到了该策略配置。", - config.length_instruction(), - config.sensitivity, - config.sensitivity_instruction(), - mode_instruction, - ) -} - -fn merge_system_prompt_with_auto_continue( - base_prompt: Option, - auto_continue: Option<&AutoContinuePayload>, -) -> Option { - let Some(config) = auto_continue else { - return base_prompt; - }; - if !config.enabled { - return base_prompt; - } - - let auto_continue_prompt = build_auto_continue_system_prompt(config); - - match base_prompt { - Some(base) => { - if base.contains(AUTO_CONTINUE_PROMPT_MARKER) { - Some(base) - } else if base.trim().is_empty() { - Some(auto_continue_prompt) - } else { - Some(format!("{base}\n\n{auto_continue_prompt}")) - } - } - None => Some(auto_continue_prompt), - } -} - -fn build_elicitation_context_system_prompt( - request_metadata: Option<&serde_json::Value>, -) -> Option { - let metadata = request_metadata?.as_object()?; - let context = metadata.get("elicitation_context")?.as_object()?; - let entries = context.get("entries")?.as_array()?; - - let rendered_entries = entries - .iter() - .filter_map(|entry| { - let entry_object = entry.as_object()?; - let label = entry_object - .get("label") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty())?; - let summary = entry_object - .get("summary") - .or_else(|| entry_object.get("value")) - .and_then(render_elicitation_context_value)?; - Some(format!("- {label}: {summary}")) - }) - .collect::>(); - - if rendered_entries.is_empty() { - return None; - } - - let source = context - .get("source") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("structured_form"); - let mode = context - .get("mode") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("runtime_metadata"); - - Some(format!( - "{ELICITATION_CONTEXT_PROMPT_MARKER}\n\ -来源:{source}\n\ -模式:{mode}\n\ -执行要求:\n\ -1. 下列信息来自用户刚刚提交的结构化补充信息,视为本轮已确认约束。\n\ -2. 回答与后续执行时优先吸收这些信息,不要重复追问同一字段。\n\ -3. 若仍缺关键信息,只追问尚未填写的最少字段。\n\ -已确认信息:\n\ -{}", - rendered_entries.join("\n") - )) -} - -fn render_elicitation_context_value(value: &serde_json::Value) -> Option { - match value { - serde_json::Value::String(text) => { - let normalized = text.trim(); - if normalized.is_empty() { - None - } else { - Some(normalized.to_string()) - } - } - serde_json::Value::Number(number) => Some(number.to_string()), - serde_json::Value::Bool(boolean) => Some(if *boolean { - "是".to_string() - } else { - "否".to_string() - }), - serde_json::Value::Array(items) => { - let rendered = items - .iter() - .filter_map(render_elicitation_context_value) - .collect::>(); - if rendered.is_empty() { - None - } else { - Some(rendered.join("、")) - } - } - serde_json::Value::Object(object) => { - let rendered = serde_json::to_string(object).ok()?; - let normalized = rendered.trim(); - if normalized.is_empty() { - None - } else { - Some(normalized.to_string()) - } - } - serde_json::Value::Null => None, - } -} - -fn merge_system_prompt_with_elicitation_context( - base_prompt: Option, - request_metadata: Option<&serde_json::Value>, -) -> Option { - let Some(elicitation_prompt) = build_elicitation_context_system_prompt(request_metadata) else { - return base_prompt; - }; - - match base_prompt { - Some(base) => { - if base.contains(ELICITATION_CONTEXT_PROMPT_MARKER) { - Some(base) - } else if base.trim().is_empty() { - Some(elicitation_prompt) - } else { - Some(format!("{base}\n\n{elicitation_prompt}")) - } - } - None => Some(elicitation_prompt), - } -} - -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, - artifact_type: String, - stage: String, - stage_label: String, - version_label: String, - source_file_name: String, - branch_key: String, - platform: Option, - is_auxiliary: bool, -} - -#[derive(Debug, Clone, Default)] -struct ChatRunObservation { - artifact_paths: Vec, - primary_social_artifact: Option, -} - -impl ChatRunObservation { - fn record_event( - &mut self, - event: &TauriAgentEvent, - workspace_root: &str, - request_metadata: Option<&serde_json::Value>, - ) { - match event { - TauriAgentEvent::ToolStart { - tool_name, - arguments, - .. - } => { - if let Some(path) = extract_artifact_path_from_tool_start( - tool_name, - arguments.as_deref(), - workspace_root, - ) { - self.record_artifact_path(path, request_metadata); - } - } - TauriAgentEvent::ToolEnd { result, .. } => { - if let Some(metadata) = &result.metadata { - for path in - extract_artifact_paths_from_tool_result_metadata(metadata, workspace_root) - { - self.record_artifact_path(path, request_metadata); - } - } - } - TauriAgentEvent::ArtifactSnapshot { artifact } => { - if let Some(path) = - normalize_metadata_path(artifact.file_path.as_str(), workspace_root) - { - self.record_artifact_path(path, request_metadata); - } - } - _ => {} - } - } - - fn record_artifact_path(&mut self, path: String, request_metadata: Option<&serde_json::Value>) { - if path.trim().is_empty() { - return; - } - - if !self.artifact_paths.iter().any(|item| item == &path) { - self.artifact_paths.push(path.clone()); - } - - if !should_track_social_artifact(request_metadata, path.as_str()) { - return; - } - - let gate_key = extract_harness_string(request_metadata, &["gate_key", "gateKey"]); - let run_title = - extract_harness_string(request_metadata, &["run_title", "runTitle", "title"]); - let candidate = resolve_social_run_artifact_descriptor( - path.as_str(), - gate_key.as_deref(), - run_title.as_deref(), - ); - let should_replace = match self.primary_social_artifact.as_ref() { - None => true, - Some(existing) if existing.is_auxiliary && !candidate.is_auxiliary => true, - _ => false, - }; - if should_replace { - self.primary_social_artifact = Some(candidate); - } - } -} - -fn normalize_metadata_path(raw: &str, workspace_root: &str) -> Option { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return None; - } - - let normalized = trimmed.replace('\\', "/"); - let normalized_root = workspace_root.trim().replace('\\', "/"); - - if !normalized_root.is_empty() && normalized.starts_with(normalized_root.as_str()) { - let suffix = normalized - .strip_prefix(normalized_root.as_str()) - .unwrap_or(normalized.as_str()) - .trim_start_matches('/') - .to_string(); - if !suffix.is_empty() { - return Some(suffix); - } - } - - Some(normalized) -} - -fn parse_tool_arguments(arguments: Option<&str>) -> Option { - let raw = arguments?.trim(); - if raw.is_empty() { - return None; - } - serde_json::from_str::(raw).ok() -} - -fn extract_artifact_path_from_tool_start( - tool_name: &str, - arguments: Option<&str>, - workspace_root: &str, -) -> Option { - let normalized_tool_name = tool_name.trim().to_lowercase(); - if normalized_tool_name.is_empty() { - return None; - } - - let args = parse_tool_arguments(arguments)?; - let object = args.as_object()?; - - for key in ["path", "file_path", "filePath", "output_path", "outputPath"] { - let Some(raw_path) = object.get(key).and_then(serde_json::Value::as_str) else { - continue; - }; - if normalized_tool_name.contains("write") - || normalized_tool_name.contains("create") - || normalized_tool_name.contains("output") - { - return normalize_metadata_path(raw_path, workspace_root); - } - } - - None -} - -fn push_metadata_path(target: &mut Vec, value: &serde_json::Value, workspace_root: &str) { - match value { - serde_json::Value::String(path) => { - if let Some(normalized) = normalize_metadata_path(path, workspace_root) { - if !target.iter().any(|item| item == &normalized) { - target.push(normalized); - } - } - } - serde_json::Value::Array(items) => { - for item in items { - push_metadata_path(target, item, workspace_root); - } - } - _ => {} - } -} - -fn extract_artifact_paths_from_tool_result_metadata( - metadata: &HashMap, - workspace_root: &str, -) -> Vec { - let mut paths = Vec::new(); - for key in [ - "artifact_paths", - "artifact_path", - "path", - "absolute_path", - "output_file", - "file_path", - "output_path", - "article_path", - "cover_meta_path", - "publish_path", - ] { - if let Some(value) = metadata.get(key) { - push_metadata_path(&mut paths, value, workspace_root); - } - } - paths -} - -fn extract_harness_object( - request_metadata: Option<&serde_json::Value>, -) -> Option<&serde_json::Map> { - let metadata = request_metadata?; - let object = metadata.as_object()?; - if let Some(harness) = object.get("harness").and_then(serde_json::Value::as_object) { - return Some(harness); - } - Some(object) -} - -fn extract_harness_string( - request_metadata: Option<&serde_json::Value>, - keys: &[&str], -) -> Option { - let harness = extract_harness_object(request_metadata)?; - keys.iter() - .filter_map(|key| harness.get(*key)) - .find_map(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - -fn extract_harness_bool( - request_metadata: Option<&serde_json::Value>, - keys: &[&str], -) -> Option { - let harness = extract_harness_object(request_metadata)?; - keys.iter() - .filter_map(|key| harness.get(*key)) - .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], -) -> Option<&'a serde_json::Map> { - let harness = extract_harness_object(request_metadata)?; - keys.iter() - .filter_map(|key| harness.get(*key)) - .find_map(serde_json::Value::as_object) -} - -fn parse_browser_backend_hint(value: &str) -> Option { - match value.trim().to_ascii_lowercase().as_str() { - "aster_compat" => Some(BrowserBackendType::AsterCompat), - "lime_extension_bridge" => Some(BrowserBackendType::LimeExtensionBridge), - "cdp_direct" => Some(BrowserBackendType::CdpDirect), - _ => None, - } -} - -fn extract_browser_assist_runtime_hint( - request_metadata: Option<&serde_json::Value>, -) -> Option { - let browser_assist = - extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"])?; - let profile_key = ["profile_key", "profileKey"] - .iter() - .filter_map(|key| browser_assist.get(*key)) - .find_map(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty())? - .to_string(); - let preferred_backend = ["preferred_backend", "preferredBackend"] - .iter() - .filter_map(|key| browser_assist.get(*key)) - .find_map(serde_json::Value::as_str) - .and_then(parse_browser_backend_hint); - let auto_launch = ["auto_launch", "autoLaunch"] - .iter() - .filter_map(|key| browser_assist.get(*key)) - .find_map(serde_json::Value::as_bool) - .unwrap_or(false); - let launch_url = ["launch_url", "launchUrl", "url"] - .iter() - .filter_map(|key| browser_assist.get(*key)) - .find_map(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - - Some(BrowserAssistRuntimeHint { - profile_key, - preferred_backend, - auto_launch, - launch_url, - }) -} - -fn is_browser_assist_enabled(request_metadata: Option<&serde_json::Value>) -> bool { - let Some(browser_assist) = - extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"]) - else { - return false; - }; - - if let Some(enabled) = ["enabled", "is_enabled", "isEnabled"] - .iter() - .filter_map(|key| browser_assist.get(*key)) - .find_map(serde_json::Value::as_bool) - { - return enabled; - } - - extract_browser_assist_runtime_hint(request_metadata).is_some() || !browser_assist.is_empty() -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum BrowserTaskRequirement { - Optional, - Required, - RequiredWithUserStep, -} - -fn extract_browser_task_requirement( - request_metadata: Option<&serde_json::Value>, -) -> Option { - match extract_harness_string( - request_metadata, - &["browser_requirement", "browserRequirement"], - ) - .as_deref() - { - Some("optional") => Some(BrowserTaskRequirement::Optional), - Some("required") => Some(BrowserTaskRequirement::Required), - Some("required_with_user_step") => Some(BrowserTaskRequirement::RequiredWithUserStep), - _ => None, - } -} - -fn apply_browser_requirement_to_request_tool_policy( - request_metadata: Option<&serde_json::Value>, - request_web_search: Option, - request_search_mode: Option, -) -> (Option, Option) { - match extract_browser_task_requirement(request_metadata) { - Some(BrowserTaskRequirement::Required | BrowserTaskRequirement::RequiredWithUserStep) => { - (Some(false), Some(RequestToolPolicyMode::Disabled)) - } - _ => (request_web_search, request_search_mode), - } -} - -fn build_session_scoped_permission_conditions(session_id: &str) -> Vec { - let session_id = session_id.trim(); - if session_id.is_empty() { - return Vec::new(); - } - - vec![PermissionCondition { - condition_type: ConditionType::Session, - field: Some("session_id".to_string()), - operator: ConditionOperator::Equals, - value: serde_json::json!(session_id), - validator: None, - description: Some("仅对当前聊天会话生效".to_string()), - }] -} - -fn append_browser_assist_session_permissions( - permissions: &mut Vec, - session_id: &str, - request_metadata: Option<&serde_json::Value>, -) { - if !is_browser_assist_enabled(request_metadata) { - return; - } - - let conditions = build_session_scoped_permission_conditions(session_id); - permissions.push(ToolPermission { - tool: BROWSER_ASSIST_ALLOW_PATTERN.to_string(), - allowed: true, - priority: 1100, - conditions: conditions.clone(), - parameter_restrictions: Vec::new(), - scope: PermissionScope::Session, - reason: Some( - "Browser Assist 会话已启用:网页任务应统一走 Lime 浏览器运行时工具".to_string(), - ), - expires_at: None, - metadata: HashMap::new(), - }); - - for pattern in BROWSER_ASSIST_DENY_PATTERNS { - permissions.push(ToolPermission { - tool: (*pattern).to_string(), - allowed: false, - priority: 1200, - conditions: conditions.clone(), - parameter_restrictions: Vec::new(), - scope: PermissionScope::Session, - reason: Some( - "Browser Assist 会话禁止回退到 Playwright 浏览器工具;请改用 mcp__lime-browser__*,以便右侧画布附着实时浏览器会话" - .to_string(), - ), - expires_at: None, - metadata: HashMap::new(), - }); - } -} - -async fn sync_browser_assist_runtime_hint( - session_id: &str, - request_metadata: Option<&serde_json::Value>, -) { - let mut hints = shared_browser_assist_runtime_hints().write().await; - if let Some(hint) = extract_browser_assist_runtime_hint(request_metadata) { - hints.insert(session_id.to_string(), hint); - } else { - hints.remove(session_id); - } -} - -async fn get_browser_assist_runtime_hint(session_id: &str) -> Option { - shared_browser_assist_runtime_hints() - .read() - .await - .get(session_id) - .cloned() -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RuntimeChatMode { - Agent, - Creator, - General, -} - -fn resolve_runtime_chat_mode(request_metadata: Option<&serde_json::Value>) -> RuntimeChatMode { - if let Some(chat_mode) = extract_harness_string(request_metadata, &["chat_mode", "chatMode"]) { - match chat_mode.as_str() { - "general" => return RuntimeChatMode::General, - "creator" => return RuntimeChatMode::Creator, - _ => {} - } - } - - match extract_harness_string(request_metadata, &["theme", "harness_theme"]).as_deref() { - Some("general" | "knowledge" | "planning") => RuntimeChatMode::General, - _ => RuntimeChatMode::Agent, - } -} - -fn default_web_search_enabled_for_chat_mode(_chat_mode: RuntimeChatMode) -> bool { - false -} - -fn should_enable_model_skill_tool(request_metadata: Option<&serde_json::Value>) -> bool { - if let Some(explicit) = extract_harness_bool( - request_metadata, - &["allow_model_skills", "allowModelSkills"], - ) { - return explicit; - } - - matches!( - extract_harness_string(request_metadata, &["session_mode", "sessionMode"]).as_deref(), - Some("theme_workbench") - ) -} - -fn execution_strategy_label(strategy: AsterExecutionStrategy) -> &'static str { - match strategy { - AsterExecutionStrategy::React => "对话执行优先", - AsterExecutionStrategy::CodeOrchestrated => "代码编排执行", - AsterExecutionStrategy::Auto => "自动路由执行", - } -} - -fn model_supports_reasoning(model_name: Option<&str>) -> bool { - let Some(model_name) = model_name.map(str::trim).filter(|value| !value.is_empty()) else { - return false; - }; - let normalized = model_name.to_ascii_lowercase(); - normalized.contains("thinking") - || normalized.contains("reason") - || normalized.contains("r1") - || normalized.contains("o1") - || normalized.contains("o3") - || normalized.contains("o4") - || normalized.contains("gpt-5") - || normalized.contains("2.5") -} - -fn message_suggests_live_search(message: &str) -> bool { - let normalized = message.to_ascii_lowercase(); - [ - "搜索", - "搜一下", - "查一下", - "查一查", - "检索", - "上网查", - "联网查", - "最新", - "今天", - "刚刚", - "实时", - "新闻", - "股价", - "汇率", - "天气", - "政策", - "法规", - "版本", - "价格", - "热搜", - "上线", - "发布", - "search", - "look up", - "google", - "browse", - "now", - "today", - "latest", - "recent", - "price", - "version", - "news", - "weather", - ] - .iter() - .any(|keyword| normalized.contains(keyword)) -} - -fn message_suggests_planning(message: &str) -> bool { - let normalized = message.to_ascii_lowercase(); - [ - "计划", - "规划", - "roadmap", - "拆解", - "分步骤", - "执行方案", - "实施方案", - "阶段", - "里程碑", - "todo", - ] - .iter() - .any(|keyword| normalized.contains(keyword)) -} - -fn message_suggests_task(message: &str) -> bool { - let normalized = message.to_ascii_lowercase(); - [ - "后台", - "稍后", - "异步", - "排队", - "持续生成", - "长时间", - "继续跑", - "持续跑", - ] - .iter() - .any(|keyword| normalized.contains(keyword)) -} - -fn message_suggests_subagent(message: &str) -> bool { - let normalized = message.to_ascii_lowercase(); - [ - "并行", - "多代理", - "分工", - "分别分析", - "从多个角度", - "parallel", - "subagent", - "delegate", - ] - .iter() - .any(|keyword| normalized.contains(keyword)) -} - -fn build_turn_runtime_statuses( - request: &AsterChatRequest, - effective_strategy: AsterExecutionStrategy, - request_tool_policy: &RequestToolPolicy, - model_name: Option<&str>, -) -> (TauriRuntimeStatus, TauriRuntimeStatus) { - let thinking_enabled = extract_harness_bool( - request.metadata.as_ref(), - &["thinking_enabled", "thinkingEnabled"], - ) - .unwrap_or(false); - let task_enabled = extract_harness_bool( - request.metadata.as_ref(), - &["task_mode_enabled", "taskModeEnabled"], - ) - .unwrap_or(false); - let subagent_enabled = extract_harness_bool( - request.metadata.as_ref(), - &["subagent_mode_enabled", "subagentModeEnabled"], - ) - .unwrap_or(false); - let reasoning_supported = model_supports_reasoning(model_name); - let news_expansion_needed = request_tool_policy.allows_web_search() - && message_suggests_news_expansion(&request.message); - let browser_task_requirement = extract_browser_task_requirement(request.metadata.as_ref()); - - let initial_checkpoints = vec![ - execution_strategy_label(effective_strategy).to_string(), - if request_tool_policy.requires_web_search() { - "本回合必须先联网核实".to_string() - } else if news_expansion_needed { - "已识别新闻综述类输入,将先并发 WebSearch 扩搜".to_string() - } else if request_tool_policy.allows_web_search() { - "联网搜索仅作为候选能力待命".to_string() - } else { - "默认直接回答优先".to_string() - }, - if matches!( - browser_task_requirement, - Some(BrowserTaskRequirement::Required | BrowserTaskRequirement::RequiredWithUserStep) - ) { - "当前任务要求真实浏览器执行,不允许退化为联网检索".to_string() - } else { - "浏览器能力按需升级".to_string() - }, - if thinking_enabled && reasoning_supported { - "模型支持深度思考,先进入推理判定".to_string() - } else if thinking_enabled { - "当前模型不支持显式 thinking,改走轻量意图理解".to_string() - } else { - "先做轻量意图理解".to_string() - }, - if task_enabled { - "后台任务能力已待命".to_string() - } else { - "默认不升级后台任务".to_string() - }, - if subagent_enabled { - "多代理能力已待命".to_string() - } else { - "默认由单 Agent 先判断".to_string() - }, - ]; - - let decided = if request_tool_policy.requires_web_search() { - ( - "已决定:先联网检索".to_string(), - "当前回合已被明确指定为先搜索后答复,会先完成联网核实再继续生成。".to_string(), - vec![ - "用户明确要求联网搜索".to_string(), - "搜索结果返回后再形成最终答复".to_string(), - ], - ) - } else if news_expansion_needed { - ( - "已决定:先联网扩搜".to_string(), - "当前输入属于新闻/最新动态综述类请求,会先并发执行多组 WebSearch,再基于结果做主题聚类与交叉验证。" - .to_string(), - vec![ - "统一使用 WebSearch 执行多组扩搜".to_string(), - "完成来源整合后再组织最终答复".to_string(), - ], - ) - } else if subagent_enabled && message_suggests_subagent(&request.message) { - ( - "已决定:优先拆分为多代理".to_string(), - "用户输入更适合并行分工处理,先按多代理路径组织执行。".to_string(), - vec![ - "检测到并行/多角度需求".to_string(), - "主线程先承担协调职责".to_string(), - ], - ) - } else if task_enabled && message_suggests_task(&request.message) { - ( - "已决定:升级为后台任务".to_string(), - "用户输入更接近耗时或异步推进场景,优先走后台任务链路。".to_string(), - vec![ - "检测到排队/持续执行诉求".to_string(), - "先建立任务,再回传过程与产出".to_string(), - ], - ) - } else if thinking_enabled && reasoning_supported { - ( - "已决定:先深度思考".to_string(), - "当前模型支持 reasoning,先做更充分的意图理解与方案判断,再决定是否调用搜索或工具。" - .to_string(), - vec![ - "thinking 已开启".to_string(), - "搜索与工具保持候选状态,不默认触发".to_string(), - ], - ) - } else if thinking_enabled { - ( - "已决定:轻量理解后回答".to_string(), - "当前模型不支持显式 reasoning,先做轻量意图理解,再决定是否需要搜索或其他能力。" - .to_string(), - vec![ - "thinking 已开启".to_string(), - "当前模型回退为轻量推理".to_string(), - ], - ) - } else if request_tool_policy.allows_web_search() - && message_suggests_live_search(&request.message) - { - ( - "已决定:先联网核实".to_string(), - "问题包含明显时效性或实时性特征,先搜索核实再回答更稳妥。".to_string(), - vec![ - "已检测到最新/实时信息需求".to_string(), - "搜索完成后继续组织答复".to_string(), - ], - ) - } else if message_suggests_planning(&request.message) { - ( - "已决定:先规划再输出".to_string(), - "当前请求更像计划或方案拆解,会先整理执行路径和关键步骤。".to_string(), - vec![ - "检测到计划/拆解需求".to_string(), - "优先输出结构化行动路径".to_string(), - ], - ) - } else { - ( - "已决定:直接回答优先".to_string(), - "当前请求无需默认升级为搜索或任务,先直接给出结果,必要时再调用工具。".to_string(), - vec![ - "默认保持单回合直接回答".to_string(), - "只有证据不足或时效性要求出现时才升级".to_string(), - ], - ) - }; - - ( - TauriRuntimeStatus { - phase: "preparing".to_string(), - title: "正在理解意图".to_string(), - detail: - "正在判断当前回合应该直接回答、深度思考、规划、联网核实,还是升级为任务/多代理。" - .to_string(), - checkpoints: initial_checkpoints, - }, - TauriRuntimeStatus { - phase: "routing".to_string(), - title: decided.0, - detail: decided.1, - checkpoints: decided.2, - }, - ) -} - -fn emit_projected_runtime_item_event( - app: &AppHandle, - event_name: &str, - timeline_recorder: &Arc>, - workspace_root: &str, - event: TauriAgentEvent, -) { - if let Err(error) = app.emit(event_name, &event) { - tracing::warn!("[AsterAgent] 发送 runtime item 投影事件失败: {}", error); - } - - let mut recorder = match timeline_recorder.lock() { - Ok(guard) => guard, - Err(error) => error.into_inner(), - }; - if let Err(error) = recorder.record_runtime_event(app, event_name, &event, workspace_root) { - tracing::warn!( - "[AsterAgent] 记录 runtime item 投影事件失败(已降级继续): {}", - error - ); - } -} - -async fn emit_runtime_status_with_projection( - agent: &Agent, - app: &AppHandle, - event_name: &str, - timeline_recorder: &Arc>, - workspace_root: &str, - session_config: &aster::agents::SessionConfig, - status: TauriRuntimeStatus, -) { - match agent - .upsert_runtime_status_item( - session_config, - status.phase.clone(), - status.title.clone(), - status.detail.clone(), - status.checkpoints.clone(), - ) - .await - { - Ok(agent_event) => { - for event in lime_agent::convert_agent_event(agent_event) { - emit_projected_runtime_item_event( - app, - event_name, - timeline_recorder, - workspace_root, - event, - ); - } - } - Err(error) => { - tracing::warn!( - "[AsterAgent] 写入 runtime_status item 失败,降级仅发送 transient 事件: {}", - error - ); - } - } - - let runtime_event = TauriAgentEvent::RuntimeStatus { status }; - if let Err(error) = app.emit(event_name, &runtime_event) { - tracing::warn!("[AsterAgent] 发送 runtime_status 失败: {}", error); - } -} - -async fn complete_runtime_status_projection( - agent: &Agent, - app: &AppHandle, - event_name: &str, - timeline_recorder: &Arc>, - workspace_root: &str, - session_config: &aster::agents::SessionConfig, -) { - match agent.complete_runtime_status_item(session_config).await { - Ok(Some(agent_event)) => { - for event in lime_agent::convert_agent_event(agent_event) { - emit_projected_runtime_item_event( - app, - event_name, - timeline_recorder, - workspace_root, - event, - ); - } - } - Ok(None) => {} - Err(error) => { - tracing::warn!("[AsterAgent] 完成 runtime_status item 失败: {}", error); - } - } -} - -fn extend_map_with_harness_fields( - target: &mut serde_json::Map, - request_metadata: Option<&serde_json::Value>, -) { - if let Some(metadata) = request_metadata { - target.insert("request_metadata".to_string(), metadata.clone()); - } - - let Some(harness) = extract_harness_object(request_metadata) else { - return; - }; - - for (source_key, target_key) in [ - ("theme", "harness_theme"), - ("harness_theme", "harness_theme"), - ("creation_mode", "creation_mode"), - ("creationMode", "creation_mode"), - ("chat_mode", "chat_mode"), - ("chatMode", "chat_mode"), - ("session_mode", "session_mode"), - ("sessionMode", "session_mode"), - ("gate_key", "gate_key"), - ("gateKey", "gate_key"), - ("run_title", "run_title"), - ("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"), - ("browserRequirementReason", "browser_requirement_reason"), - ("browser_launch_url", "browser_launch_url"), - ("browserLaunchUrl", "browser_launch_url"), - ] { - if target.contains_key(target_key) { - continue; - } - if let Some(value) = harness.get(source_key) { - target.insert(target_key.to_string(), value.clone()); - } - } -} - -fn build_chat_run_metadata_base( - request: &AsterChatRequest, - workspace_id: &str, - effective_strategy: AsterExecutionStrategy, - request_tool_policy: &RequestToolPolicy, - auto_continue_enabled: bool, - auto_continue_metadata: Option<&AutoContinuePayload>, -) -> serde_json::Map { - let mut metadata = serde_json::Map::new(); - metadata.insert("workspace_id".to_string(), serde_json::json!(workspace_id)); - metadata.insert( - "project_id".to_string(), - serde_json::json!(request.project_id.clone()), - ); - metadata.insert( - "event_name".to_string(), - serde_json::json!(request.event_name.clone()), - ); - metadata.insert( - "execution_strategy".to_string(), - serde_json::json!(format!("{:?}", effective_strategy).to_lowercase()), - ); - metadata.insert( - "message_length".to_string(), - serde_json::json!(request.message.chars().count()), - ); - metadata.insert( - "web_search_enabled".to_string(), - serde_json::json!(request_tool_policy.effective_web_search), - ); - metadata.insert( - "web_search_mode".to_string(), - serde_json::json!(request_tool_policy.search_mode.as_str()), - ); - metadata.insert( - "auto_continue_enabled".to_string(), - serde_json::json!(auto_continue_enabled), - ); - metadata.insert( - "auto_continue".to_string(), - serde_json::json!(auto_continue_metadata), - ); - extend_map_with_harness_fields(&mut metadata, request.metadata.as_ref()); - metadata -} - -fn with_string_field( - target: &mut serde_json::Map, - key: &str, - value: Option<&str>, -) { - if target.contains_key(key) { - return; - } - if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { - target.insert(key.to_string(), serde_json::json!(value)); - } -} - -fn should_track_social_artifact(request_metadata: Option<&serde_json::Value>, path: &str) -> bool { - if extract_harness_string(request_metadata, &["theme", "harness_theme"]) - .map(|theme| theme == "social-media") - .unwrap_or(false) - { - return true; - } - path.to_lowercase().contains("social") -} - -fn normalize_artifact_file_name(file_name: &str) -> String { - file_name.replace('\\', "/").trim().to_string() -} - -fn artifact_base_name(file_name: &str) -> String { - normalize_artifact_file_name(file_name) - .split('/') - .last() - .unwrap_or(file_name) - .to_string() -} - -fn strip_social_known_suffix(file_name: &str) -> String { - let base_name = artifact_base_name(file_name); - if let Some(value) = base_name.strip_suffix(".publish-pack.json") { - return value.to_string(); - } - if let Some(value) = base_name.strip_suffix(".cover.json") { - return value.to_string(); - } - base_name - .rsplit_once('.') - .map(|(prefix, _)| prefix.to_string()) - .unwrap_or(base_name) -} - -fn to_social_branch_key(file_name: &str) -> String { - let mut branch_key = String::new(); - let mut last_is_dash = false; - for ch in strip_social_known_suffix(file_name).chars() { - let keep = ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fa5}').contains(&ch); - if keep { - branch_key.push(ch.to_ascii_lowercase()); - last_is_dash = false; - } else if !last_is_dash { - branch_key.push('-'); - last_is_dash = true; - } - } - let branch_key = branch_key.trim_matches('-').to_string(); - if branch_key.is_empty() { - "artifact".to_string() - } else { - branch_key - } -} - -fn infer_social_platform_from_text(text: &str) -> Option { - let normalized = text.to_lowercase(); - if normalized.contains("xiaohongshu") || normalized.contains("xhs") || text.contains("小红书") - { - return Some("xiaohongshu".to_string()); - } - if normalized.contains("wechat") - || normalized.contains("weixin") - || normalized.contains("gzh") - || text.contains("公众号") - || text.contains("微信") - { - return Some("wechat".to_string()); - } - if normalized.contains("zhihu") || text.contains("知乎") { - return Some("zhihu".to_string()); - } - None -} - -fn resolve_social_artifact_type( - normalized_file_name: &str, - platform: Option<&str>, - gate_key: Option<&str>, -) -> String { - let base_name = artifact_base_name(normalized_file_name).to_lowercase(); - if base_name.ends_with(".publish-pack.json") { - return "publish_package".to_string(); - } - if base_name.ends_with(".cover.json") { - return "cover_meta".to_string(); - } - if !base_name.ends_with(".md") { - return "asset".to_string(); - } - if base_name == "brief.md" || base_name.contains("brief") { - return "brief".to_string(); - } - if base_name == "draft.md" || base_name.contains("draft") { - return "draft".to_string(); - } - if base_name == "article.md" || base_name.contains("article") || base_name.contains("final") { - return "polished".to_string(); - } - if base_name == "adapted.md" || base_name.contains("adapt") { - return "platform_variant".to_string(); - } - if platform.is_some() { - return "platform_variant".to_string(); - } - match gate_key.unwrap_or_default() { - "topic_select" => "brief".to_string(), - "publish_confirm" => { - if platform.is_some() { - "platform_variant".to_string() - } else { - "polished".to_string() - } - } - _ => "draft".to_string(), - } -} - -fn resolve_social_stage_for_artifact(artifact_type: &str, gate_key: Option<&str>) -> String { - match artifact_type { - "brief" => "briefing".to_string(), - "draft" => "drafting".to_string(), - "polished" => "polishing".to_string(), - "platform_variant" => "adapting".to_string(), - "cover_meta" | "publish_package" => "publish_prep".to_string(), - _ => match gate_key.unwrap_or("idle") { - "topic_select" => "briefing".to_string(), - "publish_confirm" => "publish_prep".to_string(), - _ => "drafting".to_string(), - }, - } -} - -fn resolve_social_stage_label(stage: &str) -> String { - match stage { - "briefing" => "需求澄清".to_string(), - "drafting" => "初稿创作".to_string(), - "polishing" => "润色优化".to_string(), - "adapting" => "平台适配".to_string(), - "publish_prep" => "发布准备".to_string(), - _ => "社媒创作".to_string(), - } -} - -fn resolve_social_version_label(artifact_type: &str, platform: Option<&str>) -> String { - match artifact_type { - "brief" => "需求简报".to_string(), - "draft" => "社媒初稿".to_string(), - "polished" => "润色成稿".to_string(), - "platform_variant" => match platform { - Some("xiaohongshu") => "平台适配 · 小红书".to_string(), - Some("wechat") => "平台适配 · 公众号".to_string(), - Some("zhihu") => "平台适配 · 知乎".to_string(), - _ => "平台适配".to_string(), - }, - "cover_meta" => "封面配置".to_string(), - "publish_package" => "发布包".to_string(), - _ => "社媒产物".to_string(), - } -} - -fn resolve_social_run_artifact_descriptor( - file_name: &str, - gate_key: Option<&str>, - run_title: Option<&str>, -) -> SocialRunArtifactDescriptor { - let normalized_file_name = normalize_artifact_file_name(file_name); - let platform = infer_social_platform_from_text( - format!("{} {}", normalized_file_name, run_title.unwrap_or_default()).as_str(), - ); - let artifact_type = - resolve_social_artifact_type(normalized_file_name.as_str(), platform.as_deref(), gate_key); - let stage = resolve_social_stage_for_artifact(artifact_type.as_str(), gate_key); - let branch_key = to_social_branch_key(normalized_file_name.as_str()); - let artifact_suffix = match platform.as_deref() { - Some(platform) => format!("{branch_key}:{platform}"), - None => branch_key.clone(), - }; - - SocialRunArtifactDescriptor { - artifact_id: format!("social-media:{}:{}", artifact_type, artifact_suffix), - artifact_type: artifact_type.clone(), - stage: stage.clone(), - stage_label: resolve_social_stage_label(stage.as_str()), - version_label: resolve_social_version_label(artifact_type.as_str(), platform.as_deref()), - source_file_name: normalized_file_name, - branch_key, - platform, - is_auxiliary: matches!( - artifact_type.as_str(), - "cover_meta" | "publish_package" | "asset" - ), - } -} - -fn infer_gate_key_from_social_stage(stage: &str) -> Option<&'static str> { - match stage { - "briefing" => Some("topic_select"), - "drafting" | "polishing" => Some("write_mode"), - "adapting" | "publish_prep" => Some("publish_confirm"), - _ => None, - } -} - -fn build_chat_run_finish_metadata( - base_metadata: &serde_json::Map, - observation: &ChatRunObservation, -) -> serde_json::Value { - let mut metadata = base_metadata.clone(); - - if !observation.artifact_paths.is_empty() { - metadata.insert( - "artifact_paths".to_string(), - serde_json::json!(observation.artifact_paths.clone()), - ); - } - - if let Some(artifact) = observation.primary_social_artifact.as_ref() { - with_string_field(&mut metadata, "harness_theme", Some("social-media")); - with_string_field( - &mut metadata, - "artifact_id", - Some(artifact.artifact_id.as_str()), - ); - with_string_field( - &mut metadata, - "artifact_type", - Some(artifact.artifact_type.as_str()), - ); - with_string_field(&mut metadata, "stage", Some(artifact.stage.as_str())); - with_string_field( - &mut metadata, - "stage_label", - Some(artifact.stage_label.as_str()), - ); - with_string_field( - &mut metadata, - "version_label", - Some(artifact.version_label.as_str()), - ); - with_string_field( - &mut metadata, - "branch_key", - Some(artifact.branch_key.as_str()), - ); - with_string_field(&mut metadata, "platform", artifact.platform.as_deref()); - with_string_field( - &mut metadata, - "source_file_name", - Some(artifact.source_file_name.as_str()), - ); - let version_id = format!("artifact:{}", artifact.source_file_name); - with_string_field(&mut metadata, "version_id", Some(version_id.as_str())); - - if !metadata.contains_key("gate_key") { - with_string_field( - &mut metadata, - "gate_key", - infer_gate_key_from_social_stage(artifact.stage.as_str()), - ); - } - if !metadata.contains_key("run_title") { - with_string_field( - &mut metadata, - "run_title", - Some(artifact.version_label.as_str()), - ); - } - } - - serde_json::Value::Object(metadata) -} - -/// Agent 执行策略 -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[derive(Default)] -pub enum AsterExecutionStrategy { - React, - CodeOrchestrated, - #[default] - Auto, -} - -impl AsterExecutionStrategy { - fn as_db_value(self) -> &'static str { - match self { - Self::React => "react", - Self::CodeOrchestrated => "code_orchestrated", - Self::Auto => "auto", - } - } - - fn from_db_value(value: Option<&str>) -> Self { - match value { - Some("code_orchestrated") => Self::CodeOrchestrated, - Some("auto") => Self::Auto, - _ => Self::Auto, - } - } - - fn effective_for_message(self, message: &str) -> Self { - if should_force_react_for_message(message) { - return Self::React; - } - - match self { - Self::Auto if should_use_code_orchestrated_for_message(message) => { - Self::CodeOrchestrated - } - Self::Auto => Self::React, - _ => self, - } - } -} - -fn should_force_react_for_message(message: &str) -> bool { - let lowered = message.to_lowercase(); - let default_hints = [ - "tool_search", - "调用 tool_search", - "调用tool_search", - "use tool_search", - "call tool_search", - "websearch", - "web search", - "web_search", - "webfetch", - "web fetch", - "web_fetch", - ]; - resolve_intent_hints(FORCE_REACT_HINT_ENV_KEYS, &default_hints) - .iter() - .any(|kw| lowered.contains(kw)) -} - -fn should_use_code_orchestrated_for_message(message: &str) -> bool { - let lowered = message.to_lowercase(); - // 默认不做消息关键词硬编码推断,Auto 模式优先走 ReAct。 - // 如需启用自动切换,可通过环境变量 LIME_CODE_ORCHESTRATED_HINTS 显式配置。 - resolve_intent_hints(CODE_ORCHESTRATED_HINT_ENV_KEYS, &[]) - .iter() - .any(|kw| lowered.contains(kw)) -} - -fn resolve_intent_hints(env_keys: &[&str], defaults: &[&str]) -> Vec { - if let Some(raw) = lime_core::env_compat::var(env_keys) { - let parsed = raw - .split(',') - .map(|item| item.trim().to_lowercase()) - .filter(|item| !item.is_empty()) - .collect::>(); - if !parsed.is_empty() { - return parsed; - } - } - - defaults.iter().map(|item| item.to_string()).collect() -} - -fn should_fallback_to_react_from_code_orchestrated(error: &ReplyAttemptError) -> bool { - if !error.emitted_any { - return true; - } - - let lowered = error.message.to_lowercase(); - let recoverable_hints = ["unknown subscript", "tool_search_analysis", "web_scraping"]; - - recoverable_hints.iter().any(|hint| lowered.contains(hint)) -} - -async fn ensure_code_execution_extension_enabled(agent: &Agent) -> Result { - let extension_configs = agent.get_extension_configs().await; - if extension_configs - .iter() - .any(|cfg| cfg.name() == CODE_EXECUTION_EXTENSION_NAME) - { - return Ok(false); - } - - let extension = ExtensionConfig::Platform { - name: CODE_EXECUTION_EXTENSION_NAME.to_string(), - description: "Execute JavaScript code in a sandboxed environment".to_string(), - bundled: Some(true), - available_tools: vec![], - deferred_loading: false, - always_expose_tools: Vec::new(), - allowed_caller: None, - }; - - agent - .add_extension(extension) - .await - .map_err(|e| format!("启用 code_execution 扩展失败: {e}"))?; - - Ok(true) -} - -async fn stream_reply_once( - agent: &Agent, - app: &AppHandle, - event_name: &str, - user_message: Message, - working_directory: Option<&Path>, - session_config: aster::agents::SessionConfig, - cancel_token: CancellationToken, - request_tool_policy: &RequestToolPolicy, - mut on_event: F, -) -> Result<(), ReplyAttemptError> -where - F: FnMut(&TauriAgentEvent), -{ - stream_message_reply_with_policy( - agent, - user_message, - working_directory, - session_config, - Some(cancel_token), - request_tool_policy, - |event| { - on_event(event); - 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 - .map(|_| ()) -} - -fn build_runtime_user_message(message_text: &str, images: Option<&[ImageInput]>) -> Message { - let mut message = Message::user(); - - if !message_text.is_empty() { - message = message.with_text(message_text); - } - - if let Some(images) = images { - for image in images { - if image.data.trim().is_empty() || image.media_type.trim().is_empty() { - continue; - } - message = message.with_image(image.data.clone(), image.media_type.clone()); - } - } - - if message.content.is_empty() { - return Message::user().with_text(message_text); - } - - message -} - -/// 基于 aster::sandbox 的本地 bash 强隔离工具 -#[derive(Debug)] -struct WorkspaceSandboxedBashTool { - delegate: BashTool, - sandbox_type_name: String, - base_sandbox_config: ProcessSandboxConfig, - auto_approve_warnings: bool, -} - -impl WorkspaceSandboxedBashTool { - fn new(workspace_root: &str, auto_approve_warnings: bool) -> Result { - let workspace_root = workspace_root.trim(); - if workspace_root.is_empty() { - return Err("workspace 根目录为空".to_string()); - } - - let sandbox_type = detect_best_sandbox(); - let sandbox_type_name = format!("{sandbox_type:?}"); - if sandbox_type_name == "None" { - return Err(format!( - "未检测到可用本地 sandbox 执行器。{}", - workspace_sandbox_platform_hint() - )); - } - - let workspace_path = PathBuf::from(workspace_root); - let mut read_only_paths = vec![ - PathBuf::from("/usr"), - PathBuf::from("/bin"), - PathBuf::from("/sbin"), - PathBuf::from("/etc"), - PathBuf::from("/System"), - PathBuf::from("/Library"), - workspace_path.clone(), - ]; - read_only_paths.sort(); - read_only_paths.dedup(); - - let mut writable_paths = vec![workspace_path.clone(), PathBuf::from("/tmp")]; - if cfg!(target_os = "macos") { - writable_paths.push(PathBuf::from("/private/tmp")); - } - writable_paths.sort(); - writable_paths.dedup(); - - let base_sandbox_config = ProcessSandboxConfig { - enabled: true, - sandbox_type, - allowed_paths: vec![workspace_path], - denied_paths: Vec::new(), - network_access: false, - environment_variables: HashMap::new(), - read_only_paths, - writable_paths, - allow_dev_access: false, - allow_proc_access: false, - allow_sys_access: false, - env_whitelist: Vec::new(), - tmpfs_size: "64M".to_string(), - unshare_all: true, - die_with_parent: true, - new_session: true, - docker: None, - custom_args: Vec::new(), - audit_logging: None, - resource_limits: None, - }; - - Ok(Self { - delegate: BashTool::new(), - sandbox_type_name, - base_sandbox_config, - auto_approve_warnings, - }) - } - - fn sandbox_type(&self) -> &str { - &self.sandbox_type_name - } - - fn build_sandbox_config( - &self, - context: &ToolContext, - timeout_secs: u64, - ) -> ProcessSandboxConfig { - let mut config = self.base_sandbox_config.clone(); - - let mut environment_variables = HashMap::new(); - environment_variables.insert("ASTER_TERMINAL".to_string(), "1".to_string()); - for (key, value) in &context.environment { - environment_variables.insert(key.clone(), value.clone()); - } - if let Ok(path_env) = std::env::var("PATH") { - environment_variables - .entry("PATH".to_string()) - .or_insert(path_env); - } - - config.environment_variables = environment_variables; - config.resource_limits = Some(ResourceLimits { - max_memory: Some(1024 * 1024 * 1024), - max_cpu: Some(70), - max_processes: Some(32), - max_file_size: Some(50 * 1024 * 1024), - max_execution_time: Some(timeout_secs.saturating_mul(1000)), - max_file_descriptors: Some(256), - }); - config - } - - #[cfg(not(target_os = "windows"))] - fn quote_shell(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\"'\"'")) - } - - fn build_shell_command(&self, command: &str, _context: &ToolContext) -> (String, Vec) { - #[cfg(target_os = "windows")] - { - return ( - "powershell".to_string(), - vec![ - "-NoProfile".to_string(), - "-NonInteractive".to_string(), - "-Command".to_string(), - command.to_string(), - ], - ); - } - - #[cfg(not(target_os = "windows"))] - { - let working_dir = _context.working_directory.to_string_lossy().to_string(); - let wrapped_command = format!("cd {} && {}", Self::quote_shell(&working_dir), command); - ("sh".to_string(), vec!["-lc".to_string(), wrapped_command]) - } - } - - fn format_output(stdout: &str, stderr: &str, exit_code: i32) -> String { - let mut output = String::new(); - - if !stdout.is_empty() { - output.push_str(stdout); - } - - if !stderr.is_empty() { - if !output.is_empty() && !output.ends_with('\n') { - output.push('\n'); - } - if !stdout.is_empty() { - output.push_str("--- stderr ---\n"); - } - output.push_str(stderr); - } - - if exit_code != 0 && output.is_empty() { - output = format!("Command exited with code {exit_code}"); - } - - if output.len() <= MAX_OUTPUT_LENGTH { - return output; - } - - let bytes = output.as_bytes(); - let truncated = String::from_utf8_lossy(&bytes[..MAX_OUTPUT_LENGTH]).to_string(); - format!( - "{}\n\n[output truncated: {} bytes total]", - truncated, - output.len() - ) - } -} - -fn normalize_shell_command_params(params: &serde_json::Value) -> serde_json::Value { - let mut normalized = params.clone(); - if let Some(object) = normalized.as_object_mut() { - let has_command = object - .get("command") - .and_then(|value| value.as_str()) - .map(|value| !value.trim().is_empty()) - .unwrap_or(false); - - if !has_command { - if let Some(cmd_value) = object.get("cmd").cloned() { - if cmd_value - .as_str() - .map(|value| !value.trim().is_empty()) - .unwrap_or(false) - { - object.insert("command".to_string(), cmd_value); - } - } - } - } - normalized -} - -fn normalize_workspace_tool_permission_behavior( - permission: PermissionCheckResult, - auto_approve_warnings: bool, -) -> PermissionCheckResult { - if permission.behavior != PermissionBehavior::Ask { - return permission; - } - - let warning = permission - .message - .unwrap_or_else(|| "命令包含潜在风险操作".to_string()); - - if auto_approve_warnings { - tracing::warn!("[AsterAgent] Auto 模式自动通过 bash 风险提示: {}", warning); - return PermissionCheckResult { - behavior: PermissionBehavior::Allow, - message: None, - updated_params: permission.updated_params, - }; - } - - PermissionCheckResult { - behavior: PermissionBehavior::Deny, - message: Some(format!( - "{warning}。当前模式不支持交互确认,请切换到 Auto 模式或调整命令。" - )), - updated_params: permission.updated_params, - } -} - -fn append_workspace_bash_summary( - mut output: String, - exit_code: i32, - stdout_length: usize, - stderr_length: usize, - sandboxed: bool, - sandbox_type: &str, -) -> String { - if !output.is_empty() && !output.ends_with('\n') { - output.push('\n'); - } - - let output_truncated = output.contains("[output truncated:"); - output.push_str("\n[Lime 执行摘要]\n"); - output.push_str(&format!("exit_code: {exit_code}\n")); - output.push_str(&format!("stdout_length: {stdout_length}\n")); - output.push_str(&format!("stderr_length: {stderr_length}\n")); - output.push_str(&format!("sandboxed: {sandboxed}\n")); - output.push_str(&format!("sandbox_type: {sandbox_type}\n")); - output.push_str(&format!("output_truncated: {output_truncated}")); - output -} - -fn output_contains_lime_metadata_block(output: &str) -> bool { - output.contains(LIME_TOOL_METADATA_BEGIN) && output.contains(LIME_TOOL_METADATA_END) -} - -fn append_lime_tool_metadata_block( - mut content: String, - metadata: &serde_json::Map, -) -> String { - if output_contains_lime_metadata_block(&content) { - return content; - } - - if !content.is_empty() && !content.ends_with('\n') { - content.push('\n'); - } - if !content.is_empty() { - content.push('\n'); - } - - let metadata_json = serde_json::to_string(metadata).unwrap_or_else(|_| "{}".to_string()); - content.push_str(LIME_TOOL_METADATA_BEGIN); - content.push('\n'); - content.push_str(&metadata_json); - content.push('\n'); - content.push_str(LIME_TOOL_METADATA_END); - content -} - -fn encode_tool_result_for_harness_observability(result: ToolResult) -> ToolResult { - let mut metadata = result.metadata.clone(); - let base_content = if result.success { - result.output.unwrap_or_default() - } else { - metadata - .entry("reported_success".to_string()) - .or_insert_with(|| serde_json::json!(false)); - result - .error - .unwrap_or_else(|| "工具执行失败,但未返回错误详情".to_string()) - }; - - if result.success && metadata.is_empty() { - return ToolResult::success(base_content); - } - - let encoded_output = - if metadata.is_empty() || output_contains_lime_metadata_block(&base_content) { - base_content - } else { - let metadata_object = metadata - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect::>(); - append_lime_tool_metadata_block(base_content, &metadata_object) - }; - - ToolResult::success(encoded_output).with_metadata_map(metadata) -} - -fn remap_virtual_memory_path_param( - params: &mut serde_json::Map, - key: &str, -) -> Result { - let Some(raw_path) = params.get(key).and_then(|value| value.as_str()) else { - return Ok(false); - }; - - let Some(mapped_path) = - resolve_virtual_memory_path(raw_path).map_err(ToolError::invalid_params)? - else { - return Ok(false); - }; - - params.insert( - key.to_string(), - serde_json::Value::String(mapped_path.to_string_lossy().to_string()), - ); - Ok(true) -} - -fn remap_virtual_memory_glob_pattern( - params: &mut serde_json::Map, -) -> Result { - let Some(pattern) = params.get("pattern").and_then(|value| value.as_str()) else { - return Ok(false); - }; - if !is_virtual_memory_path(pattern) { - return Ok(false); - } - - let relative_pattern = virtual_memory_relative_path(pattern).unwrap_or_default(); - if relative_pattern.split('/').any(|segment| segment == "..") { - return Err(ToolError::invalid_params( - "glob.pattern 中的 `/memories/` 路径不允许包含 `..`".to_string(), - )); - } - - let root_path = resolve_virtual_memory_path(DURABLE_MEMORY_VIRTUAL_ROOT) - .map_err(ToolError::invalid_params)? - .ok_or_else(|| ToolError::invalid_params("无法解析 durable memory 根目录".to_string()))?; - - let normalized_pattern = relative_pattern.trim_start_matches('/'); - let normalized_pattern = if normalized_pattern.is_empty() { - "**/*".to_string() - } else { - normalized_pattern.to_string() - }; - - params.insert( - "path".to_string(), - serde_json::Value::String(root_path.to_string_lossy().to_string()), - ); - params.insert( - "pattern".to_string(), - serde_json::Value::String(normalized_pattern), - ); - Ok(true) -} - -fn normalize_params_for_durable_memory_support( - tool_name: &str, - params: &serde_json::Value, -) -> Result { - let Some(map) = params.as_object() else { - return Ok(params.clone()); - }; - - let mut normalized = map.clone(); - let mut changed = false; - - match tool_name { - "read" | "write" | "edit" | "grep" => { - changed |= remap_virtual_memory_path_param(&mut normalized, "path")?; - } - "glob" => { - changed |= remap_virtual_memory_path_param(&mut normalized, "path")?; - changed |= remap_virtual_memory_glob_pattern(&mut normalized)?; - } - _ => {} - } - - if changed { - Ok(serde_json::Value::Object(normalized)) - } else { - Ok(params.clone()) - } -} - -struct DurableMemoryMappedTool { - delegate: Box, -} - -impl DurableMemoryMappedTool { - fn new(delegate: Box) -> Self { - Self { delegate } - } -} - -#[async_trait] -impl Tool for DurableMemoryMappedTool { - fn name(&self) -> &str { - self.delegate.name() - } - - fn description(&self) -> &str { - self.delegate.description() - } - - fn dynamic_description(&self) -> Option { - self.delegate.dynamic_description() - } - - fn input_schema(&self) -> serde_json::Value { - self.delegate.input_schema() - } - - fn options(&self) -> ToolOptions { - self.delegate.options() - } - - async fn check_permissions( - &self, - params: &serde_json::Value, - context: &ToolContext, - ) -> PermissionCheckResult { - let normalized_params = - match normalize_params_for_durable_memory_support(self.name(), params) { - Ok(value) => value, - Err(error) => { - return PermissionCheckResult::deny(format!( - "durable memory 参数无效: {error}" - )); - } - }; - - let mut result = self - .delegate - .check_permissions(&normalized_params, context) - .await; - - if result.updated_params.is_none() && normalized_params != *params { - result.updated_params = Some(normalized_params); - } - result - } - - async fn execute( - &self, - params: serde_json::Value, - context: &ToolContext, - ) -> Result { - let normalized_params = normalize_params_for_durable_memory_support(self.name(), ¶ms)?; - self.delegate.execute(normalized_params, context).await - } -} - -struct HarnessObservedTool { - delegate: Box, -} - -impl HarnessObservedTool { - fn new(delegate: Box) -> Self { - Self { delegate } - } -} - -#[async_trait] -impl Tool for HarnessObservedTool { - fn name(&self) -> &str { - self.delegate.name() - } - - fn description(&self) -> &str { - self.delegate.description() - } - - fn dynamic_description(&self) -> Option { - self.delegate.dynamic_description() - } - - fn input_schema(&self) -> serde_json::Value { - self.delegate.input_schema() - } - - fn options(&self) -> ToolOptions { - self.delegate.options() - } - - async fn check_permissions( - &self, - params: &serde_json::Value, - context: &ToolContext, - ) -> PermissionCheckResult { - self.delegate.check_permissions(params, context).await - } - - async fn execute( - &self, - params: serde_json::Value, - context: &ToolContext, - ) -> Result { - self.delegate - .execute(params, context) - .await - .map(encode_tool_result_for_harness_observability) - } -} - -fn wrap_registry_native_tools_for_harness_observability(registry: &mut aster::tools::ToolRegistry) { - let tool_names = registry - .native_tool_names() - .into_iter() - .map(|name| name.to_string()) - .collect::>(); - - for tool_name in tool_names { - let Some(tool) = registry.unregister(&tool_name) else { - continue; - }; - registry.register(Box::new(HarnessObservedTool::new(tool))); - } -} - -fn wrap_registry_native_tools_for_durable_memory_fs(registry: &mut aster::tools::ToolRegistry) { - for tool_name in ["read", "write", "edit", "glob", "grep"] { - let Some(tool) = registry.unregister(tool_name) else { - continue; - }; - registry.register(Box::new(DurableMemoryMappedTool::new(tool))); - } -} - -#[async_trait] -impl Tool for WorkspaceSandboxedBashTool { - fn name(&self) -> &str { - self.delegate.name() - } - - fn description(&self) -> &str { - self.delegate.description() - } - - fn input_schema(&self) -> serde_json::Value { - self.delegate.input_schema() - } - - fn options(&self) -> ToolOptions { - self.delegate.options() - } - - async fn check_permissions( - &self, - params: &serde_json::Value, - context: &ToolContext, - ) -> PermissionCheckResult { - let normalized_params = normalize_shell_command_params(params); - let permission = self - .delegate - .check_permissions(&normalized_params, context) - .await; - normalize_workspace_tool_permission_behavior(permission, self.auto_approve_warnings) - } - - async fn execute( - &self, - params: serde_json::Value, - context: &ToolContext, - ) -> Result { - let normalized_params = normalize_shell_command_params(¶ms); - - if context.is_cancelled() { - return Err(ToolError::Cancelled); - } - - let permission = self.check_permissions(&normalized_params, context).await; - match permission.behavior { - PermissionBehavior::Allow => {} - PermissionBehavior::Deny => { - let message = permission - .message - .unwrap_or_else(|| "命令被安全策略拒绝".to_string()); - return Err(ToolError::permission_denied(message)); - } - PermissionBehavior::Ask => { - let message = permission - .message - .unwrap_or_else(|| "命令需要人工确认".to_string()); - return Err(ToolError::permission_denied(message)); - } - } - - let command = normalized_params - .get("command") - .and_then(|v| v.as_str()) - .ok_or_else(|| ToolError::invalid_params("Missing required parameter: command"))?; - - let background = normalized_params - .get("background") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if background { - return Err(ToolError::invalid_params( - "本地 sandbox 模式不支持 background=true", - )); - } - - let timeout_secs = normalized_params - .get("timeout") - .and_then(|v| v.as_u64()) - .unwrap_or(DEFAULT_BASH_TIMEOUT_SECS) - .min(MAX_BASH_TIMEOUT_SECS); - - let sandbox_config = self.build_sandbox_config(context, timeout_secs); - let (entry, args) = self.build_shell_command(command, context); - - let execution = tokio::time::timeout( - Duration::from_secs(timeout_secs), - execute_in_sandbox(&entry, &args, &sandbox_config), - ) - .await - .map_err(|_| ToolError::timeout(Duration::from_secs(timeout_secs)))? - .map_err(|e| ToolError::execution_failed(format!("sandbox 执行失败: {e}")))?; - - let output = append_workspace_bash_summary( - Self::format_output(&execution.stdout, &execution.stderr, execution.exit_code), - execution.exit_code, - execution.stdout.len(), - execution.stderr.len(), - execution.sandboxed, - &format!("{:?}", execution.sandbox_type), - ); - if execution.exit_code == 0 { - Ok(ToolResult::success(output) - .with_metadata("exit_code", serde_json::json!(execution.exit_code)) - .with_metadata("stdout_length", serde_json::json!(execution.stdout.len())) - .with_metadata("stderr_length", serde_json::json!(execution.stderr.len())) - .with_metadata("sandboxed", serde_json::json!(execution.sandboxed)) - .with_metadata( - "sandbox_type", - serde_json::json!(format!("{:?}", execution.sandbox_type)), - )) - } else { - Ok(ToolResult::success(output) - .with_metadata("exit_code", serde_json::json!(execution.exit_code)) - .with_metadata("stdout_length", serde_json::json!(execution.stdout.len())) - .with_metadata("stderr_length", serde_json::json!(execution.stderr.len())) - .with_metadata("sandboxed", serde_json::json!(execution.sandboxed)) - .with_metadata( - "sandbox_type", - serde_json::json!(format!("{:?}", execution.sandbox_type)), - ) - .with_metadata("reported_success", serde_json::json!(false))) - } - } -} - -/// 统一处理 Task 工具的 Ask 权限,避免缺少回调导致流程中断 -struct WorkspaceTaskTool { - delegate: TaskTool, - auto_approve_warnings: bool, -} - -impl WorkspaceTaskTool { - fn new(auto_approve_warnings: bool, task_manager: Arc) -> Self { - Self { - delegate: TaskTool::with_manager(task_manager), - auto_approve_warnings, - } - } -} - -#[async_trait] -impl Tool for WorkspaceTaskTool { - fn name(&self) -> &str { - self.delegate.name() - } - - fn description(&self) -> &str { - self.delegate.description() - } - - fn input_schema(&self) -> serde_json::Value { - self.delegate.input_schema() - } - - fn options(&self) -> ToolOptions { - self.delegate.options() - } - - async fn check_permissions( - &self, - params: &serde_json::Value, - context: &ToolContext, - ) -> PermissionCheckResult { - let normalized_params = normalize_shell_command_params(params); - let permission = self - .delegate - .check_permissions(&normalized_params, context) - .await; - normalize_workspace_tool_permission_behavior(permission, self.auto_approve_warnings) - } - - async fn execute( - &self, - params: serde_json::Value, - context: &ToolContext, - ) -> Result { - let normalized_params = normalize_shell_command_params(¶ms); - self.delegate.execute(normalized_params, context).await - } -} - -struct WorkspaceTaskOutputTool { - delegate: TaskOutputTool, - task_manager: Arc, -} - -impl WorkspaceTaskOutputTool { - fn new(task_manager: Arc) -> Self { - Self { - delegate: TaskOutputTool::with_manager(task_manager.clone()), - task_manager, - } - } -} - -#[async_trait] -impl Tool for WorkspaceTaskOutputTool { - fn name(&self) -> &str { - self.delegate.name() - } - - fn description(&self) -> &str { - self.delegate.description() - } - - fn input_schema(&self) -> serde_json::Value { - self.delegate.input_schema() - } - - fn options(&self) -> ToolOptions { - self.delegate.options() - } - - async fn check_permissions( - &self, - params: &serde_json::Value, - context: &ToolContext, - ) -> PermissionCheckResult { - self.delegate.check_permissions(params, context).await - } - - async fn execute( - &self, - params: serde_json::Value, - context: &ToolContext, - ) -> Result { - let input = serde_json::from_value::(params.clone()).ok(); - let mut result = self.delegate.execute(params, context).await?; - - let Some(task_id) = input.map(|value| value.task_id) else { - return Ok(result); - }; - - let Some(state) = self.task_manager.get_status(&task_id).await else { - return Ok(result); - }; - - result = result - .with_metadata( - "output_file", - serde_json::json!(state.output_file.to_string_lossy().to_string()), - ) - .with_metadata( - "working_directory", - serde_json::json!(state.working_directory.to_string_lossy().to_string()), - ) - .with_metadata("session_id", serde_json::json!(state.session_id)) - .with_metadata("status", serde_json::json!(state.status.to_string())); - - if let Some(exit_code) = state.exit_code { - result = result.with_metadata("exit_code", serde_json::json!(exit_code)); - } - - Ok(result) - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SubAgentTaskToolInput { - prompt: String, - task_type: Option, - description: Option, - role: Option, - timeout_secs: Option, - model: Option, - return_summary: Option, - allowed_tools: Option>, - denied_tools: Option>, - max_tokens: Option, -} - -fn parse_subagent_role(raw: Option<&str>) -> Result { - let normalized = raw - .map(|value| value.trim().to_ascii_lowercase()) - .unwrap_or_else(|| "executor".to_string()); - - match normalized.as_str() { - "" | "executor" | "execute" | "code" => Ok(SubAgentRole::Executor), - "planner" | "plan" => Ok(SubAgentRole::Planner), - "explorer" | "explore" | "research" => Ok(SubAgentRole::Explorer), - _ => Err(ToolError::invalid_params(format!( - "未知 SubAgent 角色: {},支持 explorer/planner/executor", - normalized - ))), - } -} - -fn default_subagent_task_type(role: SubAgentRole) -> &'static str { - match role { - SubAgentRole::Explorer => "explore", - SubAgentRole::Planner => "plan", - SubAgentRole::Executor => "code", - } -} - -fn build_subagent_task_definition( - input: &SubAgentTaskToolInput, - role: SubAgentRole, -) -> Result { - let prompt = input.prompt.trim(); - if prompt.is_empty() { - return Err(ToolError::invalid_params( - "SubAgentTask.prompt 不能为空".to_string(), - )); - } - - let task_type = input - .task_type - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(default_subagent_task_type(role)); - - let mut task = SubAgentTask::new(uuid::Uuid::new_v4().to_string(), task_type, prompt); - - if let Some(description) = input - .description - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - task = task.with_description(description.to_string()); - } - - if let Some(timeout_secs) = input.timeout_secs.filter(|value| *value > 0) { - task = task.with_timeout(Duration::from_secs(timeout_secs)); - } - - if let Some(model) = input - .model - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - task = task.with_model(model.to_string()); - } - - if let Some(return_summary) = input.return_summary { - task = task.with_summary(return_summary); - } - - if let Some(allowed_tools) = input - .allowed_tools - .as_ref() - .filter(|items| !items.is_empty()) - { - task = task.with_allowed_tools(allowed_tools.clone()); - } - - if let Some(denied_tools) = input - .denied_tools - .as_ref() - .filter(|items| !items.is_empty()) - { - task = task.with_denied_tools(denied_tools.clone()); - } - - if let Some(max_tokens) = input.max_tokens.filter(|value| *value > 0) { - task = task.with_max_tokens(max_tokens); - } - - Ok(task) -} - -fn build_subagent_task_runtime_message( - input: &SubAgentTaskToolInput, - task: &SubAgentTask, - role: SubAgentRole, -) -> String { - let mut sections = Vec::new(); - - if let Some(description) = input - .description - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - 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(|| { - 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() - .map(str::trim) - .filter(|value| !value.is_empty()) - .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()); - - 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 { - runtime: SubagentControlRuntime, -} - -impl SubAgentTaskTool { - fn new(runtime: SubagentControlRuntime) -> Self { - Self { runtime } - } -} - -#[async_trait] -impl Tool for SubAgentTaskTool { - fn name(&self) -> &str { - "SubAgentTask" - } - - 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 { - serde_json::json!({ - "type": "object", - "properties": { - "prompt": { - "type": "string", - "description": "子代理要执行的任务说明" - }, - "taskType": { - "type": "string", - "description": "任务类型,例如 explore、plan、code、review" - }, - "description": { - "type": "string", - "description": "展示给用户的任务标题" - }, - "role": { - "type": "string", - "description": "子代理角色:explorer、planner、executor" - }, - "timeoutSecs": { - "type": "integer", - "minimum": 1, - "description": "单个子任务超时时间(秒)" - }, - "model": { - "type": "string", - "description": "可选模型名" - }, - "returnSummary": { - "type": "boolean", - "description": "是否优先返回摘要" - }, - "allowedTools": { - "type": "array", - "items": { "type": "string" }, - "description": "显式允许的工具列表" - }, - "deniedTools": { - "type": "array", - "items": { "type": "string" }, - "description": "显式拒绝的工具列表" - }, - "maxTokens": { - "type": "integer", - "minimum": 1, - "description": "子代理最大 token 限制" - } - }, - "required": ["prompt"], - "additionalProperties": false - }) - } - - fn options(&self) -> ToolOptions { - ToolOptions::new() - .with_max_retries(0) - .with_base_timeout(Duration::from_secs(900)) - .with_dynamic_timeout(false) - } - - async fn execute( - &self, - params: serde_json::Value, - context: &ToolContext, - ) -> Result { - let input: SubAgentTaskToolInput = serde_json::from_value(params) - .map_err(|err| ToolError::invalid_params(format!("SubAgentTask 参数无效: {err}")))?; - 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 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 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 = 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(), - "status": status, - "timed_out": wait_result.timed_out, - "compat_mode": "subagent_task->spawn_agent", - "compat_warnings": compat_warnings, - }); - - let success = !wait_result.timed_out && status.kind == SubagentRuntimeStatusKind::Completed; - let result = if success { - ToolResult::success(summary) - } else { - 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(), - )) - } -} - -#[derive(Debug, Clone)] -struct LimeBrowserMcpTool { - tool_name: String, - action_name: String, - description: String, - input_schema: serde_json::Value, -} - -impl LimeBrowserMcpTool { - fn new( - tool_name: String, - action_name: String, - description: String, - input_schema: serde_json::Value, - ) -> Self { - Self { - tool_name, - action_name, - description, - input_schema, - } - } - - fn parse_backend(params: &serde_json::Value) -> Option { - let raw = params.get("backend")?.as_str()?.trim().to_ascii_lowercase(); - parse_browser_backend_hint(&raw) - } - - fn supports_cdp_direct_action(action_name: &str) -> bool { - matches!( - action_name.trim().to_ascii_lowercase().as_str(), - "tabs_context_mcp" - | "tabs_create_mcp" - | "navigate" - | "click" - | "type" - | "form_input" - | "scroll" - | "scroll_page" - | "refresh_page" - | "go_back" - | "go_forward" - | "get_page_info" - | "read_page" - | "get_page_text" - | "read_console_messages" - | "read_network_requests" - ) - } - - fn resolve_backend( - action_name: &str, - params: &serde_json::Value, - session_hint: Option<&BrowserAssistRuntimeHint>, - ) -> Option { - if let Some(explicit_backend) = Self::parse_backend(params) { - return Some(explicit_backend); - } - - match session_hint.and_then(|hint| hint.preferred_backend.clone()) { - Some(BrowserBackendType::CdpDirect) - if !Self::supports_cdp_direct_action(action_name) => - { - None - } - other => other, - } - } - - fn extract_profile_key(params: &serde_json::Value, context: &ToolContext) -> Option { - if let Some(value) = params.get("profile_key").and_then(|v| v.as_str()) { - let trimmed = value.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - context - .environment - .get(BROWSER_PROFILE_KEY_ENV_KEYS[0]) - .cloned() - .or_else(|| { - context - .environment - .get(BROWSER_PROFILE_KEY_ENV_KEYS[1]) - .cloned() - }) - } - - fn extract_launch_url(action_name: &str, params: &serde_json::Value) -> Option { - let normalized = action_name.trim().to_ascii_lowercase(); - if normalized == "navigate" - || normalized.ends_with("navigate") - || normalized == "tabs_create_mcp" - || normalized.ends_with("tabs_create_mcp") - || normalized == "open_url" - || normalized.ends_with("open_url") - { - return params - .get("url") - .and_then(|value| value.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - } - None - } -} - -#[async_trait] -impl Tool for LimeBrowserMcpTool { - fn name(&self) -> &str { - &self.tool_name - } - - fn description(&self) -> &str { - &self.description - } - - fn input_schema(&self) -> serde_json::Value { - self.input_schema.clone() - } - - fn options(&self) -> ToolOptions { - ToolOptions::new() - .with_max_retries(1) - .with_base_timeout(Duration::from_secs(90)) - .with_dynamic_timeout(false) - } - - async fn execute( - &self, - params: serde_json::Value, - _context: &ToolContext, - ) -> Result { - let session_hint = get_browser_assist_runtime_hint(&_context.session_id).await; - let backend = Self::resolve_backend(&self.action_name, ¶ms, session_hint.as_ref()); - let profile_key = Self::extract_profile_key(¶ms, _context) - .or_else(|| session_hint.as_ref().map(|hint| hint.profile_key.clone())); - if let (Some(hint), Some(profile_key)) = (session_hint.as_ref(), profile_key.as_ref()) { - if hint.auto_launch { - let launch_url = Self::extract_launch_url(&self.action_name, ¶ms) - .or_else(|| hint.launch_url.clone()); - ensure_managed_chrome_profile_global(profile_key.clone(), launch_url) - .await - .map_err(|error| { - ToolError::execution_failed(format!("自动启动浏览器协助会话失败: {error}")) - })?; - } - } - let timeout_ms = params.get("timeout_ms").and_then(|v| v.as_u64()); - let request = BrowserActionRequest { - profile_key, - backend, - action: self.action_name.clone(), - args: params, - timeout_ms, - }; - - let result = browser_execute_action_global(request) - .await - .map_err(|e| ToolError::execution_failed(format!("浏览器动作执行失败: {e}")))?; - - let payload = serde_json::to_string_pretty(&result) - .unwrap_or_else(|_| format!("{{\"success\": {}}}", result.success)); - let browser_session_metadata = if result.session_id.is_some() { - result - .data - .as_ref() - .and_then(|value| value.get("browser_session")) - .cloned() - .or_else(|| { - Some(serde_json::json!({ - "session_id": result.session_id.clone(), - "target_id": result.target_id.clone(), - })) - }) - } else { - None - }; - - if result.success { - let mut tool_result = ToolResult::success(payload) - .with_metadata("tool_family", serde_json::json!("browser")) - .with_metadata("action", serde_json::json!(self.action_name)) - .with_metadata( - "selected_backend", - serde_json::json!(result.backend.clone()), - ) - .with_metadata("attempt_count", serde_json::json!(result.attempts.len())) - .with_metadata("attempts", serde_json::json!(result.attempts.clone())) - .with_metadata("result", serde_json::json!(result.clone())); - if let Some(browser_session) = browser_session_metadata { - tool_result = tool_result.with_metadata("browser_session", browser_session); - } - Ok(tool_result) - } else { - let mut tool_result = ToolResult::error( - result - .error - .clone() - .unwrap_or_else(|| "浏览器动作执行失败".to_string()), - ) - .with_metadata("tool_family", serde_json::json!("browser")) - .with_metadata("action", serde_json::json!(self.action_name)) - .with_metadata( - "selected_backend", - serde_json::json!(result.backend.clone()), - ) - .with_metadata("attempts", serde_json::json!(result.attempts.clone())) - .with_metadata("result", serde_json::json!(result.clone())); - if let Some(browser_session) = browser_session_metadata { - tool_result = tool_result.with_metadata("browser_session", browser_session); - } - Ok(tool_result) - } - } -} - -#[derive(Clone)] -struct SocialGenerateCoverImageTool { - config_manager: Arc, - client: reqwest::Client, -} - -impl SocialGenerateCoverImageTool { - fn new(config_manager: Arc) -> Self { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(180)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - Self { - config_manager, - client, - } - } - - fn normalize_server_host(host: &str) -> String { - let trimmed = host.trim(); - if trimmed.is_empty() || trimmed == "0.0.0.0" || trimmed == "::" { - return "127.0.0.1".to_string(); - } - if trimmed.starts_with('[') && trimmed.ends_with(']') { - return trimmed.to_string(); - } - if trimmed.contains(':') { - return format!("[{trimmed}]"); - } - trimmed.to_string() - } - - fn parse_non_empty_string( - params: &serde_json::Value, - key: &str, - default: Option<&str>, - ) -> Option { - if let Some(value) = params.get(key).and_then(|v| v.as_str()) { - let trimmed = value.trim(); - if !trimmed.is_empty() { - return Some(trimmed.to_string()); - } - } - default.map(ToString::to_string) - } - - fn extract_first_image_payload( - response_body: &serde_json::Value, - ) -> Result<(Option, Option, Option), String> { - let data = response_body - .get("data") - .and_then(|v| v.as_array()) - .ok_or_else(|| "图像接口返回缺少 data 字段".to_string())?; - - let first = data - .first() - .ok_or_else(|| "图像接口返回 data 为空".to_string())?; - - let image_url = first - .get("url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let image_b64 = first - .get("b64_json") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let revised_prompt = first - .get("revised_prompt") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - Ok((image_url, image_b64, revised_prompt)) - } -} - -#[async_trait] -impl Tool for SocialGenerateCoverImageTool { - fn name(&self) -> &str { - SOCIAL_IMAGE_TOOL_NAME - } - - fn description(&self) -> &str { - "为社媒文章生成封面图,内部复用 Lime 的 /v1/images/generations 能力。" - } - - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "prompt": { - "type": "string", - "description": "图片描述词,建议包含主体、风格、氛围、构图。" - }, - "model": { - "type": "string", - "description": "可选模型名;不传则使用默认图像模型。" - }, - "size": { - "type": "string", - "description": "图片尺寸,例如 1024x1024、1024x1792。" - }, - "response_format": { - "type": "string", - "enum": ["url", "b64_json"], - "description": "返回格式,默认 url。" - } - }, - "required": ["prompt"], - "additionalProperties": false, - "x-lime": { - "always_visible": true, - "tags": ["image", "social-media", "cover"], - "allowed_callers": ["assistant", "skill"], - "input_examples": [ - { - "prompt": "科技感蓝紫渐变背景,一位年轻创作者在笔记本前沉思,暖色轮廓光,简洁社媒封面风格", - "size": "1024x1024" - } - ] - } - }) - } - - fn options(&self) -> ToolOptions { - ToolOptions::new() - .with_max_retries(1) - .with_base_timeout(Duration::from_secs(180)) - .with_dynamic_timeout(false) - } - - async fn execute( - &self, - params: serde_json::Value, - _context: &ToolContext, - ) -> Result { - let prompt = Self::parse_non_empty_string(¶ms, "prompt", None).ok_or_else(|| { - ToolError::invalid_params("参数 prompt 必填,且不能为空字符串".to_string()) - })?; - - let runtime_config = self.config_manager.config(); - let model = - Self::parse_non_empty_string(¶ms, "model", Some(SOCIAL_IMAGE_DEFAULT_MODEL)) - .unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_MODEL.to_string()); - let size = Self::parse_non_empty_string( - ¶ms, - "size", - runtime_config.image_gen.default_size.as_deref(), - ) - .unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_SIZE.to_string()); - let response_format = Self::parse_non_empty_string( - ¶ms, - "response_format", - Some(SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT), - ) - .unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT.to_string()); - - if response_format != "url" && response_format != "b64_json" { - return Err(ToolError::invalid_params( - "response_format 仅支持 url 或 b64_json".to_string(), - )); - } - - let server_host = Self::normalize_server_host(&runtime_config.server.host); - let endpoint = format!( - "http://{}:{}/v1/images/generations", - server_host, runtime_config.server.port - ); - let request_body = serde_json::json!({ - "prompt": prompt, - "model": model, - "n": 1, - "size": size, - "response_format": response_format - }); - - let response = self - .client - .post(&endpoint) - .header( - "Authorization", - format!("Bearer {}", runtime_config.server.api_key), - ) - .json(&request_body) - .send() - .await - .map_err(|e| ToolError::execution_failed(format!("调用图像接口失败: {e}")))?; - - let status = response.status(); - let response_body: serde_json::Value = response - .json() - .await - .map_err(|e| ToolError::execution_failed(format!("图像接口响应解析失败: {e}")))?; - - if !status.is_success() { - let error_message = response_body - .get("error") - .and_then(|v| v.get("message")) - .and_then(|v| v.as_str()) - .unwrap_or("图像生成失败") - .to_string(); - let error_code = response_body - .get("error") - .and_then(|v| v.get("code")) - .and_then(|v| v.as_str()) - .unwrap_or("image_generation_failed") - .to_string(); - let result_payload = serde_json::json!({ - "success": false, - "error_code": error_code, - "error_message": error_message, - "status": status.as_u16(), - "retryable": status.is_server_error() || status.as_u16() == 429 - }); - return Ok(ToolResult::error(result_payload.to_string()) - .with_metadata("result", result_payload)); - } - - let (image_url, image_b64, revised_prompt) = - Self::extract_first_image_payload(&response_body) - .map_err(ToolError::execution_failed)?; - - if image_url.is_none() && image_b64.is_none() { - return Err(ToolError::execution_failed( - "图像接口返回中未找到 url 或 b64_json".to_string(), - )); - } - - let result_payload = serde_json::json!({ - "success": true, - "image_url": image_url, - "b64_json": image_b64, - "revised_prompt": revised_prompt, - "model": request_body.get("model").cloned(), - "size": request_body.get("size").cloned(), - "response_format": request_body.get("response_format").cloned() - }); - let output = serde_json::to_string_pretty(&result_payload) - .unwrap_or_else(|_| result_payload.to_string()); - Ok(ToolResult::success(output).with_metadata("result", result_payload)) - } -} - -fn is_safe_relative_path(path: &Path) -> bool { - if path.is_absolute() { - return false; - } - !path.components().any(|component| { - matches!( - component, - std::path::Component::ParentDir - | std::path::Component::RootDir - | std::path::Component::Prefix(_) - ) - }) -} - -fn resolve_output_relative_path( - task_type: &str, - output_path: Option<&str>, -) -> Result { - if let Some(raw) = output_path { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(ToolError::invalid_params( - "outputPath 不能为空字符串".to_string(), - )); - } - let candidate = PathBuf::from(trimmed); - if !is_safe_relative_path(&candidate) { - return Err(ToolError::invalid_params( - "outputPath 必须是安全的相对路径,且不能包含 '..'".to_string(), - )); - } - return Ok(candidate); - } - - let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string(); - let suffix = uuid::Uuid::new_v4().simple().to_string(); - Ok(PathBuf::from(".lime") - .join("tasks") - .join(task_type) - .join(format!("{timestamp}-{suffix}.json"))) -} - -fn submit_creation_task_record( - app_handle: &AppHandle, - context: &ToolContext, - task_type: &str, - title: Option, - payload: serde_json::Value, - output_path: Option<&str>, -) -> Result { - let output_rel_path = resolve_output_relative_path(task_type, output_path)?; - let output_abs_path = context.working_directory.join(&output_rel_path); - - let parent = output_abs_path - .parent() - .ok_or_else(|| ToolError::execution_failed("无法解析任务文件父目录".to_string()))?; - std::fs::create_dir_all(parent) - .map_err(|error| ToolError::execution_failed(format!("创建任务目录失败: {error}")))?; - - let task_id = uuid::Uuid::new_v4().to_string(); - let task_record = serde_json::json!({ - "task_id": task_id, - "task_type": task_type, - "title": title, - "payload": payload, - "status": "pending_submit", - "created_at": chrono::Utc::now().to_rfc3339() - }); - let task_content = - serde_json::to_string_pretty(&task_record).unwrap_or_else(|_| task_record.to_string()); - - std::fs::write(&output_abs_path, task_content.as_bytes()) - .map_err(|error| ToolError::execution_failed(format!("写入任务文件失败: {error}")))?; - - let emitted_payload = serde_json::json!({ - "task_id": task_id, - "task_type": task_type, - "path": output_rel_path.to_string_lossy().to_string(), - "absolute_path": output_abs_path.to_string_lossy().to_string() - }); - if let Err(error) = app_handle.emit("lime://creation_task_submitted", &emitted_payload) { - tracing::warn!( - "[AsterAgent] creation_task_submitted 事件发送失败: {}", - error - ); - } - - let output_payload = serde_json::json!({ - "success": true, - "task_id": task_id, - "task_type": task_type, - "path": output_rel_path.to_string_lossy().to_string(), - "absolute_path": output_abs_path.to_string_lossy().to_string(), - "record": task_record - }); - let output = serde_json::to_string_pretty(&output_payload) - .unwrap_or_else(|_| output_payload.to_string()); - Ok(ToolResult::success(output) - .with_metadata("task_id", serde_json::json!(task_id)) - .with_metadata("task_type", serde_json::json!(task_type)) - .with_metadata("path", serde_json::json!(output_abs_path.to_string_lossy()))) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct BroadcastTaskInput { - content: String, - #[serde(default)] - title: Option, - #[serde(default)] - audience: Option, - #[serde(default)] - tone: Option, - #[serde(default)] - duration_hint_minutes: Option, - #[serde(default)] - output_path: Option, -} - -#[derive(Clone)] -struct LimeCreateBroadcastTaskTool { - app_handle: AppHandle, -} - -impl LimeCreateBroadcastTaskTool { - fn new(app_handle: AppHandle) -> Self { - Self { app_handle } - } -} - -#[async_trait] -impl Tool for LimeCreateBroadcastTaskTool { - fn name(&self) -> &str { - LIME_CREATE_BROADCAST_TASK_TOOL_NAME - } - - fn description(&self) -> &str { - "创建播客内容整理任务(broadcast_generate)。" - } - - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "content": { "type": "string", "description": "可播报正文内容。" }, - "title": { "type": "string", "description": "任务标题(可选)。" }, - "audience": { "type": "string", "description": "目标听众(可选)。" }, - "tone": { "type": "string", "description": "语气风格(可选)。" }, - "durationHintMinutes": { "type": "integer", "minimum": 1, "maximum": 180, "description": "建议时长(分钟,可选)。" }, - "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } - }, - "required": ["content"], - "additionalProperties": false, - "x-lime": { - "always_visible": true, - "tags": ["broadcast", "task", "creation"], - "allowed_callers": ["assistant", "skill"] - } - }) - } - - async fn execute( - &self, - params: serde_json::Value, - context: &ToolContext, - ) -> Result { - let input: BroadcastTaskInput = serde_json::from_value(params) - .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; - if input.content.trim().is_empty() { - return Err(ToolError::invalid_params( - "content 不能为空字符串".to_string(), - )); - } - let payload = serde_json::json!({ - "content": input.content, - "audience": input.audience, - "tone": input.tone, - "durationHintMinutes": input.duration_hint_minutes - }); - submit_creation_task_record( - &self.app_handle, - context, - "broadcast_generate", - input.title, - payload, - input.output_path.as_deref(), - ) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CoverTaskInput { - prompt: String, - #[serde(default)] - title: Option, - #[serde(default)] - platform: Option, - #[serde(default)] - size: Option, - #[serde(default)] - image_url: Option, - #[serde(default)] - status: Option, - #[serde(default)] - remark: Option, - #[serde(default)] - output_path: Option, -} - -#[derive(Clone)] -struct LimeCreateCoverTaskTool { - app_handle: AppHandle, -} - -impl LimeCreateCoverTaskTool { - fn new(app_handle: AppHandle) -> Self { - Self { app_handle } - } -} - -#[async_trait] -impl Tool for LimeCreateCoverTaskTool { - fn name(&self) -> &str { - LIME_CREATE_COVER_TASK_TOOL_NAME - } - - fn description(&self) -> &str { - "创建封面生成任务记录(cover_generate)。" - } - - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "prompt": { "type": "string", "description": "封面提示词。" }, - "title": { "type": "string", "description": "任务标题(可选)。" }, - "platform": { "type": "string", "description": "目标平台(可选)。" }, - "size": { "type": "string", "description": "尺寸(可选)。" }, - "imageUrl": { "type": "string", "description": "生成后的封面 URL(可选)。" }, - "status": { "type": "string", "description": "状态(成功/失败,可选)。" }, - "remark": { "type": "string", "description": "备注(可选)。" }, - "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } - }, - "required": ["prompt"], - "additionalProperties": false, - "x-lime": { - "always_visible": true, - "tags": ["cover", "image", "task"], - "allowed_callers": ["assistant", "skill"] - } - }) - } - - async fn execute( - &self, - params: serde_json::Value, - context: &ToolContext, - ) -> Result { - let input: CoverTaskInput = serde_json::from_value(params) - .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; - if input.prompt.trim().is_empty() { - return Err(ToolError::invalid_params( - "prompt 不能为空字符串".to_string(), - )); - } - let payload = serde_json::json!({ - "prompt": input.prompt, - "platform": input.platform, - "size": input.size, - "imageUrl": input.image_url, - "status": input.status, - "remark": input.remark - }); - submit_creation_task_record( - &self.app_handle, - context, - "cover_generate", - input.title, - payload, - input.output_path.as_deref(), - ) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ResourceSearchTaskInput { - resource_type: String, - query: String, - #[serde(default)] - title: Option, - #[serde(default)] - usage: Option, - #[serde(default)] - count: Option, - #[serde(default)] - filters: Option, - #[serde(default)] - output_path: Option, -} - -#[derive(Clone)] -struct LimeCreateResourceSearchTaskTool { - app_handle: AppHandle, -} - -impl LimeCreateResourceSearchTaskTool { - fn new(app_handle: AppHandle) -> Self { - Self { app_handle } - } -} - -#[async_trait] -impl Tool for LimeCreateResourceSearchTaskTool { - fn name(&self) -> &str { - LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME - } - - fn description(&self) -> &str { - "创建资源检索任务(modal_resource_search)。" - } - - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "resourceType": { "type": "string", "description": "资源类型,例如 image/bgm/sfx。" }, - "query": { "type": "string", "description": "检索关键词。" }, - "title": { "type": "string", "description": "任务标题(可选)。" }, - "usage": { "type": "string", "description": "用途说明(可选)。" }, - "count": { "type": "integer", "minimum": 1, "maximum": 50, "description": "候选数量(可选)。" }, - "filters": { "type": "object", "description": "过滤条件(可选)。" }, - "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } - }, - "required": ["resourceType", "query"], - "additionalProperties": false, - "x-lime": { - "always_visible": true, - "tags": ["resource", "search", "task"], - "allowed_callers": ["assistant", "skill"] - } - }) - } - - async fn execute( - &self, - params: serde_json::Value, - context: &ToolContext, - ) -> Result { - let input: ResourceSearchTaskInput = serde_json::from_value(params) - .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; - if input.resource_type.trim().is_empty() || input.query.trim().is_empty() { - return Err(ToolError::invalid_params( - "resourceType/query 不能为空字符串".to_string(), - )); - } - let payload = serde_json::json!({ - "resourceType": input.resource_type, - "query": input.query, - "usage": input.usage, - "count": input.count, - "filters": input.filters - }); - submit_creation_task_record( - &self.app_handle, - context, - "modal_resource_search", - input.title, - payload, - input.output_path.as_deref(), - ) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ImageTaskInput { - prompt: String, - #[serde(default)] - title: Option, - #[serde(default)] - style: Option, - #[serde(default)] - size: Option, - #[serde(default)] - count: Option, - #[serde(default)] - usage: Option, - #[serde(default)] - output_path: Option, -} - -#[derive(Clone)] -struct LimeCreateImageTaskTool { - app_handle: AppHandle, -} - -impl LimeCreateImageTaskTool { - fn new(app_handle: AppHandle) -> Self { - Self { app_handle } - } -} - -#[async_trait] -impl Tool for LimeCreateImageTaskTool { - fn name(&self) -> &str { - LIME_CREATE_IMAGE_TASK_TOOL_NAME - } - - fn description(&self) -> &str { - "创建图片生成任务(image_generate)。" - } - - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "prompt": { "type": "string", "description": "图像提示词。" }, - "title": { "type": "string", "description": "任务标题(可选)。" }, - "style": { "type": "string", "description": "风格(可选)。" }, - "size": { "type": "string", "description": "尺寸(可选)。" }, - "count": { "type": "integer", "minimum": 1, "maximum": 20, "description": "生成数量(可选)。" }, - "usage": { "type": "string", "description": "用途(可选)。" }, - "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } - }, - "required": ["prompt"], - "additionalProperties": false, - "x-lime": { - "always_visible": true, - "tags": ["image", "task", "generation"], - "allowed_callers": ["assistant", "skill"] - } - }) - } - - async fn execute( - &self, - params: serde_json::Value, - context: &ToolContext, - ) -> Result { - let input: ImageTaskInput = serde_json::from_value(params) - .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; - if input.prompt.trim().is_empty() { - return Err(ToolError::invalid_params( - "prompt 不能为空字符串".to_string(), - )); - } - let payload = serde_json::json!({ - "prompt": input.prompt, - "style": input.style, - "size": input.size, - "count": input.count, - "usage": input.usage - }); - submit_creation_task_record( - &self.app_handle, - context, - "image_generate", - input.title, - payload, - input.output_path.as_deref(), - ) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct UrlParseTaskInput { - url: String, - #[serde(default)] - title: Option, - #[serde(default)] - summary: Option, - #[serde(default)] - key_points: Option>, - #[serde(default)] - extract_status: Option, - #[serde(default)] - output_path: Option, -} - -#[derive(Clone)] -struct LimeCreateUrlParseTaskTool { - app_handle: AppHandle, -} - -impl LimeCreateUrlParseTaskTool { - fn new(app_handle: AppHandle) -> Self { - Self { app_handle } - } -} - -#[async_trait] -impl Tool for LimeCreateUrlParseTaskTool { - fn name(&self) -> &str { - LIME_CREATE_URL_PARSE_TASK_TOOL_NAME - } - - fn description(&self) -> &str { - "创建链接解析任务(url_parse)。" - } - - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "url": { "type": "string", "description": "目标 URL。" }, - "title": { "type": "string", "description": "任务标题(可选)。" }, - "summary": { "type": "string", "description": "摘要(可选)。" }, - "keyPoints": { "type": "array", "items": { "type": "string" }, "description": "关键要点(可选)。" }, - "extractStatus": { "type": "string", "description": "提取状态(可选)。" }, - "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } - }, - "required": ["url"], - "additionalProperties": false, - "x-lime": { - "always_visible": true, - "tags": ["url", "parse", "task"], - "allowed_callers": ["assistant", "skill"] - } - }) - } - - async fn execute( - &self, - params: serde_json::Value, - context: &ToolContext, - ) -> Result { - let input: UrlParseTaskInput = serde_json::from_value(params) - .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; - if input.url.trim().is_empty() { - return Err(ToolError::invalid_params("url 不能为空字符串".to_string())); - } - let payload = serde_json::json!({ - "url": input.url, - "summary": input.summary, - "keyPoints": input.key_points, - "extractStatus": input.extract_status - }); - submit_creation_task_record( - &self.app_handle, - context, - "url_parse", - input.title, - payload, - input.output_path.as_deref(), - ) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct TypesettingTaskInput { - content: String, - #[serde(default)] - title: Option, - #[serde(default)] - target_platform: Option, - #[serde(default)] - rules: Option, - #[serde(default)] - output_path: Option, -} - -#[derive(Clone)] -struct LimeCreateTypesettingTaskTool { - app_handle: AppHandle, -} - -impl LimeCreateTypesettingTaskTool { - fn new(app_handle: AppHandle) -> Self { - Self { app_handle } - } -} - -#[async_trait] -impl Tool for LimeCreateTypesettingTaskTool { - fn name(&self) -> &str { - LIME_CREATE_TYPESETTING_TASK_TOOL_NAME - } - - fn description(&self) -> &str { - "创建排版优化任务(typesetting)。" - } - - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "content": { "type": "string", "description": "待排版内容。" }, - "title": { "type": "string", "description": "任务标题(可选)。" }, - "targetPlatform": { "type": "string", "description": "目标平台(可选)。" }, - "rules": { "type": "object", "description": "排版规则(可选)。" }, - "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } - }, - "required": ["content"], - "additionalProperties": false, - "x-lime": { - "always_visible": true, - "tags": ["typesetting", "task", "text"], - "allowed_callers": ["assistant", "skill"] - } - }) - } - - async fn execute( - &self, - params: serde_json::Value, - context: &ToolContext, - ) -> Result { - let input: TypesettingTaskInput = serde_json::from_value(params) - .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; - if input.content.trim().is_empty() { - return Err(ToolError::invalid_params( - "content 不能为空字符串".to_string(), - )); - } - let payload = serde_json::json!({ - "content": input.content, - "targetPlatform": input.target_platform, - "rules": input.rules - }); - submit_creation_task_record( - &self.app_handle, - context, - "typesetting", - input.title, - payload, - input.output_path.as_deref(), - ) - } -} - -#[derive(Clone)] -struct LimeCreateVideoGenerationTaskTool { - db: DbConnection, - api_key_provider_service: Arc, -} - -impl LimeCreateVideoGenerationTaskTool { - fn new(db: DbConnection, api_key_provider_service: Arc) -> Self { - Self { - db, - api_key_provider_service, - } - } -} - -#[async_trait] -impl Tool for LimeCreateVideoGenerationTaskTool { - fn name(&self) -> &str { - LIME_CREATE_VIDEO_TASK_TOOL_NAME - } - - fn description(&self) -> &str { - "调用 Lime 视频任务服务,创建真实的视频生成任务。" - } - - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "projectId": { "type": "string", "description": "项目 ID。" }, - "providerId": { "type": "string", "description": "视频服务 Provider ID。" }, - "model": { "type": "string", "description": "模型名。" }, - "prompt": { "type": "string", "description": "视频生成提示词。" }, - "aspectRatio": { "type": "string", "description": "画幅比例,例如 16:9、9:16。" }, - "resolution": { "type": "string", "description": "分辨率,例如 720p。" }, - "duration": { "type": "integer", "description": "时长(秒)。" }, - "imageUrl": { "type": "string", "description": "首帧图 URL(可选)。" }, - "endImageUrl": { "type": "string", "description": "末帧图 URL(可选)。" }, - "seed": { "type": "integer", "description": "随机种子(可选)。" }, - "generateAudio": { "type": "boolean", "description": "是否生成音频(可选)。" }, - "cameraFixed": { "type": "boolean", "description": "是否固定镜头(可选)。" } - }, - "required": ["projectId", "providerId", "model", "prompt"], - "additionalProperties": false, - "x-lime": { - "always_visible": true, - "tags": ["video", "task", "generation"], - "allowed_callers": ["assistant", "skill"], - "input_examples": [ - { - "projectId": "project-demo", - "providerId": "volcengine", - "model": "doubao-seedance-1-0-pro-250528", - "prompt": "未来城市清晨,镜头缓慢推进,电影感", - "aspectRatio": "16:9", - "duration": 5 - } - ] - } - }) - } - - async fn execute( - &self, - params: serde_json::Value, - _context: &ToolContext, - ) -> Result { - let request: CreateVideoGenerationRequest = serde_json::from_value(params) - .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; - if request.project_id.trim().is_empty() - || request.provider_id.trim().is_empty() - || request.model.trim().is_empty() - || request.prompt.trim().is_empty() - { - return Err(ToolError::invalid_params( - "projectId/providerId/model/prompt 均不能为空".to_string(), - )); - } - - let service = VideoGenerationService::new(); - let created = service - .create_task(&self.db, self.api_key_provider_service.as_ref(), request) - .await - .map_err(|error| ToolError::execution_failed(format!("创建视频任务失败: {error}")))?; - - let payload = serde_json::json!({ - "success": true, - "task": created - }); - let output = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string()); - Ok(ToolResult::success(output)) - } -} - -struct ToolSearchBridgeTool { - registry: Arc>, - extension_manager: Option>, -} - -impl ToolSearchBridgeTool { - fn new( - registry: Arc>, - extension_manager: Option>, - ) -> Self { - Self { - registry, - extension_manager, - } - } - - fn with_input_examples_in_schema( - schema: &serde_json::Value, - input_examples: &[serde_json::Value], - ) -> serde_json::Value { - if input_examples.is_empty() { - return schema.clone(); - } - - let mut enriched = schema.clone(); - let Some(root) = enriched.as_object_mut() else { - return schema.clone(); - }; - let extension = root - .entry("x-lime".to_string()) - .or_insert_with(|| serde_json::json!({})); - let Some(extension_obj) = extension.as_object_mut() else { - return schema.clone(); - }; - if extension_obj.get("input_examples").is_none() - && extension_obj.get("inputExamples").is_none() - { - extension_obj.insert( - "input_examples".to_string(), - serde_json::Value::Array(input_examples.to_vec()), - ); - } - enriched - } - - #[cfg(test)] - fn parse_schema_metadata( - tool_name: &str, - schema: &serde_json::Value, - ) -> ( - bool, // deferred_loading - bool, // always_visible - Vec, // allowed_callers - Vec, // tags - Vec, // input_examples - ) { - let metadata = lime_core::tool_calling::extract_tool_surface_metadata(tool_name, schema); - - ( - 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 { - lime_core::tool_calling::score_tool_match(name, description, tags, query) - } - - 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_NAME - } - - fn description(&self) -> &str { - "统一搜索当前会话工具面:包含原生 registry 工具与 extension/MCP 工具。对 deferred 工具会返回加载提示。" - } - - fn input_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "query": { "type": "string", "description": "工具名称/描述关键词" }, - "caller": { "type": "string", "description": "调用方,例如 assistant/code_execution" }, - "limit": { "type": "integer", "minimum": 1, "maximum": 100 }, - "include_deferred": { "type": "boolean", "description": "是否包含延迟加载工具" }, - "include_schema": { "type": "boolean", "description": "是否返回完整输入 schema" } - }, - "required": [] - }) - } - - fn options(&self) -> ToolOptions { - ToolOptions::new() - .with_max_retries(1) - .with_base_timeout(Duration::from_secs(15)) - .with_dynamic_timeout(false) - } - - async fn execute( - &self, - params: serde_json::Value, - _context: &ToolContext, - ) -> Result { - let query = params - .get("query") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_ascii_lowercase(); - let caller = params - .get("caller") - .and_then(|v| v.as_str()) - .unwrap_or("assistant") - .trim() - .to_ascii_lowercase(); - let include_deferred = params - .get("include_deferred") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let include_schema = params - .get("include_schema") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let limit = params - .get("limit") - .and_then(|v| v.as_u64()) - .map(|v| v.clamp(1, 100) as usize) - .unwrap_or(10); - - let registry = self.registry.read().await; - let definitions = registry.get_definitions(); - - let mut scored = definitions - .into_iter() - .filter(|d| d.name != self.name()) - .filter_map(|definition| { - 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 !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 { - return None; - } - - let item = if include_schema { - let enriched_schema = Self::with_input_examples_in_schema( - &definition.input_schema, - &input_examples, - ); - serde_json::json!({ - "source": "native_registry", - "name": definition.name, - "description": definition.description, - "input_schema": enriched_schema, - "deferred_loading": deferred_loading, - "always_visible": always_visible, - "allowed_callers": allowed_callers, - "input_examples": input_examples, - "tags": tags - }) - } else { - serde_json::json!({ - "source": "native_registry", - "name": definition.name, - "description": definition.description, - "deferred_loading": deferred_loading, - "always_visible": always_visible, - "allowed_callers": allowed_callers, - "input_examples": input_examples, - "tags": tags - }) - }; - Some((score, item)) - }) - .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"] - .as_str() - .unwrap_or_default() - .cmp(b_item["name"].as_str().unwrap_or_default()) - }) - }); - - let result = scored - .into_iter() - .take(limit) - .map(|(_, item)| item) - .collect::>(); - let text = serde_json::to_string_pretty(&serde_json::json!({ - "query": query, - "caller": caller, - "count": result.len(), - "tools": result - })) - .map_err(|e| ToolError::execution_failed(format!("tool_search 序列化失败: {e}")))?; - - Ok(ToolResult::success(text)) - } -} - -fn browser_mcp_tool_names() -> Vec { - let mut names = Vec::new(); - for tool in get_chrome_mcp_tools() { - names.push(format!("{}{}", browser_runtime_tool_prefix(), tool.name)); - } - names -} - -fn register_browser_mcp_tools_to_registry(registry: &mut aster::tools::ToolRegistry) { - let tool_defs = get_chrome_mcp_tools(); - for tool_def in tool_defs { - for prefix in ["mcp__lime-browser__"] { - let full_name = format!("{prefix}{}", tool_def.name); - if registry.contains(&full_name) { - continue; - } - let tool = LimeBrowserMcpTool::new( - full_name, - tool_def.name.clone(), - tool_def.description.clone(), - tool_def.input_schema.clone(), - ); - registry.register(Box::new(tool)); - } - } -} - -fn register_social_image_tool_to_registry( - registry: &mut aster::tools::ToolRegistry, - config_manager: Arc, -) { - if registry.contains(SOCIAL_IMAGE_TOOL_NAME) { - return; - } - registry.register(Box::new(SocialGenerateCoverImageTool::new(config_manager))); -} - -fn register_creation_task_tools_to_registry( - registry: &mut aster::tools::ToolRegistry, - db: DbConnection, - api_key_provider_service: Arc, - app_handle: AppHandle, -) { - if !registry.contains(LIME_CREATE_VIDEO_TASK_TOOL_NAME) { - registry.register(Box::new(LimeCreateVideoGenerationTaskTool::new( - db.clone(), - api_key_provider_service.clone(), - ))); - } - if !registry.contains(LIME_CREATE_BROADCAST_TASK_TOOL_NAME) { - registry.register(Box::new(LimeCreateBroadcastTaskTool::new( - app_handle.clone(), - ))); - } - if !registry.contains(LIME_CREATE_COVER_TASK_TOOL_NAME) { - registry.register(Box::new(LimeCreateCoverTaskTool::new(app_handle.clone()))); - } - if !registry.contains(LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME) { - registry.register(Box::new(LimeCreateResourceSearchTaskTool::new( - app_handle.clone(), - ))); - } - if !registry.contains(LIME_CREATE_IMAGE_TASK_TOOL_NAME) { - registry.register(Box::new(LimeCreateImageTaskTool::new(app_handle.clone()))); - } - if !registry.contains(LIME_CREATE_URL_PARSE_TASK_TOOL_NAME) { - registry.register(Box::new(LimeCreateUrlParseTaskTool::new( - app_handle.clone(), - ))); - } - if !registry.contains(LIME_CREATE_TYPESETTING_TASK_TOOL_NAME) { - registry.register(Box::new(LimeCreateTypesettingTaskTool::new(app_handle))); - } -} - -fn register_tool_search_tool_to_registry( - registry: &mut aster::tools::ToolRegistry, - registry_arc: Arc>, - extension_manager: Option>, -) { - if registry.contains(TOOL_SEARCH_TOOL_NAME) { - return; - } - registry.register(Box::new(ToolSearchBridgeTool::new( - registry_arc, - extension_manager, - ))); -} - -pub async fn ensure_browser_mcp_tools_registered(state: &AsterAgentState) -> Result<(), String> { - let agent_arc = state.get_agent_arc(); - let guard = agent_arc.read().await; - let agent = guard - .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(), - Some(extension_manager), - ); - Ok(()) -} - -pub async fn ensure_social_image_tool_registered( - state: &AsterAgentState, - config_manager: &GlobalConfigManagerState, -) -> Result<(), String> { - let agent_arc = state.get_agent_arc(); - let guard = agent_arc.read().await; - let agent = guard - .as_ref() - .ok_or_else(|| "Agent not initialized".to_string())?; - let registry_arc = agent.tool_registry().clone(); - drop(guard); - - let mut registry = registry_arc.write().await; - register_social_image_tool_to_registry(&mut registry, config_manager.0.clone()); - Ok(()) -} - -pub async fn ensure_creation_task_tools_registered( - state: &AsterAgentState, - db: &DbConnection, - api_key_provider_service: &ApiKeyProviderServiceState, - app_handle: &AppHandle, -) -> Result<(), String> { - let agent_arc = state.get_agent_arc(); - let guard = agent_arc.read().await; - let agent = guard - .as_ref() - .ok_or_else(|| "Agent not initialized".to_string())?; - let registry_arc = agent.tool_registry().clone(); - drop(guard); - - let mut registry = registry_arc.write().await; - register_creation_task_tools_to_registry( - &mut registry, - db.clone(), - api_key_provider_service.0.clone(), - app_handle.clone(), - ); - Ok(()) -} - -pub async fn ensure_tool_search_tool_registered(state: &AsterAgentState) -> Result<(), String> { - let agent_arc = state.get_agent_arc(); - let guard = agent_arc.read().await; - let agent = guard - .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(), - Some(extension_manager), - ); - Ok(()) -} - -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); - } - - 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 权限模板 -async fn apply_workspace_sandbox_permissions( - state: &AsterAgentState, - config_manager: &GlobalConfigManagerState, - db: &DbConnection, - api_key_provider_service: &ApiKeyProviderServiceState, - 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(); - if workspace_root.is_empty() { - return Err("workspace 根目录为空".to_string()); - } - - 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, - 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); - WorkspaceSandboxApplyOutcome::Applied { sandbox_type } - } - Err(reason) => { - if sandbox_policy.strict { - return Err(format!( - "workspace 本地 sandbox 严格模式已启用,初始化失败: {reason}" - )); - } - WorkspaceSandboxApplyOutcome::UnavailableFallback { - warning_message: build_workspace_sandbox_warning_message(&reason), - notify_user: sandbox_policy.notify_on_fallback, - } - } - } - }; - - let mut permissions = - build_workspace_execution_permissions(WorkspaceExecutionPermissionInput { - surface: tool_surface, - workspace_root, - auto_mode, - execution_policy_input, - }); - - 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); - - let agent_arc = state.get_agent_arc(); - let guard = agent_arc.read().await; - let agent = guard - .as_ref() - .ok_or_else(|| "Agent not initialized".to_string())?; - let registry_arc = agent.tool_registry().clone(); - drop(guard); - - let mut registry = registry_arc.write().await; - let mut permission_manager = ToolPermissionManager::new(None); - for permission in permissions { - permission_manager.add_permission(permission, PermissionScope::Session); - } - registry.set_permission_manager(Arc::new(permission_manager)); - - let task_manager = shared_task_manager(); - registry.register(Box::new(WorkspaceTaskTool::new( - should_auto_approve_tool_warnings("Task", auto_mode, execution_policy_input), - task_manager.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))); - - if let Some(workspace_bash_tool) = sandboxed_bash_tool { - registry.register(Box::new(workspace_bash_tool)); - } - - 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(), - ); - wrap_registry_native_tools_for_durable_memory_fs(&mut registry); - wrap_registry_native_tools_for_harness_observability(&mut registry); - - Ok(apply_outcome) -} - -/// 图片输入 -#[allow(dead_code)] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ImageInput { - pub data: String, - pub media_type: String, -} - -/// 执行单个 turn 的流式响应 -async fn execute_aster_chat_request( - app: &AppHandle, - state: &AsterAgentState, - db: &DbConnection, - api_key_provider_service: &ApiKeyProviderServiceState, - logs: &LogState, - config_manager: &GlobalConfigManagerState, - mcp_manager: &McpManagerState, - automation_state: &AutomationServiceState, - request: AsterChatRequest, -) -> Result<(), String> { - tracing::info!( - "[AsterAgent] 发送流式消息: session={}, event={}", - request.session_id, - request.event_name - ); - - // 确保 Agent 已初始化(使用带数据库的版本,注入 SessionStore) - let is_init = state.is_initialized().await; - tracing::warn!("[AsterAgent] Agent 初始化状态: {}", is_init); - if !is_init { - tracing::warn!("[AsterAgent] Agent 未初始化,开始初始化..."); - state.init_agent_with_db(db).await?; - tracing::warn!("[AsterAgent] Agent 初始化完成"); - } else { - tracing::warn!("[AsterAgent] Agent 已初始化,检查 session_store..."); - // 检查 session_store 是否存在 - let agent_arc = state.get_agent_arc(); - let guard = agent_arc.read().await; - if let Some(agent) = guard.as_ref() { - let has_store = agent.session_store().is_some(); - tracing::warn!("[AsterAgent] session_store 存在: {}", has_store); - } - } - ensure_tool_search_tool_registered(state).await?; - - // 直接使用前端传递的 session_id - // LimeSessionStore 会在 add_message 时自动创建不存在的 session - // 同时 get_session 也会自动创建不存在的 session - let session_id = &request.session_id; - - let workspace_id = request.workspace_id.trim().to_string(); - if workspace_id.is_empty() { - let message = "workspace_id 必填,请先选择项目工作区".to_string(); - logs.write() - .await - .add("error", &format!("[AsterAgent] {}", message)); - return Err(message); - } - - let manager = WorkspaceManager::new(db.clone()); - let workspace = match manager.get(&workspace_id) { - Ok(Some(workspace)) => workspace, - Ok(None) => { - let message = format!("Workspace 不存在: {workspace_id}"); - logs.write() - .await - .add("error", &format!("[AsterAgent] {}", message)); - return Err(message); - } - Err(error) => { - let message = format!("读取 workspace 失败: {error}"); - logs.write() - .await - .add("error", &format!("[AsterAgent] {}", message)); - return Err(message); - } - }; - let ensured = match ensure_workspace_ready_with_auto_relocate(&manager, &workspace) { - Ok(result) => result, - Err(message) => { - logs.write() - .await - .add("error", &format!("[AsterAgent] {}", message)); - return Err(message); - } - }; - let workspace_root = ensured.root_path.to_string_lossy().to_string(); - let runtime_config = config_manager.config(); - apply_web_search_runtime_env(&runtime_config); - let auto_continue_config = request - .auto_continue - .clone() - .map(AutoContinuePayload::normalized); - let auto_continue_enabled = auto_continue_config - .as_ref() - .map(|config| config.enabled) - .unwrap_or(false); - if let Some(config) = auto_continue_config - .as_ref() - .filter(|config| config.enabled) - { - tracing::info!( - "[AsterAgent] 自动续写策略已启用: source={:?}, fast_mode={}, continuation_length={}, sensitivity={}", - config.source, - config.fast_mode_enabled, - config.continuation_length, - config.sensitivity - ); - } - - if ensured.repaired { - let warning_message = ensured.warning.unwrap_or_else(|| { - format!( - "检测到工作区目录缺失,已自动创建并继续执行: {}", - workspace_root - ) - }); - logs.write() - .await - .add("warn", &format!("[AsterAgent] {}", warning_message)); - let warning_event = TauriAgentEvent::Warning { - code: Some(WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE.to_string()), - message: warning_message, - }; - if let Err(error) = app.emit(&request.event_name, &warning_event) { - tracing::error!("[AsterAgent] 发送工作区自动恢复提醒失败: {}", error); - } - } - - let persisted_session = AsterAgentWrapper::get_persisted_session_metadata_sync(db, session_id)?; - - if let Some(session) = persisted_session.as_ref() { - let session_dir = session.working_dir.as_deref().unwrap_or_default(); - if !session_dir.is_empty() && session_dir != workspace_root { - tracing::info!( - "[AsterAgent] workspace 变更,自动更新 session working_dir: {} -> {}", - session_dir, - workspace_root - ); - AsterAgentWrapper::update_session_working_dir_sync(db, session_id, &workspace_root)?; - } - } - - // 启动并注入 MCP extensions 到 Aster Agent - let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await; - if start_fail > 0 { - tracing::warn!( - "[AsterAgent] 部分 MCP server 自动启动失败 ({} 失败),后续可用工具可能不完整", - start_fail - ); - } - - let (_mcp_ok, mcp_fail) = inject_mcp_extensions(state, mcp_manager).await; - if mcp_fail > 0 { - tracing::warn!( - "[AsterAgent] 部分 MCP extension 注入失败 ({} 失败),Agent 可能无法使用某些 MCP 工具", - mcp_fail - ); - } - - let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref()); - let mode_default_web_search = default_web_search_enabled_for_chat_mode(runtime_chat_mode); - let (request_web_search, request_search_mode) = - apply_browser_requirement_to_request_tool_policy( - request.metadata.as_ref(), - request.web_search, - request.search_mode, - ); - - // 构建请求级工具策略: - // - web_search=true 默认只表示“允许搜索” - // - 仅显式 search_mode=required 时才强制预搜索 - let request_tool_policy = resolve_request_tool_policy_with_mode( - request_web_search, - request_search_mode, - mode_default_web_search, - ); - tracing::info!( - "[AsterAgent][WebSearchGuard] session={}, chat_mode={:?}, request_web_search={:?}, request_search_mode={:?}, effective_request_web_search={:?}, effective_request_search_mode={:?}, mode_default_web_search={}, effective_web_search={}, search_mode={}", - session_id, - runtime_chat_mode, - request.web_search, - request.search_mode, - request_web_search, - request_search_mode, - mode_default_web_search, - request_tool_policy.effective_web_search, - request_tool_policy.search_mode.as_str() - ); - - // 构建 system_prompt:优先使用项目上下文,其次使用 session 的 system_prompt - // 同时读取会话已持久化的 execution_strategy - let (system_prompt, persisted_strategy, has_persisted_session) = { - let persisted = persisted_session - .as_ref() - .map(|s| AsterExecutionStrategy::from_db_value(s.execution_strategy.as_deref())) - .unwrap_or_default(); - let session_prompt = match persisted_session.as_ref() { - Some(session) => { - tracing::debug!( - "[AsterAgent] 找到 session,system_prompt: {:?}", - session.system_prompt.as_ref().map(|s| s.len()) - ); - session.system_prompt.clone() - } - None => { - tracing::debug!("[AsterAgent] Lime 数据库中未找到 session: {}", session_id); - None - } - }; - - // 1. 如果提供了 project_id,构建项目上下文 - let project_prompt = if let Some(ref project_id) = request.project_id { - match AsterAgentState::build_project_system_prompt(db, project_id) { - Ok(prompt) => { - tracing::info!( - "[AsterAgent] 已加载项目上下文: project_id={}, prompt_len={}", - project_id, - prompt.len() - ); - Some(prompt) - } - Err(e) => { - tracing::warn!( - "[AsterAgent] 加载项目上下文失败: {}, 继续使用 session prompt", - e - ); - None - } - } - } else { - None - }; - - // 2. 如果没有项目上下文,尝试从 session 读取 - // 3. 如果 session 也没有,使用前端传入的 system_prompt - let resolved_prompt = if project_prompt.is_some() { - project_prompt - } else { - // fallback 到前端传入的 system_prompt - if session_prompt.is_some() { - session_prompt - } else if let Some(ref frontend_prompt) = request.system_prompt { - if !frontend_prompt.trim().is_empty() { - tracing::info!( - "[AsterAgent] 使用前端传入的 system_prompt, len={}", - frontend_prompt.len() - ); - Some(frontend_prompt.clone()) - } else { - None - } - } else { - None - } - }; - - let prompt_with_memory = merge_system_prompt_with_memory_context( - resolved_prompt, - &runtime_config, - MemoryPromptContext::with_working_dir(Path::new(&workspace_root)), - ); - let merged_prompt = merge_system_prompt_with_auto_continue( - 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(), - ), - auto_continue_config.as_ref(), - ); - - (merged_prompt, persisted, persisted_session.is_some()) - }; - - let requested_strategy = request.execution_strategy.unwrap_or(persisted_strategy); - let effective_strategy = requested_strategy.effective_for_message(&request.message); - - if let Some(explicit_strategy) = request.execution_strategy { - if has_persisted_session { - if let Err(error) = AsterAgentWrapper::update_session_execution_strategy_sync( - db, - session_id, - explicit_strategy.as_db_value(), - ) { - tracing::warn!( - "[AsterAgent] 更新会话执行策略失败: session={}, strategy={}, error={}", - session_id, - explicit_strategy.as_db_value(), - error - ); - } - } - } - - tracing::info!( - "[AsterAgent] 执行策略: requested={:?}, effective={:?}", - requested_strategy, - effective_strategy - ); - - // 如果提供了 Provider 配置,则配置 Provider - if let Some(provider_config) = &request.provider_config { - tracing::info!( - "[AsterAgent] 收到 provider_config: provider_id={:?}, provider_name={}, model_name={}, has_api_key={}, base_url={:?}", - provider_config.provider_id, - provider_config.provider_name, - provider_config.model_name, - provider_config.api_key.is_some(), - provider_config.base_url - ); - 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 - .provider_id - .as_deref() - .unwrap_or(&provider_config.provider_name); - state - .configure_provider_from_pool( - db, - provider_selector, - &provider_config.model_name, - session_id, - ) - .await?; - persist_session_provider_routing(session_id, provider_selector).await?; - } - } - - // 检查 Provider 是否已配置 - if !state.is_provider_configured().await { - return Err("Provider 未配置,请先调用 aster_agent_configure_provider".to_string()); - } - - let sandbox_outcome = apply_workspace_sandbox_permissions( - state, - 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 - .map_err(|e| format!("注入 workspace 安全策略失败: {e}"))?; - - match sandbox_outcome { - WorkspaceSandboxApplyOutcome::Applied { sandbox_type } => { - tracing::info!( - "[AsterAgent] 已启用 workspace 本地 sandbox: root={}, type={}", - workspace_root, - sandbox_type - ); - } - WorkspaceSandboxApplyOutcome::DisabledByConfig => { - tracing::info!( - "[AsterAgent] workspace 本地 sandbox 已关闭,继续使用普通执行模式: root={}", - workspace_root - ); - } - WorkspaceSandboxApplyOutcome::UnavailableFallback { - warning_message, - notify_user, - } => { - tracing::warn!( - "[AsterAgent] workspace 本地 sandbox 不可用,已降级为普通执行: root={}, warning={}", - workspace_root, - warning_message - ); - if notify_user { - let warning_event = TauriAgentEvent::Warning { - code: Some(WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE.to_string()), - message: warning_message, - }; - if let Err(e) = app.emit(&request.event_name, &warning_event) { - tracing::error!("[AsterAgent] 发送 sandbox 降级提醒失败: {}", e); - } - } - } - } - - let tracker = ExecutionTracker::new(db.clone()); - let cancel_token = state.create_cancel_token(session_id).await; - let auto_continue_metadata = auto_continue_config.clone(); - let request_metadata = request.metadata.clone(); - sync_browser_assist_runtime_hint(session_id, request_metadata.as_ref()).await; - let model_skill_tool_enabled = should_enable_model_skill_tool(request_metadata.as_ref()); - let run_start_metadata = build_chat_run_metadata_base( - &request, - workspace_id.as_str(), - effective_strategy, - &request_tool_policy, - auto_continue_enabled, - auto_continue_metadata.as_ref(), - ); - let run_observation = Arc::new(Mutex::new(ChatRunObservation::default())); - let run_observation_for_finalize = run_observation.clone(); - let run_start_metadata_for_finalize = run_start_metadata.clone(); - - let agent_arc = state.get_agent_arc(); - let runtime_snapshot = { - let guard = agent_arc.read().await; - let agent = guard.as_ref().ok_or("Agent not initialized")?; - match agent.runtime_snapshot(session_id).await { - Ok(snapshot) => Some(snapshot), - Err(error) => { - tracing::warn!( - "[AsterAgent] 提交 turn 前读取 runtime snapshot 失败: session_id={}, error={}", - session_id, - error - ); - None - } - } - }; - let resolved_thread_id = runtime_snapshot - .as_ref() - .and_then(|snapshot| snapshot.threads.first()) - .map(|thread| thread.thread.id.clone()) - .unwrap_or_else(|| session_id.to_string()); - let resolved_turn_id = request - .turn_id - .clone() - .unwrap_or_else(|| Uuid::new_v4().to_string()); - let timeline_recorder = Arc::new(Mutex::new(AgentTimelineRecorder::create( - db.clone(), - resolved_thread_id.clone(), - resolved_turn_id.clone(), - request.message.clone(), - )?)); - let include_context_trace = runtime_config.memory.enabled; - let turn_context = build_turn_context_override(request.metadata.as_ref()); - let runtime_status_session_config = { - let mut session_config_builder = SessionConfigBuilder::new(session_id) - .thread_id(resolved_thread_id.clone()) - .turn_id(resolved_turn_id.clone()); - if let Some(turn_context) = turn_context.clone() { - session_config_builder = session_config_builder.turn_context(turn_context); - } - session_config_builder.build() - }; - - // 获取 Agent Arc 并保持 guard 在整个流处理期间存活 - let guard = agent_arc.read().await; - let agent = guard.as_ref().ok_or("Agent not initialized")?; - if let Err(error) = agent - .ensure_runtime_turn_initialized( - &runtime_status_session_config, - Some(request.message.clone()), - ) - .await - { - tracing::warn!( - "[AsterAgent] 初始化 runtime turn 失败,后续降级继续: {}", - error - ); - } - - let (initial_runtime_status, decided_runtime_status) = build_turn_runtime_statuses( - &request, - effective_strategy, - &request_tool_policy, - request - .provider_config - .as_ref() - .map(|config| config.model_name.as_str()), - ); - for status in [initial_runtime_status, decided_runtime_status] { - emit_runtime_status_with_projection( - agent, - app, - &request.event_name, - &timeline_recorder, - workspace_root.as_str(), - &runtime_status_session_config, - status, - ) - .await; - } - let resolved_thread_id_for_session = resolved_thread_id.clone(); - let resolved_turn_id_for_session = resolved_turn_id.clone(); - - let build_session_config = || { - let mut session_config_builder = SessionConfigBuilder::new(session_id) - .thread_id(resolved_thread_id_for_session.clone()) - .turn_id(resolved_turn_id_for_session.clone()); - if let Some(prompt) = system_prompt.clone() { - session_config_builder = session_config_builder.system_prompt(prompt); - } - if let Some(turn_context) = turn_context.clone() { - session_config_builder = session_config_builder.turn_context(turn_context); - } - session_config_builder = - session_config_builder.include_context_trace(include_context_trace); - session_config_builder.build() - }; - - lime_agent::tools::set_skill_tool_session_access(session_id, model_skill_tool_enabled); - let final_result = tracker - .with_run_custom( - RunSource::Chat, - Some("agent_runtime_submit_turn".to_string()), - Some(session_id.to_string()), - Some(serde_json::Value::Object(run_start_metadata.clone())), - async { - let mut added_code_execution = false; - if effective_strategy == AsterExecutionStrategy::CodeOrchestrated { - added_code_execution = ensure_code_execution_extension_enabled(agent).await?; - } - - let primary_result = stream_reply_once( - agent, - app, - &request.event_name, - build_runtime_user_message(&request.message, request.images.as_deref()), - Some(Path::new(&workspace_root)), - build_session_config(), - cancel_token.clone(), - &request_tool_policy, - { - let run_observation = run_observation.clone(); - let app = app.clone(); - let event_name = request.event_name.clone(); - let timeline_recorder = timeline_recorder.clone(); - let workspace_root = workspace_root.clone(); - let request_metadata = request_metadata.clone(); - move |event| { - let mut observation = match run_observation.lock() { - Ok(guard) => guard, - Err(error) => { - tracing::warn!( - "[AsterAgent] run observation lock poisoned,继续复用内部状态" - ); - error.into_inner() - } - }; - observation.record_event( - event, - workspace_root.as_str(), - request_metadata.as_ref(), - ); - let mut recorder = match timeline_recorder.lock() { - Ok(guard) => guard, - Err(error) => error.into_inner(), - }; - if let Err(error) = recorder.record_runtime_event( - &app, - &event_name, - event, - workspace_root.as_str(), - ) { - tracing::warn!( - "[AsterAgent] 记录时间线事件失败(已降级继续): {}", - error - ); - } - } - }, - ) - .await; - - let run_result: Result<(), String> = match primary_result { - Ok(()) => Ok(()), - Err(primary_error) - if effective_strategy == AsterExecutionStrategy::CodeOrchestrated - && should_fallback_to_react_from_code_orchestrated(&primary_error) => - { - tracing::warn!( - "[AsterAgent] 编排模式执行失败,自动降级到 ReAct: {}", - primary_error.message - ); - if added_code_execution { - if let Err(e) = - agent.remove_extension(CODE_EXECUTION_EXTENSION_NAME).await - { - tracing::warn!( - "[AsterAgent] 降级前移除 code_execution 扩展失败: {}", - e - ); - } - added_code_execution = false; - } - stream_reply_once( - agent, - &app, - &request.event_name, - build_runtime_user_message( - &request.message, - request.images.as_deref(), - ), - Some(Path::new(&workspace_root)), - build_session_config(), - cancel_token.clone(), - &request_tool_policy, - { - let run_observation = run_observation.clone(); - let app = app.clone(); - let event_name = request.event_name.clone(); - let timeline_recorder = timeline_recorder.clone(); - let workspace_root = workspace_root.clone(); - let request_metadata = request_metadata.clone(); - move |event| { - let mut observation = match run_observation.lock() { - Ok(guard) => guard, - Err(error) => { - tracing::warn!( - "[AsterAgent] run observation lock poisoned,继续复用内部状态" - ); - error.into_inner() - } - }; - observation.record_event( - event, - workspace_root.as_str(), - request_metadata.as_ref(), - ); - let mut recorder = match timeline_recorder.lock() { - Ok(guard) => guard, - Err(error) => error.into_inner(), - }; - if let Err(error) = recorder.record_runtime_event( - &app, - &event_name, - event, - workspace_root.as_str(), - ) { - tracing::warn!( - "[AsterAgent] 记录时间线事件失败(已降级继续): {}", - error - ); - } - } - }, - ) - .await - .map_err(|fallback_err| fallback_err.message) - } - Err(primary_error) => Err(primary_error.message), - }; - - if added_code_execution { - if let Err(e) = agent.remove_extension(CODE_EXECUTION_EXTENSION_NAME).await { - tracing::warn!( - "[AsterAgent] 移除 code_execution 扩展失败,后续会话可能继续保留编排模式: {}", - e - ); - } - } - - run_result - }, - move |result| { - let observation = match run_observation_for_finalize.lock() { - Ok(guard) => guard.clone(), - Err(error) => { - tracing::warn!( - "[AsterAgent] finalize run metadata 时 observation lock 已 poisoned" - ); - error.into_inner().clone() - } - }; - let metadata = - build_chat_run_finish_metadata(&run_start_metadata_for_finalize, &observation); - - match result { - Ok(_) => RunFinishDecision { - status: lime_core::database::dao::agent_run::AgentRunStatus::Success, - error_code: None, - error_message: None, - metadata: Some(metadata), - }, - Err(err) => RunFinishDecision { - status: lime_core::database::dao::agent_run::AgentRunStatus::Error, - error_code: Some("chat_stream_failed".to_string()), - error_message: Some(err.clone()), - metadata: Some(metadata), - }, - } - }, - ) - .await; - lime_agent::tools::clear_skill_tool_session_access(session_id); - - match final_result { - Ok(()) => { - complete_runtime_status_projection( - agent, - app, - &request.event_name, - &timeline_recorder, - workspace_root.as_str(), - &runtime_status_session_config, - ) - .await; - { - let mut recorder = match timeline_recorder.lock() { - Ok(guard) => guard, - Err(error) => error.into_inner(), - }; - if let Err(error) = recorder.complete_turn_success(app, &request.event_name) { - tracing::warn!("[AsterAgent] 完成 turn 时间线失败(已降级继续): {}", error); - } - } - let done_event = TauriAgentEvent::FinalDone { usage: None }; - 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( - agent, - app, - &request.event_name, - &timeline_recorder, - workspace_root.as_str(), - &runtime_status_session_config, - ) - .await; - { - let mut recorder = match timeline_recorder.lock() { - Ok(guard) => guard, - Err(error) => error.into_inner(), - }; - if let Err(timeline_error) = recorder.fail_turn(app, &request.event_name, &e) { - tracing::warn!( - "[AsterAgent] 记录失败 turn 时间线失败(已降级继续): {}", - timeline_error - ); - } - } - let error_event = TauriAgentEvent::Error { message: e.clone() }; - 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); - } - } - - // 清理取消令牌 - state.remove_cancel_token(session_id).await; - - Ok(()) -} - -fn build_queued_turn_preview(message: &str) -> String { - let compact = message.split_whitespace().collect::>().join(" "); - if compact.is_empty() { - return "空白输入".to_string(); - } - - let preview = compact.chars().take(80).collect::(); - if compact.chars().count() > 80 { - format!("{preview}...") - } else { - preview - } -} - -fn build_queued_turn_task( - mut request: AsterChatRequest, -) -> Result, String> { - let queued_turn_id = request - .queued_turn_id - .clone() - .unwrap_or_else(|| Uuid::new_v4().to_string()); - request.queued_turn_id = Some(queued_turn_id.clone()); - - let image_count = request - .images - .as_ref() - .map(|images| images.len()) - .unwrap_or(0); - let payload = - serde_json::to_value(&request).map_err(|e| format!("序列化排队 turn 失败: {e}"))?; - - Ok(QueuedTurnTask { - queued_turn_id, - session_id: request.session_id.clone(), - event_name: request.event_name.clone(), - message_preview: build_queued_turn_preview(&request.message), - message_text: request.message.clone(), - created_at: chrono::Utc::now().timestamp_millis(), - image_count, - payload, - }) -} - -fn deserialize_queued_turn_request(payload: serde_json::Value) -> Result { - serde_json::from_value(payload).map_err(|e| format!("反序列化排队 turn 失败: {e}")) -} - -fn build_runtime_queue_executor() -> RuntimeQueueExecutor { - Arc::new(|context, payload| { - async move { - let request = deserialize_queued_turn_request(payload)?; - execute_aster_chat_request( - &context.app, - &context.state, - &context.db, - &context.api_key_provider_service, - &context.logs, - &context.config_manager, - &context.mcp_manager, - &context.automation_state, - request, - ) - .await - } - .boxed() - }) -} - -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, - db: &DbConnection, - api_key_provider_service: &ApiKeyProviderServiceState, - logs: &LogState, - config_manager: &GlobalConfigManagerState, - mcp_manager: &McpManagerState, - automation_state: &AutomationServiceState, -) -> Result { - resume_persisted_runtime_queues_on_startup_service( - app, - state, - db, - api_key_provider_service, - logs, - config_manager, - mcp_manager, - automation_state, - build_runtime_queue_executor(), - ) - .await -} - -/// 统一运行时:提交一个 turn。 -#[tauri::command] -pub async fn agent_runtime_submit_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: AgentRuntimeSubmitTurnRequest, -) -> Result<(), String> { - let runtime_request: AsterChatRequest = request.into(); - let queue_if_busy = runtime_request.queue_if_busy.unwrap_or(false); - let queued_task = build_queued_turn_task(runtime_request)?; - submit_runtime_turn_service( - app, - state.inner(), - db.inner(), - api_key_provider_service.inner(), - logs.inner(), - config_manager.inner(), - mcp_manager.inner(), - automation_state.inner(), - queued_task, - queue_if_busy, - build_runtime_queue_executor(), - ) - .await -} - -/// 统一运行时:中断当前 turn。 -#[tauri::command] -pub async fn agent_runtime_interrupt_turn( - app: AppHandle, - state: State<'_, AsterAgentState>, - request: AgentRuntimeInterruptTurnRequest, -) -> Result { - let session_id = request.session_id; - let cancelled = state.cancel_session(&session_id).await; - let cleared = clear_runtime_queue_service(&app, &session_id).await?; - Ok(cancelled || !cleared.is_empty()) -} - -/// 创建新会话 -#[tauri::command] -pub async fn agent_runtime_create_session( - db: State<'_, DbConnection>, - workspace_id: String, - name: Option, - execution_strategy: Option, -) -> Result { - create_runtime_session_internal(db.inner(), None, workspace_id, name, execution_strategy).await -} - -async fn create_runtime_session_internal( - db: &DbConnection, - working_dir: Option, - workspace_id: String, - name: Option, - execution_strategy: Option, -) -> Result { - tracing::info!("[AsterAgent] 创建会话: name={:?}", name); - - let workspace_id = workspace_id.trim().to_string(); - if workspace_id.is_empty() { - return Err("workspace_id 必填,请先选择项目工作区".to_string()); - } - - let manager = WorkspaceManager::new(db.clone()); - let workspace = manager - .get(&workspace_id) - .map_err(|e| format!("读取 workspace 失败: {e}"))? - .ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?; - let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?; - let workspace_root = ensured.root_path.to_string_lossy().to_string(); - - if ensured.repaired { - tracing::warn!( - "[AsterAgent] 会话创建阶段检测到 workspace 目录异常并已修复: {}{}", - workspace_root, - if ensured.relocated { - "(已迁移)" - } else { - "" - } - ); - } - - let resolved_working_dir = working_dir - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - .or_else(|| Some(workspace_root.clone())); - - AsterAgentWrapper::create_session_sync( - db, - name, - resolved_working_dir, - workspace_id, - Some( - execution_strategy - .unwrap_or(AsterExecutionStrategy::React) - .as_db_value() - .to_string(), - ), - ) -} - -fn update_runtime_session_execution_strategy_internal( - db: &DbConnection, - session_id: &str, - execution_strategy: AsterExecutionStrategy, -) -> Result<(), String> { - AsterAgentWrapper::update_session_execution_strategy_sync( - db, - session_id, - execution_strategy.as_db_value(), - ) -} - -/// 统一运行时:列出会话。 -#[tauri::command] -pub async fn agent_runtime_list_sessions( - db: State<'_, DbConnection>, - logs: State<'_, LogState>, -) -> Result, String> { - let started_at = Instant::now(); - logs.write() - .await - .add("info", "[AgentDiag] agent_runtime_list_sessions.start"); - - match list_runtime_sessions_internal(db.inner()) { - Ok(sessions) => { - logs.write().await.add( - "info", - &format!( - "[AgentDiag] agent_runtime_list_sessions.success duration_ms={} sessions={}", - started_at.elapsed().as_millis(), - sessions.len() - ), - ); - Ok(sessions) - } - Err(error) => { - logs.write().await.add( - "error", - &format!( - "[AgentDiag] agent_runtime_list_sessions.error duration_ms={} error={}", - started_at.elapsed().as_millis(), - crate::logger::sanitize_log_message(&error) - ), - ); - Err(error) - } - } -} - -fn list_runtime_sessions_internal(db: &DbConnection) -> Result, String> { - tracing::info!("[AsterAgent] 列出会话"); - AsterAgentWrapper::list_sessions_sync(db) -} - -/// 统一运行时:获取会话详情。 -#[tauri::command] -pub async fn agent_runtime_get_session( - 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>, - session_id: String, -) -> Result { - tracing::info!("[AsterAgent] 获取运行时会话: {}", session_id); - let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?; - - if let Err(error) = 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.clone(), - build_runtime_queue_executor(), - ) - .await - { - tracing::warn!( - "[AsterAgent][Queue] 获取会话后恢复排队执行失败: session_id={}, error={}", - session_id, - error - ); - } - - let queued_turns = list_runtime_queue_snapshots_service(&session_id).await?; - Ok(AgentRuntimeSessionDetail::from_session_detail( - detail, - queued_turns, - )) -} - -/// 统一运行时:获取工具库存快照。 -#[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( - app: AppHandle, - request: AgentRuntimeRemoveQueuedTurnRequest, -) -> 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); - } - - 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, - name: &str, -) -> Result<(), String> { - tracing::info!("[AsterAgent] 重命名会话: {}", session_id); - AsterAgentWrapper::rename_session_sync(db, session_id, name) -} - -/// 统一运行时:更新会话元数据。 -#[tauri::command] -pub async fn agent_runtime_update_session( - db: State<'_, DbConnection>, - request: AgentRuntimeUpdateSessionRequest, -) -> Result<(), String> { - let trimmed_session_id = request.session_id.trim().to_string(); - if trimmed_session_id.is_empty() { - return Err("session_id 不能为空".to_string()); - } - - if let Some(name) = request.name.as_ref() { - let normalized_name = name.trim(); - if !normalized_name.is_empty() { - rename_runtime_session_internal(db.inner(), &trimmed_session_id, normalized_name)?; - } - } - - if let Some(execution_strategy) = request.execution_strategy { - update_runtime_session_execution_strategy_internal( - db.inner(), - &trimmed_session_id, - execution_strategy, - )?; - } - - Ok(()) -} - -async fn delete_runtime_session_internal( - db: &DbConnection, - session_id: &str, -) -> Result<(), String> { - tracing::info!("[AsterAgent] 删除会话: {}", session_id); - AsterAgentWrapper::delete_session(db, session_id).await?; - Ok(()) -} - -/// 统一运行时:删除会话。 -#[tauri::command] -pub async fn agent_runtime_delete_session( - app: AppHandle, - state: State<'_, AsterAgentState>, - db: State<'_, DbConnection>, - session_id: String, -) -> Result<(), String> { - let trimmed_session_id = session_id.trim().to_string(); - let _ = state.cancel_session(&trimmed_session_id).await; - let _ = clear_runtime_queue_service(&app, &trimmed_session_id).await; - delete_runtime_session_internal(db.inner(), &trimmed_session_id).await -} - -/// 确认权限请求 -#[derive(Debug, Deserialize)] -pub struct ConfirmRequest { - pub request_id: String, - pub confirmed: bool, - #[allow(dead_code)] - pub response: Option, -} - -async fn confirm_runtime_action_internal( - state: &AsterAgentState, - request: ConfirmRequest, -) -> Result<(), String> { - tracing::info!( - "[AsterAgent] 确认请求: id={}, confirmed={}", - request.request_id, - request.confirmed - ); - - let permission = if request.confirmed { - Permission::AllowOnce - } else { - Permission::DenyOnce - }; - - let confirmation = PermissionConfirmation { - principal_type: PrincipalType::Tool, - permission, - }; - - let agent_arc = state.get_agent_arc(); - let guard = agent_arc.read().await; - let agent = guard.as_ref().ok_or("Agent not initialized")?; - agent - .handle_confirmation(request.request_id.clone(), confirmation) - .await; - - Ok(()) -} - -/// Elicitation 回填请求 -#[derive(Debug, Deserialize)] -pub struct SubmitElicitationResponseRequest { - pub request_id: String, - pub user_data: serde_json::Value, - #[serde(default)] - pub metadata: Option, -} - -fn validate_elicitation_submission(session_id: &str, request_id: &str) -> Result { - let trimmed_session_id = session_id.trim().to_string(); - if trimmed_session_id.is_empty() { - return Err("session_id 不能为空".to_string()); - } - if request_id.trim().is_empty() { - return Err("request_id 不能为空".to_string()); - } - 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; - } - - if !request.confirmed { - return serde_json::Value::String(String::new()); - } - - let Some(response) = request.response.as_ref() else { - return serde_json::Value::String(String::new()); - }; - let trimmed = response.trim(); - if trimmed.is_empty() { - return serde_json::Value::String(String::new()); - } - - serde_json::from_str(trimmed).unwrap_or_else(|_| serde_json::Value::String(trimmed.to_string())) -} - -/// 统一运行时:响应工具确认 / ask / elicitation。 -#[tauri::command] -pub async fn agent_runtime_respond_action( - app: AppHandle, - state: State<'_, AsterAgentState>, - request: AgentRuntimeRespondActionRequest, -) -> Result<(), String> { - match request.action_type { - AgentRuntimeActionType::ToolConfirmation => { - confirm_runtime_action_internal( - state.inner(), - ConfirmRequest { - request_id: request.request_id.clone(), - confirmed: request.confirmed, - response: request.response.clone(), - }, - ) - .await - } - 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(), - SubmitElicitationResponseRequest { - request_id: request.request_id.clone(), - user_data, - metadata: request.metadata.clone(), - }, - ) - .await - .map(|_| { - if let Some(event_name) = resume_event_name.as_deref() { - emit_action_resume_runtime_status(&app, event_name); - } - }) - } - } -} - -async fn submit_runtime_elicitation_response_internal( - state: &AsterAgentState, - session_id: String, - request: SubmitElicitationResponseRequest, -) -> Result<(), String> { - let session_id = validate_elicitation_submission(&session_id, &request.request_id)?; - - tracing::info!( - "[AsterAgent] 提交 elicitation 响应: session={}, request_id={}", - session_id, - request.request_id - ); - - let message = - Message::user().with_content(MessageContent::action_required_elicitation_response( - request.request_id.clone(), - request.user_data, - )); - - let mut session_config_builder = - SessionConfigBuilder::new(&session_id).include_context_trace(true); - if let Some(prompt) = - merge_system_prompt_with_elicitation_context(None, request.metadata.as_ref()) - { - session_config_builder = session_config_builder.system_prompt(prompt); - } - let session_config = session_config_builder.build(); - - let agent_arc = state.get_agent_arc(); - let guard = agent_arc.read().await; - let agent = guard.as_ref().ok_or("Agent not initialized")?; - - let mut stream = agent - .reply(message, session_config, None) - .await - .map_err(|e| format!("提交 elicitation 响应失败: {e}"))?; - - while let Some(event_result) = stream.next().await { - match event_result { - Ok(AgentEvent::Message(message)) => { - let text = message.as_concat_text(); - if text.contains("Failed to submit elicitation response") - || text.contains("Request not found") - { - return Err(format!("提交 elicitation 响应失败: {text}")); - } - } - Ok(_) => {} - Err(e) => { - return Err(format!("提交 elicitation 响应失败: {e}")); - } - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use async_trait::async_trait; - use lime_agent::request_tool_policy::resolve_request_tool_policy; - use regex::Regex; - use std::ffi::OsString; - use std::path::{Path, PathBuf}; - use std::sync::{Mutex, OnceLock}; - use tempfile::TempDir; - - struct DummyTool { - name: String, - description: String, - schema: serde_json::Value, - } - - impl DummyTool { - fn new(name: &str, description: &str, schema: serde_json::Value) -> Self { - Self { - name: name.to_string(), - description: description.to_string(), - schema, - } - } - } - - #[async_trait] - impl Tool for DummyTool { - fn name(&self) -> &str { - &self.name - } - - fn description(&self) -> &str { - &self.description - } - - fn input_schema(&self) -> serde_json::Value { - self.schema.clone() - } - - async fn execute( - &self, - _params: serde_json::Value, - _context: &ToolContext, - ) -> Result { - Ok(ToolResult::success("ok")) - } - } - - fn durable_memory_test_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - struct DurableMemoryEnvGuard { - previous: Option, - } - - impl DurableMemoryEnvGuard { - fn set(path: &Path) -> Self { - let previous = lime_core::env_compat::var_os(&[ - lime_agent::LIME_DURABLE_MEMORY_ROOT_ENV, - lime_agent::LEGACY_DURABLE_MEMORY_ROOT_ENV, - ]); - std::env::set_var(lime_agent::LIME_DURABLE_MEMORY_ROOT_ENV, path.as_os_str()); - std::env::remove_var(lime_agent::LEGACY_DURABLE_MEMORY_ROOT_ENV); - Self { previous } - } - } - - impl Drop for DurableMemoryEnvGuard { - fn drop(&mut self) { - if let Some(value) = &self.previous { - std::env::set_var(lime_agent::LIME_DURABLE_MEMORY_ROOT_ENV, value); - } else { - std::env::remove_var(lime_agent::LIME_DURABLE_MEMORY_ROOT_ENV); - } - std::env::remove_var(lime_agent::LEGACY_DURABLE_MEMORY_ROOT_ENV); - } - } - - 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#"{ - "message": "Hello", - "session_id": "test-session", - "event_name": "agent_stream", - "workspace_id": "workspace-test" - }"#; - - let request: AsterChatRequest = serde_json::from_str(json).unwrap(); - assert_eq!(request.message, "Hello"); - assert_eq!(request.session_id, "test-session"); - assert_eq!(request.event_name, "agent_stream"); - assert_eq!(request.workspace_id, "workspace-test"); - assert_eq!(request.execution_strategy, None); - assert_eq!(request.auto_continue, None); - } - - #[test] - fn test_message_suggests_live_search_accepts_explicit_search_verbs() { - assert!(message_suggests_live_search( - "请帮我搜一下哥德尔不完备定理的历史背景" - )); - assert!(message_suggests_live_search( - "please look up kyoto travel tips" - )); - assert!(!message_suggests_live_search( - "帮我解释一下什么是向量数据库" - )); - } - - #[test] - fn test_aster_chat_request_deserialize_with_execution_strategy() { - let json = r#"{ - "message": "Hello", - "session_id": "test-session", - "event_name": "agent_stream", - "workspace_id": "workspace-test", - "execution_strategy": "code_orchestrated" - }"#; - - let request: AsterChatRequest = serde_json::from_str(json).unwrap(); - assert_eq!( - request.execution_strategy, - Some(AsterExecutionStrategy::CodeOrchestrated) - ); - } - - #[test] - fn test_aster_chat_request_deserialize_with_web_search_flag() { - let json = r#"{ - "message": "Hello", - "session_id": "test-session", - "event_name": "agent_stream", - "workspace_id": "workspace-test", - "web_search": true - }"#; - - let request: AsterChatRequest = serde_json::from_str(json).unwrap(); - assert_eq!(request.web_search, Some(true)); - } - - #[test] - fn test_aster_chat_request_deserialize_with_auto_continue_payload() { - let json = r#"{ - "message": "Hello", - "session_id": "test-session", - "event_name": "agent_stream", - "workspace_id": "workspace-test", - "auto_continue": { - "enabled": true, - "fast_mode_enabled": true, - "continuation_length": 2, - "sensitivity": 88, - "source": "document_canvas" - } - }"#; - - let request: AsterChatRequest = serde_json::from_str(json).unwrap(); - assert_eq!( - request.auto_continue, - Some(AutoContinuePayload { - enabled: true, - fast_mode_enabled: true, - continuation_length: 2, - sensitivity: 88, - source: Some("document_canvas".to_string()), - }) - ); - } - - #[test] - fn test_aster_chat_request_deserialize_with_auto_continue_camel_case_aliases() { - let json = r#"{ - "message": "Hello", - "session_id": "test-session", - "event_name": "agent_stream", - "workspace_id": "workspace-test", - "autoContinue": { - "enabled": true, - "fastModeEnabled": true, - "continuationLength": 1, - "sensitivity": 45 - } - }"#; - - let request: AsterChatRequest = serde_json::from_str(json).unwrap(); - assert_eq!( - request.auto_continue, - Some(AutoContinuePayload { - enabled: true, - fast_mode_enabled: true, - continuation_length: 1, - sensitivity: 45, - source: None, - }) - ); - } - - #[test] - fn test_aster_chat_request_deserialize_with_metadata() { - let json = r#"{ - "message": "Hello", - "session_id": "test-session", - "event_name": "agent_stream", - "workspace_id": "workspace-test", - "metadata": { - "harness": { - "theme": "social-media", - "gate_key": "write_mode", - "run_title": "社媒初稿" - } - } - }"#; - - let request: AsterChatRequest = serde_json::from_str(json).unwrap(); - assert_eq!( - request - .metadata - .as_ref() - .and_then(|value| value.get("harness")) - .and_then(|value| value.get("theme")) - .and_then(serde_json::Value::as_str), - Some("social-media") - ); - } - - #[test] - fn test_resolve_runtime_chat_mode_prefers_explicit_chat_mode() { - let metadata = serde_json::json!({ - "harness": { - "theme": "social-media", - "chat_mode": "general" - } - }); - - assert_eq!( - resolve_runtime_chat_mode(Some(&metadata)), - RuntimeChatMode::General - ); - } - - #[test] - fn test_resolve_runtime_chat_mode_falls_back_to_general_theme_group() { - let metadata = serde_json::json!({ - "harness": { - "theme": "planning" - } - }); - - assert_eq!( - resolve_runtime_chat_mode(Some(&metadata)), - RuntimeChatMode::General - ); - } - - #[test] - fn test_default_web_search_enabled_for_chat_mode_requires_explicit_opt_in() { - assert!(!default_web_search_enabled_for_chat_mode( - RuntimeChatMode::Agent - )); - assert!(!default_web_search_enabled_for_chat_mode( - RuntimeChatMode::Creator - )); - assert!(!default_web_search_enabled_for_chat_mode( - RuntimeChatMode::General - )); - } - - #[test] - fn test_browser_required_task_disables_web_search_policy() { - let metadata = serde_json::json!({ - "harness": { - "browser_requirement": "required_with_user_step" - } - }); - - assert_eq!( - apply_browser_requirement_to_request_tool_policy( - Some(&metadata), - Some(true), - Some(RequestToolPolicyMode::Allowed), - ), - (Some(false), Some(RequestToolPolicyMode::Disabled)) - ); - } - - #[test] - fn test_optional_browser_task_keeps_original_web_search_policy() { - let metadata = serde_json::json!({ - "harness": { - "browser_requirement": "optional" - } - }); - - assert_eq!( - apply_browser_requirement_to_request_tool_policy( - Some(&metadata), - Some(true), - Some(RequestToolPolicyMode::Allowed), - ), - (Some(true), Some(RequestToolPolicyMode::Allowed)) - ); - } - - #[test] - fn test_should_enable_model_skill_tool_defaults_to_false() { - let metadata = serde_json::json!({ - "harness": { - "theme": "general", - "session_mode": "default" - } - }); - - assert!(!should_enable_model_skill_tool(Some(&metadata))); - assert!(!should_enable_model_skill_tool(None)); - } - - #[test] - fn test_should_enable_model_skill_tool_allows_theme_workbench() { - let metadata = serde_json::json!({ - "harness": { - "theme": "social-media", - "session_mode": "theme_workbench" - } - }); - - assert!(should_enable_model_skill_tool(Some(&metadata))); - } - - #[test] - fn test_should_enable_model_skill_tool_respects_explicit_override() { - let metadata = serde_json::json!({ - "harness": { - "theme": "social-media", - "session_mode": "theme_workbench", - "allow_model_skills": false - } - }); - - assert!(!should_enable_model_skill_tool(Some(&metadata))); - } - - #[test] - fn test_extract_browser_assist_runtime_hint_from_harness_metadata() { - let metadata = serde_json::json!({ - "harness": { - "theme": "general", - "browser_assist": { - "profile_key": "general_browser_assist", - "preferred_backend": "cdp_direct", - "auto_launch": true, - "launch_url": "https://www.google.com" - } - } - }); - - assert_eq!( - extract_browser_assist_runtime_hint(Some(&metadata)), - Some(BrowserAssistRuntimeHint { - profile_key: "general_browser_assist".to_string(), - preferred_backend: Some(BrowserBackendType::CdpDirect), - auto_launch: true, - launch_url: Some("https://www.google.com".to_string()), - }) - ); - } - - #[test] - fn test_resolve_browser_backend_keeps_explicit_backend() { - let params = serde_json::json!({ - "backend": "cdp_direct" - }); - let session_hint = BrowserAssistRuntimeHint { - profile_key: "general_browser_assist".to_string(), - preferred_backend: Some(BrowserBackendType::AsterCompat), - auto_launch: true, - launch_url: None, - }; - - assert_eq!( - LimeBrowserMcpTool::resolve_backend("find", ¶ms, Some(&session_hint)), - Some(BrowserBackendType::CdpDirect) - ); - } - - #[test] - fn test_resolve_browser_backend_does_not_force_cdp_for_unsupported_action() { - let params = serde_json::json!({}); - let session_hint = BrowserAssistRuntimeHint { - profile_key: "general_browser_assist".to_string(), - preferred_backend: Some(BrowserBackendType::CdpDirect), - auto_launch: true, - launch_url: None, - }; - - assert_eq!( - LimeBrowserMcpTool::resolve_backend("find", ¶ms, Some(&session_hint)), - None - ); - assert_eq!( - LimeBrowserMcpTool::resolve_backend("javascript_tool", ¶ms, Some(&session_hint)), - None - ); - } - - #[test] - fn test_resolve_browser_backend_keeps_cdp_for_supported_action() { - let params = serde_json::json!({}); - let session_hint = BrowserAssistRuntimeHint { - profile_key: "general_browser_assist".to_string(), - preferred_backend: Some(BrowserBackendType::CdpDirect), - auto_launch: true, - launch_url: None, - }; - - assert_eq!( - LimeBrowserMcpTool::resolve_backend("navigate", ¶ms, Some(&session_hint)), - Some(BrowserBackendType::CdpDirect) - ); - assert_eq!( - LimeBrowserMcpTool::resolve_backend("read_page", ¶ms, Some(&session_hint)), - Some(BrowserBackendType::CdpDirect) - ); - } - - #[test] - fn test_is_browser_assist_enabled_respects_explicit_flag() { - let disabled_metadata = serde_json::json!({ - "harness": { - "browser_assist": { - "enabled": false, - "profile_key": "general_browser_assist" - } - } - }); - let enabled_metadata = serde_json::json!({ - "harness": { - "browser_assist": { - "profile_key": "general_browser_assist" - } - } - }); - - assert!(!is_browser_assist_enabled(Some(&disabled_metadata))); - assert!(is_browser_assist_enabled(Some(&enabled_metadata))); - assert!(!is_browser_assist_enabled(None)); - } - - #[test] - fn test_append_browser_assist_session_permissions_adds_session_scoped_rules() { - let metadata = serde_json::json!({ - "harness": { - "browser_assist": { - "enabled": true, - "profile_key": "general_browser_assist" - } - } - }); - let mut permissions = Vec::new(); - - append_browser_assist_session_permissions( - &mut permissions, - "session-browser-1", - Some(&metadata), - ); - - let allow_rule = permissions - .iter() - .find(|permission| permission.tool == BROWSER_ASSIST_ALLOW_PATTERN) - .expect("should add browser assist allow rule"); - assert!(allow_rule.allowed); - assert_eq!(allow_rule.priority, 1100); - assert_eq!(allow_rule.conditions.len(), 1); - assert_eq!( - allow_rule.conditions[0].field.as_deref(), - Some("session_id") - ); - assert_eq!( - allow_rule.conditions[0].value, - serde_json::json!("session-browser-1") - ); - - let deny_rule = permissions - .iter() - .find(|permission| permission.tool == "mcp__playwright__*") - .expect("should add playwright deny rule"); - assert!(!deny_rule.allowed); - assert_eq!(deny_rule.priority, 1200); - assert_eq!(deny_rule.conditions, allow_rule.conditions); - } - - #[test] - fn test_agent_runtime_submit_turn_request_maps_to_aster_chat_request() { - let json = r#"{ - "message": "Hello runtime", - "session_id": "runtime-session", - "event_name": "runtime_stream", - "workspace_id": "workspace-runtime", - "turn_config": { - "execution_strategy": "auto", - "web_search": true, - "system_prompt": "runtime prompt", - "provider_config": { - "provider_id": "custom-provider", - "provider_name": "custom-provider", - "model_name": "gpt-5.3-codex" - }, - "metadata": { - "source": "hook-facade" - } - } - }"#; - - let request: AgentRuntimeSubmitTurnRequest = serde_json::from_str(json).unwrap(); - let mapped: AsterChatRequest = request.into(); - - assert_eq!(mapped.message, "Hello runtime"); - assert_eq!(mapped.session_id, "runtime-session"); - assert_eq!(mapped.event_name, "runtime_stream"); - assert_eq!(mapped.workspace_id, "workspace-runtime"); - assert_eq!( - mapped.execution_strategy, - Some(AsterExecutionStrategy::Auto) - ); - assert_eq!(mapped.web_search, Some(true)); - assert_eq!(mapped.system_prompt.as_deref(), Some("runtime prompt")); - assert_eq!( - mapped - .provider_config - .as_ref() - .and_then(|config| config.provider_id.as_deref()), - Some("custom-provider") - ); - assert_eq!( - mapped - .metadata - .as_ref() - .and_then(|value| value.get("source")) - .and_then(serde_json::Value::as_str), - Some("hook-facade") - ); - } - - #[test] - fn test_build_runtime_user_message_includes_images() { - let message = build_runtime_user_message( - "这个是什么", - Some(&[ImageInput { - data: "aGVsbG8=".to_string(), - media_type: "image/png".to_string(), - }]), - ); - - assert_eq!(message.as_concat_text(), "这个是什么"); - assert_eq!(message.content.len(), 2); - - if let MessageContent::Image(image) = &message.content[1] { - assert_eq!(image.data, "aGVsbG8="); - assert_eq!(image.mime_type, "image/png"); - } else { - panic!("expected image content in runtime user message"); - } - } - - #[test] - fn test_build_runtime_action_user_data_prefers_structured_payload() { - let request = AgentRuntimeRespondActionRequest { - session_id: "session-1".to_string(), - request_id: "req-1".to_string(), - action_type: AgentRuntimeActionType::AskUser, - confirmed: true, - response: Some("{\"answer\":\"A\"}".to_string()), - user_data: Some(serde_json::json!({ "answer": "B" })), - metadata: None, - event_name: None, - }; - - assert_eq!( - build_runtime_action_user_data(&request), - serde_json::json!({ "answer": "B" }) - ); - } - - #[test] - fn test_build_runtime_action_user_data_parses_json_response() { - let request = AgentRuntimeRespondActionRequest { - session_id: "session-1".to_string(), - request_id: "req-1".to_string(), - action_type: AgentRuntimeActionType::Elicitation, - confirmed: true, - response: Some("{\"answer\":\"A\"}".to_string()), - user_data: None, - metadata: None, - event_name: None, - }; - - assert_eq!( - build_runtime_action_user_data(&request), - serde_json::json!({ "answer": "A" }) - ); - } - - #[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( - "write_file", - Some(r##"{"path":"social-posts/demo.md","content":"# 标题"}"##), - "/tmp/workspace", - ); - - assert_eq!(path.as_deref(), Some("social-posts/demo.md")); - } - - #[test] - fn test_resolve_social_run_artifact_descriptor_matches_social_draft() { - let descriptor = resolve_social_run_artifact_descriptor( - "social-posts/draft.md", - Some("write_mode"), - Some("社媒初稿"), - ); - - assert_eq!(descriptor.artifact_type, "draft"); - assert_eq!(descriptor.stage, "drafting"); - assert_eq!(descriptor.version_label, "社媒初稿"); - assert!(!descriptor.is_auxiliary); - } - - #[test] - fn test_build_chat_run_finish_metadata_includes_social_fields() { - let base = build_chat_run_metadata_base( - &AsterChatRequest { - message: "hello".to_string(), - session_id: "session-1".to_string(), - event_name: "event-1".to_string(), - images: None, - provider_config: None, - project_id: Some("project-1".to_string()), - workspace_id: "workspace-1".to_string(), - web_search: Some(false), - search_mode: None, - execution_strategy: Some(AsterExecutionStrategy::React), - auto_continue: None, - system_prompt: None, - metadata: Some(serde_json::json!({ - "harness": { - "theme": "social-media", - "gate_key": "write_mode" - } - })), - turn_id: None, - queue_if_busy: None, - queued_turn_id: None, - }, - "workspace-1", - AsterExecutionStrategy::React, - &RequestToolPolicy { - search_mode: RequestToolPolicyMode::Disabled, - effective_web_search: false, - required_tools: vec![], - allowed_tools: vec![], - disallowed_tools: vec![], - }, - false, - None, - ); - let mut observation = ChatRunObservation::default(); - observation.record_artifact_path( - "social-posts/draft.md".to_string(), - Some(&serde_json::json!({ - "harness": { - "theme": "social-media", - "gate_key": "write_mode" - } - })), - ); - - let metadata = build_chat_run_finish_metadata(&base, &observation); - - assert_eq!( - metadata - .get("artifact_paths") - .and_then(serde_json::Value::as_array), - Some(&vec![serde_json::json!("social-posts/draft.md")]) - ); - assert_eq!( - metadata - .get("artifact_type") - .and_then(serde_json::Value::as_str), - Some("draft") - ); - assert_eq!( - metadata.get("stage").and_then(serde_json::Value::as_str), - Some("drafting") - ); - assert_eq!( - metadata - .get("version_id") - .and_then(serde_json::Value::as_str), - Some("artifact:social-posts/draft.md") - ); - } - - #[test] - fn test_aster_execution_strategy_default_is_auto() { - assert_eq!( - AsterExecutionStrategy::default(), - AsterExecutionStrategy::Auto - ); - } - - #[test] - fn test_aster_execution_strategy_from_db_value_none_is_auto() { - assert_eq!( - AsterExecutionStrategy::from_db_value(None), - AsterExecutionStrategy::Auto - ); - } - - #[test] - fn test_aster_execution_strategy_from_db_value_unknown_is_auto() { - assert_eq!( - AsterExecutionStrategy::from_db_value(Some("unknown")), - AsterExecutionStrategy::Auto - ); - } - - #[test] - fn test_aster_execution_strategy_auto_prefers_react_when_tool_search_explicit() { - let strategy = - AsterExecutionStrategy::Auto.effective_for_message("请先调用 tool_search 再继续"); - assert_eq!(strategy, AsterExecutionStrategy::React); - } - - #[test] - fn test_aster_execution_strategy_auto_prefers_react_for_generic_web_search() { - let strategy = - AsterExecutionStrategy::Auto.effective_for_message("帮我联网搜索今天的 AI 新闻"); - assert_eq!(strategy, AsterExecutionStrategy::React); - } - - #[test] - fn test_aster_execution_strategy_auto_defaults_react_for_code_task() { - let strategy = AsterExecutionStrategy::Auto - .effective_for_message("请抓取这个仓库并修复 Rust 编译错误,然后给出补丁"); - assert_eq!(strategy, AsterExecutionStrategy::React); - } - - #[test] - fn test_aster_execution_strategy_code_orchestrated_still_prefers_react_for_web_search() { - let strategy = AsterExecutionStrategy::CodeOrchestrated - .effective_for_message("请使用 WebSearch 工具检索并给出来源"); - assert_eq!(strategy, AsterExecutionStrategy::React); - } - - #[test] - fn test_aster_execution_strategy_code_orchestrated_forces_react_for_websearch_instruction() { - let strategy = AsterExecutionStrategy::CodeOrchestrated - .effective_for_message("请必须使用 WebSearch 工具检索,不要用已有知识回答"); - assert_eq!(strategy, AsterExecutionStrategy::React); - } - - #[test] - fn test_merge_system_prompt_with_request_tool_policy_adds_policy_when_enabled() { - let policy = resolve_request_tool_policy(Some(true), false); - let merged = - merge_system_prompt_with_request_tool_policy(Some("你是助手".to_string()), &policy) - .expect("should have merged prompt"); - assert!(merged.contains(REQUEST_TOOL_POLICY_MARKER)); - assert!(merged.contains("WebSearch")); - } - - #[test] - fn test_merge_system_prompt_with_request_tool_policy_keeps_original_when_disabled() { - let base = Some("你好".to_string()); - let policy = resolve_request_tool_policy(Some(false), false); - let merged = merge_system_prompt_with_request_tool_policy(base.clone(), &policy); - assert_eq!(merged, base); - } - - #[test] - fn test_merge_system_prompt_with_request_tool_policy_no_duplicate_marker() { - let base = Some(format!("{REQUEST_TOOL_POLICY_MARKER}\n已有策略")); - let policy = resolve_request_tool_policy(Some(true), false); - let merged = merge_system_prompt_with_request_tool_policy(base.clone(), &policy); - assert_eq!(merged, base); - } - - #[test] - fn test_merge_system_prompt_with_auto_continue_appends_prompt() { - let config = AutoContinuePayload { - enabled: true, - fast_mode_enabled: false, - continuation_length: 1, - sensitivity: 55, - source: Some("theme_workbench_document_auto_continue".to_string()), - }; - let merged = - merge_system_prompt_with_auto_continue(Some("你是助手".to_string()), Some(&config)) - .expect("should contain merged prompt"); - assert!(merged.contains(AUTO_CONTINUE_PROMPT_MARKER)); - assert!(merged.contains("续写长度")); - assert!(merged.contains("theme_workbench_document_auto_continue")); - } - - #[test] - fn test_merge_system_prompt_with_auto_continue_skip_when_disabled() { - let config = AutoContinuePayload { - enabled: false, - fast_mode_enabled: false, - continuation_length: 1, - sensitivity: 55, - source: None, - }; - let base = Some("你是助手".to_string()); - let merged = merge_system_prompt_with_auto_continue(base.clone(), Some(&config)); - assert_eq!(merged, base); - } - - #[test] - fn test_merge_system_prompt_with_elicitation_context_appends_prompt() { - let metadata = serde_json::json!({ - "elicitation_context": { - "source": "legacy_questionnaire", - "mode": "compatibility_bridge", - "entries": [ - { - "label": "目标受众", - "summary": "客户" - }, - { - "label": "语气偏好", - "summary": "友好专业" - } - ] - } - }); - - let merged = merge_system_prompt_with_elicitation_context( - Some("你是助手".to_string()), - Some(&metadata), - ) - .expect("should contain merged prompt"); - - assert!(merged.contains(ELICITATION_CONTEXT_PROMPT_MARKER)); - assert!(merged.contains("目标受众")); - assert!(merged.contains("友好专业")); - assert!(merged.contains("legacy_questionnaire")); - } - - #[test] - fn test_merge_system_prompt_with_elicitation_context_skips_duplicate_marker() { - let metadata = serde_json::json!({ - "elicitation_context": { - "entries": [ - { - "label": "目标受众", - "summary": "客户" - } - ] - } - }); - let base = Some(format!("{ELICITATION_CONTEXT_PROMPT_MARKER}\n已有信息")); - let merged = merge_system_prompt_with_elicitation_context(base.clone(), Some(&metadata)); - assert_eq!(merged, base); - } - - #[test] - fn test_merge_system_prompt_with_elicitation_context_formats_non_string_values() { - let metadata = serde_json::json!({ - "elicitation_context": { - "entries": [ - { - "label": "渠道偏好", - "value": ["公众号", "视频号"] - }, - { - "label": "是否需要 CTA", - "value": true - }, - { - "label": "目标轮次", - "value": 3 - } - ] - } - }); - - let merged = merge_system_prompt_with_elicitation_context( - Some("你是助手".to_string()), - Some(&metadata), - ) - .expect("should contain merged prompt"); - - assert!(merged.contains("渠道偏好")); - assert!(merged.contains("公众号、视频号")); - assert!(merged.contains("是否需要 CTA")); - assert!(merged.contains("是")); - assert!(merged.contains("目标轮次")); - assert!(merged.contains("3")); - } - - #[test] - fn test_should_fallback_to_react_from_code_orchestrated_when_no_event_emitted() { - let error = ReplyAttemptError { - message: "Stream error: timeout".to_string(), - emitted_any: false, - }; - assert!(should_fallback_to_react_from_code_orchestrated(&error)); - } - - #[test] - fn test_should_fallback_to_react_from_code_orchestrated_when_unknown_subscript() { - let error = ReplyAttemptError { - message: "Agent provider execution failed: Unknown subscript 'web_scraping'" - .to_string(), - emitted_any: true, - }; - assert!(should_fallback_to_react_from_code_orchestrated(&error)); - } - - #[test] - fn test_should_not_fallback_to_react_from_code_orchestrated_for_general_error() { - let error = ReplyAttemptError { - message: "Agent provider execution failed: quota exceeded".to_string(), - emitted_any: true, - }; - assert!(!should_fallback_to_react_from_code_orchestrated(&error)); - } - - #[test] - fn test_validate_elicitation_submission_rejects_empty_session_id() { - let result = validate_elicitation_submission(" ", "req-1"); - assert_eq!(result, Err("session_id 不能为空".to_string())); - } - - #[test] - fn test_validate_elicitation_submission_rejects_empty_request_id() { - let result = validate_elicitation_submission("session-1", " "); - assert_eq!(result, Err("request_id 不能为空".to_string())); - } - - #[test] - fn test_validate_elicitation_submission_trims_session_id() { - let result = validate_elicitation_submission(" session-1 ", "req-1"); - 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("需要确认"); - let normalized = normalize_workspace_tool_permission_behavior(permission, true); - assert_eq!(normalized.behavior, PermissionBehavior::Allow); - assert!(normalized.message.is_none()); - } - - #[test] - fn test_normalize_workspace_tool_permission_behavior_non_auto_denies_warning() { - let permission = PermissionCheckResult::ask("需要确认"); - let normalized = normalize_workspace_tool_permission_behavior(permission, false); - assert_eq!(normalized.behavior, PermissionBehavior::Deny); - assert!(normalized - .message - .as_deref() - .unwrap_or_default() - .contains("当前模式不支持交互确认")); - } - - #[test] - fn test_build_workspace_shell_allow_pattern_strict_mode_rejects_python_command() { - let escaped_root = regex::escape("/tmp/workspace"); - let pattern = build_workspace_shell_allow_pattern(&escaped_root, false); - let regex = Regex::new(&pattern).unwrap(); - - assert!(regex.is_match("rg -n \"foo\" .")); - assert!(!regex.is_match("python -m pip install playwright")); - } - - #[test] - fn test_build_workspace_shell_allow_pattern_auto_mode_allows_common_commands() { - let escaped_root = regex::escape("/tmp/workspace"); - let pattern = build_workspace_shell_allow_pattern(&escaped_root, true); - let regex = Regex::new(&pattern).unwrap(); - - assert!(regex.is_match("python -m pip install playwright")); - assert!(regex.is_match("npm install && npm run build")); - 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!({ - "cmd": "echo hello", - "timeout": 10 - }); - - let normalized = normalize_shell_command_params(&input); - assert_eq!( - normalized.get("command").and_then(|value| value.as_str()), - Some("echo hello") - ); - } - - #[test] - fn test_normalize_shell_command_params_keeps_existing_command() { - let input = serde_json::json!({ - "command": "pwd", - "cmd": "echo should_not_override" - }); - - let normalized = normalize_shell_command_params(&input); - assert_eq!( - normalized.get("command").and_then(|value| value.as_str()), - Some("pwd") - ); - } - - #[test] - fn test_normalize_params_for_durable_memory_support_maps_read_path() { - let _lock = durable_memory_test_lock().lock().expect("lock env"); - let tmp = TempDir::new().expect("create temp dir"); - let _env = DurableMemoryEnvGuard::set(tmp.path()); - - let input = serde_json::json!({ - "path": "/memories/preferences.md" - }); - let normalized = normalize_params_for_durable_memory_support("read", &input) - .expect("normalize read params"); - let expected = tmp - .path() - .join("preferences.md") - .to_string_lossy() - .to_string(); - - assert_eq!( - normalized.get("path").and_then(|value| value.as_str()), - Some(expected.as_str()) - ); - } - - #[test] - fn test_normalize_params_for_durable_memory_support_rewrites_glob_pattern() { - let _lock = durable_memory_test_lock().lock().expect("lock env"); - let tmp = TempDir::new().expect("create temp dir"); - let _env = DurableMemoryEnvGuard::set(tmp.path()); - - let input = serde_json::json!({ - "pattern": "/memories/**/*.md" - }); - let normalized = normalize_params_for_durable_memory_support("glob", &input) - .expect("normalize glob params"); - let expected_root = tmp.path().to_string_lossy().to_string(); - - assert_eq!( - normalized.get("path").and_then(|value| value.as_str()), - Some(expected_root.as_str()) - ); - assert_eq!( - normalized.get("pattern").and_then(|value| value.as_str()), - Some("**/*.md") - ); - } - - #[test] - fn test_normalize_params_for_durable_memory_support_rejects_glob_parent_segments() { - let _lock = durable_memory_test_lock().lock().expect("lock env"); - let tmp = TempDir::new().expect("create temp dir"); - let _env = DurableMemoryEnvGuard::set(tmp.path()); - - let input = serde_json::json!({ - "pattern": "/memories/../escape.md" - }); - let error = normalize_params_for_durable_memory_support("glob", &input) - .expect_err("should reject parent path"); - - assert!(error.to_string().contains("不允许包含 `..`")); - } - - #[test] - fn test_encode_tool_result_for_harness_observability_appends_metadata_block() { - let result = ToolResult::success("任务已完成") - .with_metadata("output_file", serde_json::json!("/tmp/task.log")) - .with_metadata("exit_code", serde_json::json!(0)); - - let encoded = encode_tool_result_for_harness_observability(result); - assert!(encoded.success); - assert!(encoded - .output - .as_deref() - .unwrap_or_default() - .contains(LIME_TOOL_METADATA_BEGIN)); - assert!(encoded - .output - .as_deref() - .unwrap_or_default() - .contains("\"output_file\":\"/tmp/task.log\"")); - } - - #[test] - fn test_encode_tool_result_for_harness_observability_converts_error_to_success_output() { - let result = - ToolResult::error("执行失败").with_metadata("failed_count", serde_json::json!(1)); - - let encoded = encode_tool_result_for_harness_observability(result); - assert!(encoded.success); - let output = encoded.output.as_deref().unwrap_or_default(); - assert!(output.contains("执行失败")); - assert!(output.contains(LIME_TOOL_METADATA_BEGIN)); - assert!(output.contains("\"reported_success\":false")); - } - - #[test] - fn test_encode_tool_result_for_harness_observability_is_idempotent() { - let initial = ToolResult::success(format!( - "ok\n\n{LIME_TOOL_METADATA_BEGIN}\n{{\"reported_success\":false}}\n{LIME_TOOL_METADATA_END}" - )) - .with_metadata("reported_success", serde_json::json!(false)); - - let encoded = encode_tool_result_for_harness_observability(initial); - let output = encoded.output.as_deref().unwrap_or_default(); - assert_eq!(output.matches(LIME_TOOL_METADATA_BEGIN).count(), 1); - assert_eq!(output.matches(LIME_TOOL_METADATA_END).count(), 1); - } - - #[test] - fn test_shared_task_manager_returns_same_instance() { - let first = shared_task_manager(); - let second = shared_task_manager(); - assert!(Arc::ptr_eq(&first, &second)); - } - - #[test] - fn test_parse_subagent_role_supports_aliases() { - assert_eq!( - parse_subagent_role(Some("explore")).unwrap(), - SubAgentRole::Explorer - ); - assert_eq!( - parse_subagent_role(Some("plan")).unwrap(), - SubAgentRole::Planner - ); - assert_eq!( - parse_subagent_role(Some("code")).unwrap(), - SubAgentRole::Executor - ); - assert_eq!(parse_subagent_role(None).unwrap(), SubAgentRole::Executor); - } - - #[test] - fn test_build_subagent_task_definition_uses_role_defaults() { - let input = SubAgentTaskToolInput { - prompt: "分析当前 harness 缺口".to_string(), - task_type: None, - description: None, - role: Some("explorer".to_string()), - timeout_secs: Some(45), - model: None, - return_summary: None, - allowed_tools: None, - denied_tools: None, - max_tokens: None, - }; - - let task = build_subagent_task_definition(&input, SubAgentRole::Explorer).unwrap(); - assert_eq!(task.task_type, "explore"); - assert_eq!(task.timeout.map(|value| value.as_secs()), Some(45)); - assert!(task.return_summary); - } - - #[test] - fn test_build_subagent_task_definition_applies_optional_fields() { - let input = SubAgentTaskToolInput { - prompt: "实现 harness 面板".to_string(), - task_type: Some("code".to_string()), - description: Some("实现前端面板".to_string()), - role: Some("executor".to_string()), - timeout_secs: Some(120), - model: Some("claude-sonnet-4-20250514".to_string()), - return_summary: Some(false), - allowed_tools: Some(vec!["read_file".to_string(), "write_file".to_string()]), - denied_tools: Some(vec!["execute_command".to_string()]), - max_tokens: Some(4096), - }; - - let task = build_subagent_task_definition(&input, SubAgentRole::Executor).unwrap(); - assert_eq!(task.task_type, "code"); - assert_eq!(task.description.as_deref(), Some("实现前端面板")); - assert_eq!(task.model.as_deref(), Some("claude-sonnet-4-20250514")); - assert!(!task.return_summary); - assert_eq!( - task.allowed_tools, - Some(vec!["read_file".to_string(), "write_file".to_string()]) - ); - assert_eq!(task.denied_tools, Some(vec!["execute_command".to_string()])); - 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!({ - "x-lime": { - "deferred_loading": true, - "always_visible": false, - "allowed_callers": ["assistant", "code_execution"], - "input_examples": [{"query":"rust"}], - "tags": ["mcp", "filesystem"] - } - }); - let (deferred, always_visible, allowed_callers, tags, input_examples) = - ToolSearchBridgeTool::parse_schema_metadata("docs_search", &schema); - assert!(deferred); - assert!(!always_visible); - assert_eq!( - allowed_callers, - vec!["assistant".to_string(), "code_execution".to_string()] - ); - assert_eq!(tags, vec!["mcp".to_string(), "filesystem".to_string()]); - assert_eq!(input_examples, vec![serde_json::json!({"query":"rust"})]); - } - - #[test] - fn test_tool_search_parse_schema_metadata_infers_builtin_input_examples() { - let schema = serde_json::json!({ - "type": "object", - "properties": { - "query": {"type":"string"} - }, - "required": ["query"] - }); - let (_, _, _, _, input_examples) = - ToolSearchBridgeTool::parse_schema_metadata("WebSearch", &schema); - assert!(!input_examples.is_empty()); - assert!(input_examples[0].get("query").is_some()); - } - - #[test] - fn test_tool_search_score_match_prefers_exact_name() { - let exact = ToolSearchBridgeTool::score_match( - "web_fetch", - "fetch webpage", - &["web".to_string()], - "web_fetch", - ); - let partial = ToolSearchBridgeTool::score_match( - "fetch_web", - "web fetch helper", - &["web".to_string()], - "web_fetch", - ); - 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!({ - "prompt": " 封面图描述 ", - "size": " " - }); - - let prompt = SocialGenerateCoverImageTool::parse_non_empty_string(¶ms, "prompt", None); - let size = SocialGenerateCoverImageTool::parse_non_empty_string( - ¶ms, - "size", - Some(SOCIAL_IMAGE_DEFAULT_SIZE), - ); - - assert_eq!(prompt, Some("封面图描述".to_string())); - assert_eq!(size, Some(SOCIAL_IMAGE_DEFAULT_SIZE.to_string())); - } - - #[test] - fn test_social_generate_cover_image_extract_first_image_payload() { - let response = serde_json::json!({ - "data": [ - { - "url": "https://example.com/image.png", - "revised_prompt": "优化后的提示词" - } - ] - }); - - let (image_url, image_b64, revised_prompt) = - SocialGenerateCoverImageTool::extract_first_image_payload(&response).unwrap(); - assert_eq!(image_url, Some("https://example.com/image.png".to_string())); - assert_eq!(image_b64, None); - assert_eq!(revised_prompt, Some("优化后的提示词".to_string())); - } - - #[test] - fn test_social_generate_cover_image_extract_first_image_payload_rejects_empty_data() { - let response = serde_json::json!({ "data": [] }); - let result = SocialGenerateCoverImageTool::extract_first_image_payload(&response); - - assert!(result.is_err()); - assert!(result - .err() - .unwrap_or_default() - .contains("图像接口返回 data 为空")); - } - - #[test] - fn test_social_generate_cover_image_normalize_server_host() { - assert_eq!( - SocialGenerateCoverImageTool::normalize_server_host("0.0.0.0"), - "127.0.0.1".to_string() - ); - assert_eq!( - SocialGenerateCoverImageTool::normalize_server_host("::"), - "127.0.0.1".to_string() - ); - assert_eq!( - SocialGenerateCoverImageTool::normalize_server_host(" localhost "), - "localhost".to_string() - ); - } - - #[tokio::test] - async fn test_tool_search_bridge_tool_end_to_end_filters_by_caller_and_deferred() { - let registry = Arc::new(tokio::sync::RwLock::new(aster::tools::ToolRegistry::new())); - { - let mut guard = registry.write().await; - guard.register(Box::new(DummyTool::new( - "docs_search", - "Search docs", - serde_json::json!({ - "type": "object", - "x-lime": { - "deferred_loading": true, - "allowed_callers": ["assistant"], - "tags": ["docs", "search"] - } - }), - ))); - guard.register(Box::new(DummyTool::new( - "admin_secret", - "Admin-only tool", - serde_json::json!({ - "type": "object", - "x-lime": { - "deferred_loading": true, - "allowed_callers": ["code_execution"], - "tags": ["admin"] - } - }), - ))); - guard.register(Box::new(DummyTool::new( - "weather", - "Weather by city", - serde_json::json!({ - "type": "object", - "x-lime": { - "deferred_loading": false, - "tags": ["weather"] - } - }), - ))); - } - - let tool = ToolSearchBridgeTool::new(registry.clone(), None); - let context = ToolContext::new(PathBuf::from(".")); - - let hidden_result = tool - .execute( - serde_json::json!({ - "query": "search", - "caller": "assistant", - "include_deferred": false, - "include_schema": true - }), - &context, - ) - .await - .expect("tool_search should succeed"); - let hidden_output = hidden_result.output.expect("tool_search output"); - let hidden_json: serde_json::Value = - serde_json::from_str(&hidden_output).expect("parse tool_search output"); - assert_eq!(hidden_json["count"], serde_json::json!(0)); - - let visible_result = tool - .execute( - serde_json::json!({ - "query": "search", - "caller": "assistant", - "include_deferred": true, - "include_schema": true - }), - &context, - ) - .await - .expect("tool_search should succeed"); - let visible_output = visible_result.output.expect("tool_search output"); - let visible_json: serde_json::Value = - serde_json::from_str(&visible_output).expect("parse tool_search output"); - let tools = visible_json["tools"] - .as_array() - .expect("tools should be array"); - - assert_eq!(visible_json["count"], serde_json::json!(1)); - assert_eq!(tools[0]["name"], serde_json::json!("docs_search")); - assert_eq!(tools[0]["deferred_loading"], serde_json::json!(true)); - assert!(tools[0].get("input_schema").is_some()); - assert!(tools[0] - .get("input_examples") - .and_then(|v| v.as_array()) - .is_some()); - assert!(tools.iter().all(|tool| tool["name"] != "admin_secret")); - } -} - -/// 将 Lime 已运行的 MCP servers 注入到 Aster Agent 作为 extensions -/// -/// 复用 Lime 已建立的 MCP RunningService,避免 Aster 再次启动独立子进程。 -/// 同时根据 Lime 的工具元数据推导 deferred loading / always expose surface。 -/// -/// 返回 (成功数, 失败数) -async fn inject_mcp_extensions( - state: &AsterAgentState, - mcp_manager: &McpManagerState, -) -> (usize, usize) { - let manager = mcp_manager.lock().await; - let running_servers = manager.get_running_servers().await; - - if running_servers.is_empty() { - tracing::debug!("[AsterAgent] 没有运行中的 MCP servers,跳过注入"); - return (0, 0); - } - - let agent_arc = state.get_agent_arc(); - let guard = agent_arc.read().await; - let agent = match guard.as_ref() { - Some(a) => a, - None => { - tracing::warn!("[AsterAgent] Agent 未初始化,无法注入 MCP extensions"); - return (0, running_servers.len()); - } - }; - - 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; - - for server_name in &running_servers { - // 检查是否已注册(避免重复注册) - let ext_configs = agent.get_extension_configs().await; - if ext_configs.iter().any(|c| c.name() == *server_name) { - tracing::debug!("[AsterAgent] MCP extension '{}' 已注册,跳过", server_name); - success_count += 1; - continue; - } - - 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 注入结果: {} 成功, {} 失败", - success_count, - fail_count - ); - } else { - tracing::info!( - "[AsterAgent] MCP 注入完成: {} 个 extension 全部成功", - success_count - ); - } - - (success_count, fail_count) -} - -/// 确保 Lime 可用的 MCP servers 已启动 -/// -/// 启动启用了 `enabled_lime` 的服务器。 -async fn ensure_lime_mcp_servers_running( - db: &DbConnection, - mcp_manager: &McpManagerState, -) -> (usize, usize) { - let servers = match McpService::get_all(db) { - Ok(items) => items, - Err(e) => { - tracing::warn!("[AsterAgent] 读取 MCP 配置失败,跳过自动启动: {}", e); - return (0, 0); - } - }; - - if servers.is_empty() { - return (0, 0); - } - - let candidates: Vec<&crate::models::mcp_model::McpServer> = - servers.iter().filter(|s| s.enabled_lime).collect(); - - if candidates.is_empty() { - return (0, 0); - } - - let manager = mcp_manager.lock().await; - let mut success_count = 0usize; - let mut fail_count = 0usize; - - for server in candidates { - if manager.is_server_running(&server.name).await { - continue; - } - - let parsed = server.parse_config(); - let config = McpServerConfig { - command: parsed.command, - args: parsed.args, - env: parsed.env, - cwd: parsed.cwd, - timeout: parsed.timeout, - }; - - match manager.start_server(&server.name, &config).await { - Ok(_) => { - tracing::info!("[AsterAgent] MCP server 已自动启动: {}", server.name); - success_count += 1; - } - Err(e) => { - tracing::error!( - "[AsterAgent] MCP server 自动启动失败: {} => {}", - server.name, - e - ); - fail_count += 1; - } - } - } - - (success_count, fail_count) -} - -/// 独立封面图生成命令:供前端直接调用,复用 social_generate_cover_image 工具的 HTTP 逻辑。 -/// 返回图片 URL 字符串,失败时返回错误信息。 -#[tauri::command] -pub async fn social_generate_cover_image_cmd( - config_manager: State<'_, GlobalConfigManagerState>, - prompt: String, - size: Option, -) -> Result { - if prompt.trim().is_empty() { - return Err("prompt 不能为空".to_string()); - } - let runtime_config = config_manager.config(); - let server_host = - SocialGenerateCoverImageTool::normalize_server_host(&runtime_config.server.host); - let size = size - .as_deref() - .filter(|s| !s.trim().is_empty()) - .or(runtime_config.image_gen.default_size.as_deref()) - .unwrap_or(SOCIAL_IMAGE_DEFAULT_SIZE) - .to_string(); - let endpoint = format!( - "http://{}:{}/v1/images/generations", - server_host, runtime_config.server.port - ); - let request_body = serde_json::json!({ - "prompt": prompt.trim(), - "model": SOCIAL_IMAGE_DEFAULT_MODEL, - "n": 1, - "size": size, - "response_format": "url" - }); - - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(180)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - - let response = client - .post(&endpoint) - .header( - "Authorization", - format!("Bearer {}", runtime_config.server.api_key), - ) - .json(&request_body) - .send() - .await - .map_err(|e| format!("调用图像接口失败: {e}"))?; - - let status = response.status(); - let response_body: serde_json::Value = response - .json() - .await - .map_err(|e| format!("图像接口响应解析失败: {e}"))?; - - if !status.is_success() { - let msg = response_body - .get("error") - .and_then(|v| v.get("message")) - .and_then(|v| v.as_str()) - .unwrap_or("图像生成失败"); - return Err(msg.to_string()); - } - - let (image_url, _b64, _revised) = - SocialGenerateCoverImageTool::extract_first_image_payload(&response_body)?; - - image_url.ok_or_else(|| "接口返回中未找到 image_url".to_string()) -} diff --git a/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs new file mode 100644 index 000000000..b25f7aeaa --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/action_runtime.rs @@ -0,0 +1,228 @@ +use super::*; + +/// 统一运行时:删除会话。 +#[tauri::command] +pub async fn agent_runtime_delete_session( + app: AppHandle, + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + session_id: String, +) -> Result<(), String> { + let trimmed_session_id = session_id.trim().to_string(); + let _ = state.cancel_session(&trimmed_session_id).await; + let _ = clear_runtime_queue_service(&app, &trimmed_session_id).await; + delete_runtime_session_internal(db.inner(), &trimmed_session_id).await +} + +/// 确认权限请求 +#[derive(Debug, Deserialize)] +struct ConfirmRequest { + request_id: String, + confirmed: bool, + #[allow(dead_code)] + response: Option, +} + +async fn confirm_runtime_action_internal( + state: &AsterAgentState, + request: ConfirmRequest, +) -> Result<(), String> { + tracing::info!( + "[AsterAgent] 确认请求: id={}, confirmed={}", + request.request_id, + request.confirmed + ); + + let permission = if request.confirmed { + Permission::AllowOnce + } else { + Permission::DenyOnce + }; + + let confirmation = PermissionConfirmation { + principal_type: PrincipalType::Tool, + permission, + }; + + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard.as_ref().ok_or("Agent not initialized")?; + agent + .handle_confirmation(request.request_id.clone(), confirmation) + .await; + + Ok(()) +} + +/// Elicitation 回填请求 +#[derive(Debug, Deserialize)] +struct SubmitElicitationResponseRequest { + request_id: String, + user_data: serde_json::Value, + #[serde(default)] + metadata: Option, +} + +pub(crate) fn validate_elicitation_submission( + session_id: &str, + request_id: &str, +) -> Result { + let trimmed_session_id = session_id.trim().to_string(); + if trimmed_session_id.is_empty() { + return Err("session_id 不能为空".to_string()); + } + if request_id.trim().is_empty() { + return Err("request_id 不能为空".to_string()); + } + Ok(trimmed_session_id) +} + +pub(crate) 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 + ); + } +} + +pub(crate) fn build_runtime_action_user_data( + request: &AgentRuntimeRespondActionRequest, +) -> serde_json::Value { + if let Some(user_data) = request.user_data.clone() { + return user_data; + } + + if !request.confirmed { + return serde_json::Value::String(String::new()); + } + + let Some(response) = request.response.as_ref() else { + return serde_json::Value::String(String::new()); + }; + let trimmed = response.trim(); + if trimmed.is_empty() { + return serde_json::Value::String(String::new()); + } + + serde_json::from_str(trimmed).unwrap_or_else(|_| serde_json::Value::String(trimmed.to_string())) +} + +/// 统一运行时:响应工具确认 / ask / elicitation。 +#[tauri::command] +pub async fn agent_runtime_respond_action( + app: AppHandle, + state: State<'_, AsterAgentState>, + request: AgentRuntimeRespondActionRequest, +) -> Result<(), String> { + match request.action_type { + AgentRuntimeActionType::ToolConfirmation => { + confirm_runtime_action_internal( + state.inner(), + ConfirmRequest { + request_id: request.request_id.clone(), + confirmed: request.confirmed, + response: request.response.clone(), + }, + ) + .await + } + 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(), + SubmitElicitationResponseRequest { + request_id: request.request_id.clone(), + user_data, + metadata: request.metadata.clone(), + }, + ) + .await + .map(|_| { + if let Some(event_name) = resume_event_name.as_deref() { + emit_action_resume_runtime_status(&app, event_name); + } + }) + } + } +} + +async fn submit_runtime_elicitation_response_internal( + state: &AsterAgentState, + session_id: String, + request: SubmitElicitationResponseRequest, +) -> Result<(), String> { + let session_id = validate_elicitation_submission(&session_id, &request.request_id)?; + + tracing::info!( + "[AsterAgent] 提交 elicitation 响应: session={}, request_id={}", + session_id, + request.request_id + ); + + let message = + Message::user().with_content(MessageContent::action_required_elicitation_response( + request.request_id.clone(), + request.user_data, + )); + + let mut session_config_builder = + SessionConfigBuilder::new(&session_id).include_context_trace(true); + if let Some(prompt) = + merge_system_prompt_with_elicitation_context(None, request.metadata.as_ref()) + { + session_config_builder = session_config_builder.system_prompt(prompt); + } + let session_config = session_config_builder.build(); + + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard.as_ref().ok_or("Agent not initialized")?; + + let mut stream = agent + .reply(message, session_config, None) + .await + .map_err(|e| format!("提交 elicitation 响应失败: {e}"))?; + + while let Some(event_result) = stream.next().await { + match event_result { + Ok(AgentEvent::Message(message)) => { + let text = message.as_concat_text(); + if text.contains("Failed to submit elicitation response") + || text.contains("Request not found") + { + return Err(format!("提交 elicitation 响应失败: {text}")); + } + } + Ok(_) => {} + Err(e) => { + return Err(format!("提交 elicitation 响应失败: {e}")); + } + } + } + + Ok(()) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs b/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs new file mode 100644 index 000000000..0df0ec310 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/browser_assist.rs @@ -0,0 +1,254 @@ +use super::*; + +pub(crate) const BROWSER_PROFILE_KEY_ENV_KEYS: &[&str] = + &["LIME_BROWSER_PROFILE_KEY", "PROXYCAST_BROWSER_PROFILE_KEY"]; +pub(crate) const BROWSER_ASSIST_ALLOW_PATTERN: &str = "mcp__lime-browser__*"; +const BROWSER_ASSIST_DENY_PATTERNS: &[&str] = &["mcp__playwright__*", "browser_*", "playwright*"]; + +static BROWSER_ASSIST_RUNTIME_HINTS: OnceLock< + tokio::sync::RwLock>, +> = OnceLock::new(); + +fn shared_browser_assist_runtime_hints( +) -> &'static tokio::sync::RwLock> { + BROWSER_ASSIST_RUNTIME_HINTS.get_or_init(|| tokio::sync::RwLock::new(HashMap::new())) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BrowserAssistRuntimeHint { + pub(crate) profile_key: String, + pub(crate) preferred_backend: Option, + pub(crate) auto_launch: bool, + pub(crate) launch_url: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BrowserTaskRequirement { + Optional, + Required, + RequiredWithUserStep, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RuntimeChatMode { + Agent, + Creator, + General, +} + +pub(crate) fn parse_browser_backend_hint(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "aster_compat" => Some(BrowserBackendType::AsterCompat), + "lime_extension_bridge" => Some(BrowserBackendType::LimeExtensionBridge), + "cdp_direct" => Some(BrowserBackendType::CdpDirect), + _ => None, + } +} + +pub(crate) fn extract_browser_assist_runtime_hint( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let browser_assist = + extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"])?; + let profile_key = ["profile_key", "profileKey"] + .iter() + .filter_map(|key| browser_assist.get(*key)) + .find_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(); + let preferred_backend = ["preferred_backend", "preferredBackend"] + .iter() + .filter_map(|key| browser_assist.get(*key)) + .find_map(serde_json::Value::as_str) + .and_then(parse_browser_backend_hint); + let auto_launch = ["auto_launch", "autoLaunch"] + .iter() + .filter_map(|key| browser_assist.get(*key)) + .find_map(serde_json::Value::as_bool) + .unwrap_or(false); + let launch_url = ["launch_url", "launchUrl", "url"] + .iter() + .filter_map(|key| browser_assist.get(*key)) + .find_map(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + Some(BrowserAssistRuntimeHint { + profile_key, + preferred_backend, + auto_launch, + launch_url, + }) +} + +pub(crate) fn is_browser_assist_enabled(request_metadata: Option<&serde_json::Value>) -> bool { + let Some(browser_assist) = + extract_harness_nested_object(request_metadata, &["browser_assist", "browserAssist"]) + else { + return false; + }; + + if let Some(enabled) = ["enabled", "is_enabled", "isEnabled"] + .iter() + .filter_map(|key| browser_assist.get(*key)) + .find_map(serde_json::Value::as_bool) + { + return enabled; + } + + extract_browser_assist_runtime_hint(request_metadata).is_some() || !browser_assist.is_empty() +} + +pub(crate) fn extract_browser_task_requirement( + request_metadata: Option<&serde_json::Value>, +) -> Option { + match extract_harness_string( + request_metadata, + &["browser_requirement", "browserRequirement"], + ) + .as_deref() + { + Some("optional") => Some(BrowserTaskRequirement::Optional), + Some("required") => Some(BrowserTaskRequirement::Required), + Some("required_with_user_step") => Some(BrowserTaskRequirement::RequiredWithUserStep), + _ => None, + } +} + +pub(crate) fn apply_browser_requirement_to_request_tool_policy( + request_metadata: Option<&serde_json::Value>, + request_web_search: Option, + request_search_mode: Option, +) -> (Option, Option) { + match extract_browser_task_requirement(request_metadata) { + Some(BrowserTaskRequirement::Required | BrowserTaskRequirement::RequiredWithUserStep) => { + (Some(false), Some(RequestToolPolicyMode::Disabled)) + } + _ => (request_web_search, request_search_mode), + } +} + +fn build_session_scoped_permission_conditions(session_id: &str) -> Vec { + let session_id = session_id.trim(); + if session_id.is_empty() { + return Vec::new(); + } + + vec![PermissionCondition { + condition_type: ConditionType::Session, + field: Some("session_id".to_string()), + operator: ConditionOperator::Equals, + value: serde_json::json!(session_id), + validator: None, + description: Some("仅对当前聊天会话生效".to_string()), + }] +} + +pub(crate) fn append_browser_assist_session_permissions( + permissions: &mut Vec, + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + if !is_browser_assist_enabled(request_metadata) { + return; + } + + let conditions = build_session_scoped_permission_conditions(session_id); + permissions.push(ToolPermission { + tool: BROWSER_ASSIST_ALLOW_PATTERN.to_string(), + allowed: true, + priority: 1100, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "Browser Assist 会话已启用:网页任务应统一走 Lime 浏览器运行时工具".to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + + for pattern in BROWSER_ASSIST_DENY_PATTERNS { + permissions.push(ToolPermission { + tool: (*pattern).to_string(), + allowed: false, + priority: 1200, + conditions: conditions.clone(), + parameter_restrictions: Vec::new(), + scope: PermissionScope::Session, + reason: Some( + "Browser Assist 会话禁止回退到 Playwright 浏览器工具;请改用 mcp__lime-browser__*,以便右侧画布附着实时浏览器会话" + .to_string(), + ), + expires_at: None, + metadata: HashMap::new(), + }); + } +} + +pub(crate) async fn sync_browser_assist_runtime_hint( + session_id: &str, + request_metadata: Option<&serde_json::Value>, +) { + let mut hints = shared_browser_assist_runtime_hints().write().await; + if let Some(hint) = extract_browser_assist_runtime_hint(request_metadata) { + hints.insert(session_id.to_string(), hint); + } else { + hints.remove(session_id); + } +} + +pub(crate) async fn get_browser_assist_runtime_hint( + session_id: &str, +) -> Option { + shared_browser_assist_runtime_hints() + .read() + .await + .get(session_id) + .cloned() +} + +pub(crate) fn resolve_runtime_chat_mode( + request_metadata: Option<&serde_json::Value>, +) -> RuntimeChatMode { + if let Some(chat_mode) = extract_harness_string(request_metadata, &["chat_mode", "chatMode"]) { + match chat_mode.as_str() { + "general" => return RuntimeChatMode::General, + "creator" => return RuntimeChatMode::Creator, + _ => {} + } + } + + match extract_harness_string(request_metadata, &["theme", "harness_theme"]).as_deref() { + Some("general" | "knowledge" | "planning") => RuntimeChatMode::General, + _ => RuntimeChatMode::Agent, + } +} + +pub(crate) fn runtime_chat_mode_label(mode: RuntimeChatMode) -> &'static str { + match mode { + RuntimeChatMode::Agent => "agent", + RuntimeChatMode::Creator => "creator", + RuntimeChatMode::General => "general", + } +} + +pub(crate) fn default_web_search_enabled_for_chat_mode(_chat_mode: RuntimeChatMode) -> bool { + false +} + +pub(crate) fn should_enable_model_skill_tool(request_metadata: Option<&serde_json::Value>) -> bool { + if let Some(explicit) = extract_harness_bool( + request_metadata, + &["allow_model_skills", "allowModelSkills"], + ) { + return explicit; + } + + matches!( + extract_harness_string(request_metadata, &["session_mode", "sessionMode"]).as_deref(), + Some("theme_workbench") + ) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api.rs new file mode 100644 index 000000000..6e61fc399 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/command_api.rs @@ -0,0 +1,59 @@ +use super::*; +use crate::commands::aster_agent_cmd::runtime_turn::build_runtime_queue_executor; +use crate::commands::aster_agent_cmd::session_runtime::{ + create_runtime_session_internal, list_runtime_sessions_internal, + rename_runtime_session_internal, update_runtime_session_execution_strategy_internal, +}; +use crate::commands::aster_agent_cmd::subagent_runtime::{ + agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal, + agent_runtime_send_subagent_input_internal, agent_runtime_spawn_subagent_internal, + agent_runtime_wait_subagents_internal, SubagentControlRuntime, +}; +use crate::commands::aster_agent_cmd::tool_runtime::ensure_tool_search_tool_registered; + +#[path = "command_api/provider_api.rs"] +pub(crate) mod provider_api; +#[path = "command_api/runtime_api.rs"] +pub(crate) mod runtime_api; +#[path = "command_api/session_api.rs"] +pub(crate) mod session_api; +#[path = "command_api/subagent_api.rs"] +pub(crate) mod subagent_api; + +fn build_subagent_control_runtime( + 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>, +) -> SubagentControlRuntime { + SubagentControlRuntime::new( + app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + ) +} + +pub(crate) use provider_api::{ + aster_agent_configure_from_pool, aster_agent_configure_provider, aster_agent_init, + aster_agent_reset, aster_agent_status, +}; +pub(crate) use runtime_api::{ + agent_runtime_get_session, agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn, + agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn, agent_runtime_submit_turn, +}; +pub(crate) use session_api::{ + agent_runtime_create_session, agent_runtime_list_sessions, agent_runtime_update_session, +}; +pub(crate) use subagent_api::{ + agent_runtime_close_subagent, agent_runtime_resume_subagent, agent_runtime_send_subagent_input, + agent_runtime_spawn_subagent, agent_runtime_wait_subagents, +}; diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/provider_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/provider_api.rs new file mode 100644 index 000000000..28ea3ae3a --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/provider_api.rs @@ -0,0 +1,149 @@ +use super::*; + +fn build_agent_status( + initialized: bool, + provider_config: Option, +) -> AsterAgentStatus { + AsterAgentStatus { + initialized, + provider_configured: provider_config.is_some(), + provider_name: provider_config.as_ref().map(|c| c.provider_name.clone()), + model_name: provider_config.as_ref().map(|c| c.model_name.clone()), + credential_uuid: provider_config.and_then(|c| c.credential_uuid), + } +} + +#[tauri::command] +pub async fn aster_agent_init( + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, +) -> Result { + tracing::info!("[AsterAgent] 初始化 Agent"); + + state.init_agent_with_db(&db).await?; + ensure_tool_search_tool_registered(state.inner()).await?; + + let provider_config = state.get_provider_config().await; + + tracing::info!("[AsterAgent] Agent 初始化成功"); + + Ok(build_agent_status(true, provider_config)) +} + +/// 配置 Aster Agent 的 Provider +#[tauri::command] +pub async fn aster_agent_configure_provider( + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + request: ConfigureProviderRequest, + session_id: String, +) -> Result { + tracing::info!( + "[AsterAgent] 配置 Provider: {} / {}", + request.provider_name, + 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, + credential_path: None, + }; + + 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, + provider_configured: true, + provider_name: Some(config.provider_name), + model_name: Some(config.model_name), + credential_uuid: None, + }) +} + +/// 从凭证池配置 Aster Agent 的 Provider +/// +/// 自动从 Lime 凭证池选择可用凭证并配置 Aster Provider +#[tauri::command] +pub async fn aster_agent_configure_from_pool( + state: State<'_, AsterAgentState>, + db: State<'_, DbConnection>, + request: ConfigureFromPoolRequest, + session_id: String, +) -> Result { + tracing::info!( + "[AsterAgent] 从凭证池配置 Provider: {} / {}", + request.provider_type, + request.model_name + ); + + let aster_config = state + .configure_provider_from_pool( + &db, + &request.provider_type, + &request.model_name, + &session_id, + ) + .await?; + persist_session_provider_routing(&session_id, &request.provider_type).await?; + + Ok(AsterAgentStatus { + initialized: true, + provider_configured: true, + provider_name: Some(aster_config.provider_name), + model_name: Some(aster_config.model_name), + credential_uuid: Some(aster_config.credential_uuid), + }) +} + +/// 获取 Aster Agent 状态 +#[tauri::command] +pub async fn aster_agent_status( + state: State<'_, AsterAgentState>, +) -> Result { + let provider_config = state.get_provider_config().await; + Ok(build_agent_status( + state.is_initialized().await, + provider_config, + )) +} + +/// 重置 Aster Agent +/// +/// 清除当前 Provider 配置,下次对话时会重新从凭证池选择凭证。 +/// 用于切换凭证后无需重启应用即可生效。 +#[tauri::command] +pub async fn aster_agent_reset( + state: State<'_, AsterAgentState>, +) -> Result { + tracing::info!("[AsterAgent] 重置 Agent Provider 配置"); + + state.clear_provider_config().await; + + Ok(AsterAgentStatus { + initialized: state.is_initialized().await, + provider_configured: false, + provider_name: None, + model_name: None, + credential_uuid: None, + }) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs new file mode 100644 index 000000000..82a7e567f --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs @@ -0,0 +1,254 @@ +use super::*; + +#[tauri::command] +pub async fn agent_runtime_submit_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: AgentRuntimeSubmitTurnRequest, +) -> Result<(), String> { + let runtime_request: AsterChatRequest = request.into(); + let queue_if_busy = runtime_request.queue_if_busy.unwrap_or(false); + let queued_task = build_queued_turn_task(runtime_request)?; + submit_runtime_turn_service( + app, + state.inner(), + db.inner(), + api_key_provider_service.inner(), + logs.inner(), + config_manager.inner(), + mcp_manager.inner(), + automation_state.inner(), + queued_task, + queue_if_busy, + build_runtime_queue_executor(), + ) + .await +} + +/// 统一运行时:中断当前 turn。 +#[tauri::command] +pub async fn agent_runtime_interrupt_turn( + app: AppHandle, + state: State<'_, AsterAgentState>, + request: AgentRuntimeInterruptTurnRequest, +) -> Result { + let session_id = request.session_id; + let cancelled = state.cancel_session(&session_id).await; + let cleared = clear_runtime_queue_service(&app, &session_id).await?; + Ok(cancelled || !cleared.is_empty()) +} + +/// 统一运行时:获取会话详情。 +#[tauri::command] +pub async fn agent_runtime_get_session( + 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>, + session_id: String, +) -> Result { + tracing::info!("[AsterAgent] 获取运行时会话: {}", session_id); + let detail = AsterAgentWrapper::get_runtime_session_detail(db.inner(), &session_id).await?; + + if let Err(error) = 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.clone(), + build_runtime_queue_executor(), + ) + .await + { + tracing::warn!( + "[AsterAgent][Queue] 获取会话后恢复排队执行失败: session_id={}, error={}", + session_id, + error + ); + } + + let queued_turns = list_runtime_queue_snapshots_service(&session_id).await?; + Ok(AgentRuntimeSessionDetail::from_session_detail( + detail, + queued_turns, + )) +} + +/// 统一运行时:获取工具库存快照。 +#[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( + app: AppHandle, + request: AgentRuntimeRemoveQueuedTurnRequest, +) -> 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); + } + + 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) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs new file mode 100644 index 000000000..a8c4ee53e --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs @@ -0,0 +1,76 @@ +use super::*; + +/// 创建新会话 +#[tauri::command] +pub async fn agent_runtime_create_session( + db: State<'_, DbConnection>, + workspace_id: String, + name: Option, + execution_strategy: Option, +) -> Result { + create_runtime_session_internal(db.inner(), None, workspace_id, name, execution_strategy).await +} + +#[tauri::command] +pub async fn agent_runtime_list_sessions( + db: State<'_, DbConnection>, + logs: State<'_, LogState>, +) -> Result, String> { + let started_at = Instant::now(); + logs.write() + .await + .add("info", "[AgentDiag] agent_runtime_list_sessions.start"); + + match list_runtime_sessions_internal(db.inner()) { + Ok(sessions) => { + logs.write().await.add( + "info", + &format!( + "[AgentDiag] agent_runtime_list_sessions.success duration_ms={} sessions={}", + started_at.elapsed().as_millis(), + sessions.len() + ), + ); + Ok(sessions) + } + Err(error) => { + logs.write().await.add( + "error", + &format!( + "[AgentDiag] agent_runtime_list_sessions.error duration_ms={} error={}", + started_at.elapsed().as_millis(), + crate::logger::sanitize_log_message(&error) + ), + ); + Err(error) + } + } +} + +#[tauri::command] +pub async fn agent_runtime_update_session( + db: State<'_, DbConnection>, + request: AgentRuntimeUpdateSessionRequest, +) -> Result<(), String> { + let trimmed_session_id = request.session_id.trim().to_string(); + if trimmed_session_id.is_empty() { + return Err("session_id 不能为空".to_string()); + } + + if let Some(name) = request.name.as_ref() { + let normalized_name = name.trim(); + if !normalized_name.is_empty() { + rename_runtime_session_internal(db.inner(), &trimmed_session_id, normalized_name)?; + } + } + + if let Some(execution_strategy) = request.execution_strategy { + update_runtime_session_execution_strategy_internal( + db.inner(), + &trimmed_session_id, + execution_strategy, + )?; + } + + Ok(()) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/subagent_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/subagent_api.rs new file mode 100644 index 000000000..2440098e3 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/subagent_api.rs @@ -0,0 +1,141 @@ +use super::*; + +#[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( + &build_subagent_control_runtime( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ), + 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( + &build_subagent_control_runtime( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ), + 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( + &build_subagent_control_runtime( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ), + 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( + &build_subagent_control_runtime( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ), + 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( + &build_subagent_control_runtime( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ), + request, + ) + .await +} diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs new file mode 100644 index 000000000..df23d7b85 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -0,0 +1,415 @@ +use super::*; + +/// Aster Agent 状态信息 +#[derive(Debug, Serialize)] +pub struct AsterAgentStatus { + pub initialized: bool, + pub provider_configured: bool, + pub provider_name: Option, + pub model_name: Option, + /// 凭证 UUID(来自凭证池) + #[serde(skip_serializing_if = "Option::is_none")] + pub credential_uuid: Option, +} + +/// Provider 配置请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigureProviderRequest { + #[serde(default)] + pub provider_id: Option, + pub provider_name: String, + pub model_name: String, + #[serde(default)] + pub api_key: Option, + #[serde(default)] + pub base_url: Option, +} + +/// 从凭证池配置 Provider 的请求 +#[derive(Debug, Deserialize)] +pub struct ConfigureFromPoolRequest { + /// Provider 类型 (openai, anthropic, kiro, gemini 等) + pub provider_type: String, + /// 模型名称 + 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, +} + +/// 发送消息请求参数 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AsterChatRequest { + pub message: String, + #[serde(alias = "sessionId")] + pub session_id: String, + #[serde(alias = "eventName")] + pub event_name: String, + #[serde(default)] + #[allow(dead_code)] + pub images: Option>, + /// Provider 配置(可选,如果未配置则使用当前配置) + #[serde(default, alias = "providerConfig")] + pub provider_config: Option, + /// 项目 ID(可选,用于注入项目上下文到 System Prompt) + #[serde(default, alias = "projectId")] + pub project_id: Option, + /// Workspace ID(必填,用于校验会话与工作区一致性) + #[serde(alias = "workspaceId")] + pub workspace_id: String, + /// 是否强制开启联网搜索工具策略 + #[serde(default, alias = "webSearch")] + pub web_search: Option, + /// 联网搜索模式(disabled / allowed / required) + #[serde(default, alias = "searchMode")] + pub search_mode: Option, + /// 执行策略(react / code_orchestrated / auto) + #[serde(default, alias = "executionStrategy")] + pub execution_strategy: Option, + /// 自动续写策略(用于文稿续写等场景) + #[serde(default, alias = "autoContinue")] + pub auto_continue: Option, + /// 前端传入的 System Prompt(可选,优先级低于项目上下文) + #[serde(default, alias = "systemPrompt")] + pub system_prompt: Option, + /// 请求级元数据(可选,用于 harness / 主题工作台状态对齐) + #[serde(default)] + pub metadata: Option, + /// 回合 ID(可选,由前端提供时透传到 Aster runtime) + #[serde(default, alias = "turnId")] + pub turn_id: Option, + /// 会话忙时是否进入后端队列 + #[serde(default, alias = "queueIfBusy")] + pub queue_if_busy: Option, + /// 队列项 ID(由前端或后端生成) + #[serde(default, alias = "queuedTurnId")] + pub queued_turn_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentTurnConfigSnapshot { + #[serde(default, alias = "providerConfig")] + pub provider_config: Option, + #[serde(default, alias = "executionStrategy")] + pub execution_strategy: Option, + #[serde(default, alias = "webSearch")] + pub web_search: Option, + #[serde(default, alias = "searchMode")] + pub search_mode: Option, + #[serde(default, alias = "autoContinue")] + pub auto_continue: Option, + #[serde(default, alias = "systemPrompt")] + pub system_prompt: Option, + #[serde(default)] + pub metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRuntimeSubmitTurnRequest { + pub message: String, + #[serde(alias = "sessionId")] + pub session_id: String, + #[serde(alias = "eventName")] + pub event_name: String, + #[serde(default)] + pub images: Option>, + #[serde(alias = "workspaceId")] + pub workspace_id: String, + #[serde(default, alias = "turnConfig")] + pub turn_config: Option, + #[serde(default, alias = "turnId")] + #[allow(dead_code)] + pub turn_id: Option, + #[serde(default, alias = "queueIfBusy")] + pub queue_if_busy: Option, + #[serde(default, alias = "queuedTurnId")] + pub queued_turn_id: Option, +} + +impl From for AsterChatRequest { + fn from(request: AgentRuntimeSubmitTurnRequest) -> Self { + let turn_config = request.turn_config; + Self { + message: request.message, + session_id: request.session_id, + event_name: request.event_name, + images: request.images, + provider_config: turn_config + .as_ref() + .and_then(|config| config.provider_config.clone()), + project_id: None, + workspace_id: request.workspace_id, + web_search: turn_config.as_ref().and_then(|config| config.web_search), + search_mode: turn_config.as_ref().and_then(|config| config.search_mode), + execution_strategy: turn_config + .as_ref() + .and_then(|config| config.execution_strategy), + auto_continue: turn_config + .as_ref() + .and_then(|config| config.auto_continue.clone()), + system_prompt: turn_config + .as_ref() + .and_then(|config| config.system_prompt.clone()), + metadata: turn_config.and_then(|config| config.metadata), + turn_id: request.turn_id, + queue_if_busy: request.queue_if_busy, + queued_turn_id: request.queued_turn_id, + } + } +} + +#[derive(Debug, Deserialize)] +pub struct AgentRuntimeInterruptTurnRequest { + #[serde(alias = "sessionId")] + pub session_id: String, + #[serde(default, alias = "turnId")] + #[allow(dead_code)] + pub turn_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AgentRuntimeRemoveQueuedTurnRequest { + #[serde(alias = "sessionId")] + pub session_id: String, + #[serde(alias = "queuedTurnId")] + 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, + pub name: String, + pub created_at: i64, + pub updated_at: i64, + pub thread_id: String, + pub messages: Vec, + pub execution_strategy: Option, + pub turns: Vec, + pub items: Vec, + #[serde(default)] + 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 { + pub(crate) fn from_session_detail( + detail: SessionDetail, + queued_turns: Vec, + ) -> Self { + Self { + id: detail.id, + name: detail.name, + created_at: detail.created_at, + updated_at: detail.updated_at, + thread_id: detail.thread_id, + messages: detail.messages, + execution_strategy: detail.execution_strategy, + turns: detail.turns, + 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 { + ToolConfirmation, + AskUser, + Elicitation, +} + +#[derive(Debug, Deserialize)] +pub struct AgentRuntimeRespondActionRequest { + #[serde(alias = "sessionId")] + pub session_id: String, + #[serde(alias = "requestId")] + pub request_id: String, + #[serde(alias = "actionType")] + pub action_type: AgentRuntimeActionType, + pub confirmed: bool, + #[serde(default)] + pub response: Option, + #[serde(default, alias = "userData")] + pub user_data: Option, + #[serde(default)] + pub metadata: Option, + #[serde(default, alias = "eventName")] + pub event_name: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AgentRuntimeUpdateSessionRequest { + #[serde(alias = "sessionId")] + pub session_id: String, + #[serde(default)] + pub name: Option, + #[serde(default, alias = "executionStrategy")] + pub execution_strategy: Option, +} + +/// 自动续写参数 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AutoContinuePayload { + /// 主开关 + pub enabled: bool, + /// 快速模式 + #[serde(default, alias = "fastModeEnabled")] + pub fast_mode_enabled: bool, + /// 续写长度:0=短、1=中、2=长 + #[serde(default, alias = "continuationLength")] + pub continuation_length: u8, + /// 灵敏度:0-100 + #[serde(default)] + pub sensitivity: u8, + /// 来源标识 + #[serde(default)] + pub source: Option, +} + +impl AutoContinuePayload { + pub(crate) fn normalized(mut self) -> Self { + self.continuation_length = self.continuation_length.min(2); + self.sensitivity = self.sensitivity.min(100); + self.source = self + .source + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + self + } + + pub(crate) fn length_instruction(&self) -> &'static str { + match self.continuation_length.min(2) { + 0 => "短(补全 1-2 段,聚焦核心信息)", + 1 => "中(补全 3-5 段,兼顾结构与细节)", + _ => "长(扩展为可发布草稿,结构完整)", + } + } + + pub(crate) fn sensitivity_instruction(&self) -> &'static str { + match self.sensitivity.min(100) { + 0..=33 => "低:优先稳健延续原文表达", + 34..=66 => "中:保持一致性并适度优化表达", + _ => "高:在不偏题前提下积极补充观点亮点", + } + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/mcp_bridge.rs b/src-tauri/src/commands/aster_agent_cmd/mcp_bridge.rs new file mode 100644 index 000000000..029293fed --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/mcp_bridge.rs @@ -0,0 +1,197 @@ +use super::*; + +pub(crate) async fn inject_mcp_extensions( + state: &AsterAgentState, + mcp_manager: &McpManagerState, +) -> (usize, usize) { + let manager = mcp_manager.lock().await; + let running_servers = manager.get_running_servers().await; + + if running_servers.is_empty() { + tracing::debug!("[AsterAgent] 没有运行中的 MCP servers,跳过注入"); + return (0, 0); + } + + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = match guard.as_ref() { + Some(a) => a, + None => { + tracing::warn!("[AsterAgent] Agent 未初始化,无法注入 MCP extensions"); + return (0, running_servers.len()); + } + }; + + 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; + + for server_name in &running_servers { + // 检查是否已注册(避免重复注册) + let ext_configs = agent.get_extension_configs().await; + if ext_configs.iter().any(|c| c.name() == *server_name) { + tracing::debug!("[AsterAgent] MCP extension '{}' 已注册,跳过", server_name); + success_count += 1; + continue; + } + + 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 注入结果: {} 成功, {} 失败", + success_count, + fail_count + ); + } else { + tracing::info!( + "[AsterAgent] MCP 注入完成: {} 个 extension 全部成功", + success_count + ); + } + + (success_count, fail_count) +} + +/// 确保 Lime 可用的 MCP servers 已启动 +/// +/// 启动启用了 `enabled_lime` 的服务器。 +pub(crate) async fn ensure_lime_mcp_servers_running( + db: &DbConnection, + mcp_manager: &McpManagerState, +) -> (usize, usize) { + let servers = match McpService::get_all(db) { + Ok(items) => items, + Err(e) => { + tracing::warn!("[AsterAgent] 读取 MCP 配置失败,跳过自动启动: {}", e); + return (0, 0); + } + }; + + if servers.is_empty() { + return (0, 0); + } + + let candidates: Vec<&crate::models::mcp_model::McpServer> = + servers.iter().filter(|s| s.enabled_lime).collect(); + + if candidates.is_empty() { + return (0, 0); + } + + let manager = mcp_manager.lock().await; + let mut success_count = 0usize; + let mut fail_count = 0usize; + + for server in candidates { + if manager.is_server_running(&server.name).await { + continue; + } + + let parsed = server.parse_config(); + let config = McpServerConfig { + command: parsed.command, + args: parsed.args, + env: parsed.env, + cwd: parsed.cwd, + timeout: parsed.timeout, + }; + + match manager.start_server(&server.name, &config).await { + Ok(_) => { + tracing::info!("[AsterAgent] MCP server 已自动启动: {}", server.name); + success_count += 1; + } + Err(e) => { + tracing::error!( + "[AsterAgent] MCP server 自动启动失败: {} => {}", + server.name, + e + ); + fail_count += 1; + } + } + } + + (success_count, fail_count) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs new file mode 100644 index 000000000..890a21d5a --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -0,0 +1,471 @@ +//! Aster Agent 命令模块 +//! +//! 提供基于 Aster 框架的 Tauri 命令 +//! 这是新的对话系统实现,与 native_agent_cmd.rs 并行存在 +//! 支持从 Lime 凭证池自动选择凭证 + +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, 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::{ + browser_execute_action_global, ensure_managed_chrome_profile_global, BrowserActionRequest, + BrowserBackendType, +}; +use crate::config::{GlobalConfigManager, GlobalConfigManagerState}; +use crate::database::DbConnection; +use crate::mcp::{McpManagerState, McpServerConfig}; +use crate::services::agent_timeline_service::AgentTimelineRecorder; +use crate::services::automation_service::AutomationServiceState; +use crate::services::execution_tracker_service::{ExecutionTracker, RunFinishDecision, RunSource}; +use crate::services::memory_profile_prompt_service::{ + merge_system_prompt_with_memory_context, MemoryPromptContext, +}; +use crate::services::web_search_prompt_service::merge_system_prompt_with_web_search; +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::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, 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::extension_data::{ExtensionData, ExtensionState}; +use aster::session::{ + list_subagent_child_sessions, require_shared_thread_runtime_store, + resolve_subagent_session_metadata, SessionManager, SessionType, SubagentSessionMetadata, +}; +use aster::tools::task_output_tool::TaskOutputInput; +use aster::tools::{ + BashTool, KillShellTool, PermissionBehavior, PermissionCheckResult, TaskManager, + TaskOutputTool, TaskTool, Tool, ToolContext, ToolError, ToolOptions, ToolResult, + MAX_OUTPUT_LENGTH, +}; +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::{ + merge_system_prompt_with_request_tool_policy, resolve_request_tool_policy_with_mode, + stream_message_reply_with_policy, ReplyAttemptError, RequestToolPolicy, RequestToolPolicyMode, +}; +use lime_agent::{ + 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, + merge_system_prompt_with_runtime_agents, message_suggests_news_expansion, + read_subagent_control_state, resolve_virtual_memory_path, summarize_builtin_skill, + virtual_memory_relative_path, write_subagent_control_state, ProviderContinuationCapability, + ProviderContinuationCapable, ProviderContinuationState, RuntimeProjectionSnapshot, + SessionStateSnapshot, SubagentControlState, SubagentCustomizationState, SubagentRuntimeStatus, + SubagentRuntimeStatusKind, SubagentSkillPromptBlock, SubagentSkillSummary, TauriRuntimeStatus, + TurnInputEnvelopeBuilder, TurnPromptAugmentationStageKind, TurnProviderRoutingSnapshot, + TurnRequestToolPolicySnapshot, TurnState, TurnSystemPromptSource, DURABLE_MEMORY_VIRTUAL_ROOT, +}; +use lime_services::api_key_provider_service::ApiKeyProviderService; +use lime_services::mcp_service::McpService; +use lime_services::video_generation_service::{ + CreateVideoGenerationRequest, VideoGenerationService, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter, State}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +const DEFAULT_BASH_TIMEOUT_SECS: u64 = 300; +const MAX_BASH_TIMEOUT_SECS: u64 = 1800; +const CODE_EXECUTION_EXTENSION_NAME: &str = "code_execution"; +const WORKSPACE_SANDBOX_ENABLED_ENV_KEYS: &[&str] = &[ + "LIME_WORKSPACE_SANDBOX_ENABLED", + "PROXYCAST_WORKSPACE_SANDBOX_ENABLED", +]; +const WORKSPACE_SANDBOX_STRICT_ENV_KEYS: &[&str] = &[ + "LIME_WORKSPACE_SANDBOX_STRICT", + "PROXYCAST_WORKSPACE_SANDBOX_STRICT", +]; +const WORKSPACE_SANDBOX_NOTIFY_ENV_KEYS: &[&str] = &[ + "LIME_WORKSPACE_SANDBOX_NOTIFY_ON_FALLBACK", + "PROXYCAST_WORKSPACE_SANDBOX_NOTIFY_ON_FALLBACK", +]; +const WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE: &str = "workspace_sandbox_fallback"; +const WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE: &str = "workspace_path_auto_created"; +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 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] = + &["LIME_FORCE_REACT_HINTS", "PROXYCAST_FORCE_REACT_HINTS"]; +const CODE_ORCHESTRATED_HINT_ENV_KEYS: &[&str] = &[ + "LIME_CODE_ORCHESTRATED_HINTS", + "PROXYCAST_CODE_ORCHESTRATED_HINTS", +]; + +static SHARED_TASK_MANAGER: OnceLock> = OnceLock::new(); + +fn shared_task_manager() -> Arc { + SHARED_TASK_MANAGER + .get_or_init(|| Arc::new(TaskManager::new())) + .clone() +} + +#[derive(Debug, Clone, Copy)] +struct WorkspaceSandboxPolicy { + enabled: bool, + strict: bool, + notify_on_fallback: bool, +} + +#[derive(Debug)] +pub(crate) enum WorkspaceSandboxApplyOutcome { + Applied { + sandbox_type: String, + }, + DisabledByConfig, + UnavailableFallback { + warning_message: String, + notify_user: bool, + }, +} + +fn parse_bool_env(names: &[&str]) -> Option { + lime_core::env_compat::bool_var(names) +} + +fn resolve_workspace_sandbox_policy( + config_manager: &GlobalConfigManagerState, +) -> WorkspaceSandboxPolicy { + let config = config_manager.config(); + let mut policy = WorkspaceSandboxPolicy { + enabled: config.agent.workspace_sandbox.enabled, + strict: config.agent.workspace_sandbox.strict, + notify_on_fallback: config.agent.workspace_sandbox.notify_on_fallback, + }; + + if let Some(enabled) = parse_bool_env(WORKSPACE_SANDBOX_ENABLED_ENV_KEYS) { + policy.enabled = enabled; + } + if let Some(strict) = parse_bool_env(WORKSPACE_SANDBOX_STRICT_ENV_KEYS) { + policy.strict = strict; + } + if let Some(notify) = parse_bool_env(WORKSPACE_SANDBOX_NOTIFY_ENV_KEYS) { + policy.notify_on_fallback = notify; + } + + policy +} + +fn workspace_sandbox_platform_hint() -> &'static str { + #[cfg(target_os = "windows")] + { + "Windows 当前未检测到可用本地 sandbox 执行器,建议关闭该选项或使用非严格模式。" + } + #[cfg(target_os = "macos")] + { + "macOS 需提供 sandbox-exec。" + } + #[cfg(target_os = "linux")] + { + "Linux 需安装 bwrap 或 firejail。" + } + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + "当前平台暂未集成本地 sandbox 执行器,建议关闭该选项。" + } +} + +fn build_workspace_sandbox_warning_message(reason: &str) -> String { + format!("已启用 workspace 本地 sandbox,但当前环境不可用,已自动降级为普通执行。原因: {reason}") +} + +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) + } +} + +pub(crate) mod action_runtime; +mod browser_assist; +pub(crate) mod command_api; +mod dto; +mod mcp_bridge; +mod prompt_context; +mod reply_runtime; +mod run_metadata; +mod runtime_turn; +mod session_runtime; +mod subagent_runtime; +pub(crate) mod tool_runtime; +#[cfg(test)] +use self::subagent_runtime::{ + build_subagent_customization_state, build_subagent_customization_system_prompt, + subagent_counts_toward_team_limit, +}; +#[cfg(test)] +use self::tool_runtime::{ + encode_tool_result_for_harness_observability, normalize_params_for_durable_memory_support, + normalize_shell_command_params, normalize_workspace_tool_permission_behavior, +}; +#[cfg(test)] +include!("tests.rs"); + +pub(crate) use action_runtime::{agent_runtime_delete_session, agent_runtime_respond_action}; +#[cfg(test)] +pub(crate) use action_runtime::{ + build_action_resume_runtime_status, build_runtime_action_user_data, + validate_elicitation_submission, +}; +pub(crate) use browser_assist::{ + append_browser_assist_session_permissions, apply_browser_requirement_to_request_tool_policy, + default_web_search_enabled_for_chat_mode, extract_browser_task_requirement, + get_browser_assist_runtime_hint, is_browser_assist_enabled, parse_browser_backend_hint, + resolve_runtime_chat_mode, runtime_chat_mode_label, should_enable_model_skill_tool, + sync_browser_assist_runtime_hint, BrowserAssistRuntimeHint, BrowserTaskRequirement, + RuntimeChatMode, BROWSER_PROFILE_KEY_ENV_KEYS, +}; +#[cfg(test)] +pub(crate) use browser_assist::{ + extract_browser_assist_runtime_hint, BROWSER_ASSIST_ALLOW_PATTERN, +}; +#[allow(unused_imports)] +pub(crate) use command_api::{ + agent_runtime_close_subagent, agent_runtime_create_session, agent_runtime_get_session, + agent_runtime_get_tool_inventory, agent_runtime_interrupt_turn, agent_runtime_list_sessions, + agent_runtime_promote_queued_turn, agent_runtime_remove_queued_turn, + agent_runtime_resume_subagent, agent_runtime_send_subagent_input, agent_runtime_spawn_subagent, + agent_runtime_submit_turn, agent_runtime_update_session, agent_runtime_wait_subagents, + aster_agent_configure_from_pool, aster_agent_configure_provider, aster_agent_init, + aster_agent_reset, aster_agent_status, +}; +pub(crate) use dto::{ + AgentRuntimeActionType, AgentRuntimeCloseSubagentRequest, AgentRuntimeCloseSubagentResponse, + AgentRuntimeInterruptTurnRequest, AgentRuntimePromoteQueuedTurnRequest, + AgentRuntimeRemoveQueuedTurnRequest, AgentRuntimeRespondActionRequest, + AgentRuntimeResumeSubagentRequest, AgentRuntimeResumeSubagentResponse, + AgentRuntimeSendSubagentInputRequest, AgentRuntimeSendSubagentInputResponse, + AgentRuntimeSessionDetail, AgentRuntimeSpawnSubagentRequest, AgentRuntimeSpawnSubagentResponse, + AgentRuntimeSubmitTurnRequest, AgentRuntimeToolInventoryRequest, + AgentRuntimeUpdateSessionRequest, AgentRuntimeWaitSubagentsRequest, + AgentRuntimeWaitSubagentsResponse, AsterAgentStatus, AsterChatRequest, AutoContinuePayload, + ConfigureFromPoolRequest, ConfigureProviderRequest, +}; +pub(crate) use mcp_bridge::{ensure_lime_mcp_servers_running, inject_mcp_extensions}; +#[cfg(test)] +pub(crate) use prompt_context::build_team_preference_system_prompt; +pub(crate) use prompt_context::{ + merge_system_prompt_with_auto_continue, merge_system_prompt_with_elicitation_context, + merge_system_prompt_with_team_preference, +}; +#[cfg(test)] +use reply_runtime::message_suggests_live_search; +use reply_runtime::{ + build_runtime_user_message, build_turn_runtime_statuses, complete_runtime_status_projection, + emit_runtime_status_with_projection, ensure_code_execution_extension_enabled, + should_fallback_to_react_from_code_orchestrated, stream_reply_once, +}; +use run_metadata::{ + build_chat_run_finish_metadata, build_chat_run_metadata_base, extract_harness_array, + extract_harness_bool, extract_harness_nested_object, extract_harness_string, + load_previous_provider_continuation_state, ChatRunObservation, +}; +#[cfg(test)] +use run_metadata::{ + extract_artifact_path_from_tool_start, provider_routing_matches_current, + resolve_social_run_artifact_descriptor, +}; +pub(crate) use runtime_turn::{build_queued_turn_task, build_runtime_queue_executor}; +pub(crate) use session_runtime::{ + delete_runtime_session_internal, persist_session_provider_routing, + resolve_session_provider_selector, +}; +pub(crate) use subagent_runtime::{ + agent_runtime_close_subagent_internal, agent_runtime_resume_subagent_internal, + agent_runtime_send_subagent_input_internal, agent_runtime_spawn_subagent_internal, + agent_runtime_wait_subagents_internal, emit_subagent_status_changed_events, + maybe_emit_subagent_status_for_runtime_event, SubagentControlRuntime, +}; +#[allow(unused_imports)] +pub(crate) use tool_runtime::social_generate_cover_image_cmd; +pub(crate) use tool_runtime::{apply_workspace_sandbox_permissions, ImageInput}; +#[cfg(test)] +#[allow(unused_imports)] +pub(crate) use tool_runtime::{ + build_subagent_task_definition, build_subagent_task_runtime_message, + collect_subagent_task_compat_warnings, extract_runtime_subagent_result_text, + parse_subagent_role, LimeBrowserMcpTool, SocialGenerateCoverImageTool, SubAgentTaskToolInput, + ToolSearchBridgeTool, +}; +pub(crate) use tool_runtime::{ + ensure_browser_mcp_tools_registered, ensure_creation_task_tools_registered, + ensure_social_image_tool_registered, ensure_tool_search_tool_registered, +}; + +pub async fn resume_persisted_runtime_queues_on_startup( + app: AppHandle, + state: &AsterAgentState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + logs: &LogState, + config_manager: &GlobalConfigManagerState, + mcp_manager: &McpManagerState, + automation_state: &AutomationServiceState, +) -> Result { + resume_persisted_runtime_queues_on_startup_service( + app, + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + build_runtime_queue_executor(), + ) + .await +} + +/// Agent 执行策略 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[derive(Default)] +pub enum AsterExecutionStrategy { + React, + CodeOrchestrated, + #[default] + Auto, +} + +impl AsterExecutionStrategy { + fn as_db_value(self) -> &'static str { + match self { + Self::React => "react", + Self::CodeOrchestrated => "code_orchestrated", + Self::Auto => "auto", + } + } + + fn from_db_value(value: Option<&str>) -> Self { + match value { + Some("code_orchestrated") => Self::CodeOrchestrated, + Some("auto") => Self::Auto, + _ => Self::Auto, + } + } + + fn effective_for_message(self, message: &str) -> Self { + if should_force_react_for_message(message) { + return Self::React; + } + + match self { + Self::Auto if should_use_code_orchestrated_for_message(message) => { + Self::CodeOrchestrated + } + Self::Auto => Self::React, + _ => self, + } + } +} + +fn should_force_react_for_message(message: &str) -> bool { + let lowered = message.to_lowercase(); + let default_hints = [ + "tool_search", + "调用 tool_search", + "调用tool_search", + "use tool_search", + "call tool_search", + "websearch", + "web search", + "web_search", + "webfetch", + "web fetch", + "web_fetch", + ]; + resolve_intent_hints(FORCE_REACT_HINT_ENV_KEYS, &default_hints) + .iter() + .any(|kw| lowered.contains(kw)) +} + +fn should_use_code_orchestrated_for_message(message: &str) -> bool { + let lowered = message.to_lowercase(); + // 默认不做消息关键词硬编码推断,Auto 模式优先走 ReAct。 + // 如需启用自动切换,可通过环境变量 LIME_CODE_ORCHESTRATED_HINTS 显式配置。 + resolve_intent_hints(CODE_ORCHESTRATED_HINT_ENV_KEYS, &[]) + .iter() + .any(|kw| lowered.contains(kw)) +} + +fn resolve_intent_hints(env_keys: &[&str], defaults: &[&str]) -> Vec { + if let Some(raw) = lime_core::env_compat::var(env_keys) { + let parsed = raw + .split(',') + .map(|item| item.trim().to_lowercase()) + .filter(|item| !item.is_empty()) + .collect::>(); + if !parsed.is_empty() { + return parsed; + } + } + + defaults.iter().map(|item| item.to_string()).collect() +} diff --git a/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs new file mode 100644 index 000000000..dc16c010c --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/prompt_context.rs @@ -0,0 +1,336 @@ +use super::*; + +fn build_auto_continue_system_prompt(config: &AutoContinuePayload) -> String { + let mode_instruction = if config.fast_mode_enabled { + "快速模式:优先产出可用结果,减少解释与冗余。" + } else { + "标准模式:兼顾可读性、完整性与发布可用性。" + }; + let source = config + .source + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("document_canvas"); + + format!( + "{AUTO_CONTINUE_PROMPT_MARKER}\n\ +执行来源:{source}\n\ +执行要求:\n\ +1. 本轮任务是“基于已有文稿的续写”,不得重复已有内容。\n\ +2. 从现有结尾自然衔接,保持原文语气、受众和主题方向。\n\ +3. 续写长度:{}。\n\ +4. 灵敏度({}%):{}。\n\ +5. {}\n\ +6. 输出正文时不要显式提及你看到了该策略配置。", + config.length_instruction(), + config.sensitivity, + config.sensitivity_instruction(), + mode_instruction, + ) +} + +pub(crate) fn merge_system_prompt_with_auto_continue( + base_prompt: Option, + auto_continue: Option<&AutoContinuePayload>, +) -> Option { + let Some(config) = auto_continue else { + return base_prompt; + }; + if !config.enabled { + return base_prompt; + } + + let auto_continue_prompt = build_auto_continue_system_prompt(config); + + match base_prompt { + Some(base) => { + if base.contains(AUTO_CONTINUE_PROMPT_MARKER) { + Some(base) + } else if base.trim().is_empty() { + Some(auto_continue_prompt) + } else { + Some(format!("{base}\n\n{auto_continue_prompt}")) + } + } + None => Some(auto_continue_prompt), + } +} + +fn build_elicitation_context_system_prompt( + request_metadata: Option<&serde_json::Value>, +) -> Option { + let metadata = request_metadata?.as_object()?; + let context = metadata.get("elicitation_context")?.as_object()?; + let entries = context.get("entries")?.as_array()?; + + let rendered_entries = entries + .iter() + .filter_map(|entry| { + let entry_object = entry.as_object()?; + let label = entry_object + .get("label") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let summary = entry_object + .get("summary") + .or_else(|| entry_object.get("value")) + .and_then(render_elicitation_context_value)?; + Some(format!("- {label}: {summary}")) + }) + .collect::>(); + + if rendered_entries.is_empty() { + return None; + } + + let source = context + .get("source") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("structured_form"); + let mode = context + .get("mode") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("runtime_metadata"); + + Some(format!( + "{ELICITATION_CONTEXT_PROMPT_MARKER}\n\ +来源:{source}\n\ +模式:{mode}\n\ +执行要求:\n\ +1. 下列信息来自用户刚刚提交的结构化补充信息,视为本轮已确认约束。\n\ +2. 回答与后续执行时优先吸收这些信息,不要重复追问同一字段。\n\ +3. 若仍缺关键信息,只追问尚未填写的最少字段。\n\ +已确认信息:\n\ +{}", + rendered_entries.join("\n") + )) +} + +fn render_elicitation_context_value(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(text) => { + let normalized = text.trim(); + if normalized.is_empty() { + None + } else { + Some(normalized.to_string()) + } + } + serde_json::Value::Number(number) => Some(number.to_string()), + serde_json::Value::Bool(boolean) => Some(if *boolean { + "是".to_string() + } else { + "否".to_string() + }), + serde_json::Value::Array(items) => { + let rendered = items + .iter() + .filter_map(render_elicitation_context_value) + .collect::>(); + if rendered.is_empty() { + None + } else { + Some(rendered.join("、")) + } + } + serde_json::Value::Object(object) => { + let rendered = serde_json::to_string(object).ok()?; + let normalized = rendered.trim(); + if normalized.is_empty() { + None + } else { + Some(normalized.to_string()) + } + } + serde_json::Value::Null => None, + } +} + +pub(crate) fn merge_system_prompt_with_elicitation_context( + base_prompt: Option, + request_metadata: Option<&serde_json::Value>, +) -> Option { + let Some(elicitation_prompt) = build_elicitation_context_system_prompt(request_metadata) else { + return base_prompt; + }; + + match base_prompt { + Some(base) => { + if base.contains(ELICITATION_CONTEXT_PROMPT_MARKER) { + Some(base) + } else if base.trim().is_empty() { + Some(elicitation_prompt) + } else { + Some(format!("{base}\n\n{elicitation_prompt}")) + } + } + None => Some(elicitation_prompt), + } +} + +pub(crate) 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 role_key_suffix = object + .get("role_key") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!(" / roleKey: {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}{role_key_suffix}{skill_suffix}" + )) + }) + .collect::>(); + + if !rendered_roles.is_empty() { + lines.push("- 当前 Team 角色参考:".to_string()); + lines.extend(rendered_roles); + lines.push( + "- 如果你决定调用 spawn_agent,请优先把上述 profile / roleKey / skillIds 映射到对应结构化字段,保持 GUI Team 画布与实际分工一致。" + .to_string(), + ); + } + } + + 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")) +} + +pub(crate) 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), + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs new file mode 100644 index 000000000..95956159e --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/reply_runtime.rs @@ -0,0 +1,482 @@ +use super::*; + +fn execution_strategy_label(strategy: AsterExecutionStrategy) -> &'static str { + match strategy { + AsterExecutionStrategy::React => "对话执行优先", + AsterExecutionStrategy::CodeOrchestrated => "代码编排执行", + AsterExecutionStrategy::Auto => "自动路由执行", + } +} + +fn model_supports_reasoning(model_name: Option<&str>) -> bool { + let Some(model_name) = model_name.map(str::trim).filter(|value| !value.is_empty()) else { + return false; + }; + let normalized = model_name.to_ascii_lowercase(); + normalized.contains("thinking") + || normalized.contains("reason") + || normalized.contains("r1") + || normalized.contains("o1") + || normalized.contains("o3") + || normalized.contains("o4") + || normalized.contains("gpt-5") + || normalized.contains("2.5") +} + +pub(super) fn message_suggests_live_search(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + [ + "搜索", + "搜一下", + "查一下", + "查一查", + "检索", + "上网查", + "联网查", + "最新", + "今天", + "刚刚", + "实时", + "新闻", + "股价", + "汇率", + "天气", + "政策", + "法规", + "版本", + "价格", + "热搜", + "上线", + "发布", + "search", + "look up", + "google", + "browse", + "now", + "today", + "latest", + "recent", + "price", + "version", + "news", + "weather", + ] + .iter() + .any(|keyword| normalized.contains(keyword)) +} + +fn message_suggests_planning(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + [ + "计划", + "规划", + "roadmap", + "拆解", + "分步骤", + "执行方案", + "实施方案", + "阶段", + "里程碑", + "todo", + ] + .iter() + .any(|keyword| normalized.contains(keyword)) +} + +fn message_suggests_task(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + [ + "后台", + "稍后", + "异步", + "排队", + "持续生成", + "长时间", + "继续跑", + "持续跑", + ] + .iter() + .any(|keyword| normalized.contains(keyword)) +} + +fn message_suggests_subagent(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + [ + "并行", + "多代理", + "分工", + "分别分析", + "从多个角度", + "parallel", + "subagent", + "delegate", + ] + .iter() + .any(|keyword| normalized.contains(keyword)) +} + +pub(super) fn build_turn_runtime_statuses( + request: &AsterChatRequest, + effective_strategy: AsterExecutionStrategy, + request_tool_policy: &RequestToolPolicy, + model_name: Option<&str>, +) -> (TauriRuntimeStatus, TauriRuntimeStatus) { + let thinking_enabled = extract_harness_bool( + request.metadata.as_ref(), + &["thinking_enabled", "thinkingEnabled"], + ) + .unwrap_or(false); + let task_enabled = extract_harness_bool( + request.metadata.as_ref(), + &["task_mode_enabled", "taskModeEnabled"], + ) + .unwrap_or(false); + let subagent_enabled = extract_harness_bool( + request.metadata.as_ref(), + &["subagent_mode_enabled", "subagentModeEnabled"], + ) + .unwrap_or(false); + let reasoning_supported = model_supports_reasoning(model_name); + let news_expansion_needed = request_tool_policy.allows_web_search() + && message_suggests_news_expansion(&request.message); + let browser_task_requirement = extract_browser_task_requirement(request.metadata.as_ref()); + + let initial_checkpoints = vec![ + execution_strategy_label(effective_strategy).to_string(), + if request_tool_policy.requires_web_search() { + "本回合必须先联网核实".to_string() + } else if news_expansion_needed { + "已识别新闻综述类输入,将先并发 WebSearch 扩搜".to_string() + } else if request_tool_policy.allows_web_search() { + "联网搜索仅作为候选能力待命".to_string() + } else { + "默认直接回答优先".to_string() + }, + if matches!( + browser_task_requirement, + Some(BrowserTaskRequirement::Required | BrowserTaskRequirement::RequiredWithUserStep) + ) { + "当前任务要求真实浏览器执行,不允许退化为联网检索".to_string() + } else { + "浏览器能力按需升级".to_string() + }, + if thinking_enabled && reasoning_supported { + "模型支持深度思考,先进入推理判定".to_string() + } else if thinking_enabled { + "当前模型不支持显式 thinking,改走轻量意图理解".to_string() + } else { + "先做轻量意图理解".to_string() + }, + if task_enabled { + "后台任务能力已待命".to_string() + } else { + "默认不升级后台任务".to_string() + }, + if subagent_enabled { + "多代理能力已待命".to_string() + } else { + "默认由单 Agent 先判断".to_string() + }, + ]; + + let decided = if request_tool_policy.requires_web_search() { + ( + "已决定:先联网检索".to_string(), + "当前回合已被明确指定为先搜索后答复,会先完成联网核实再继续生成。".to_string(), + vec![ + "用户明确要求联网搜索".to_string(), + "搜索结果返回后再形成最终答复".to_string(), + ], + ) + } else if news_expansion_needed { + ( + "已决定:先联网扩搜".to_string(), + "当前输入属于新闻/最新动态综述类请求,会先并发执行多组 WebSearch,再基于结果做主题聚类与交叉验证。" + .to_string(), + vec![ + "统一使用 WebSearch 执行多组扩搜".to_string(), + "完成来源整合后再组织最终答复".to_string(), + ], + ) + } else if subagent_enabled && message_suggests_subagent(&request.message) { + ( + "已决定:优先拆分为多代理".to_string(), + "用户输入更适合并行分工处理,先按多代理路径组织执行。".to_string(), + vec![ + "检测到并行/多角度需求".to_string(), + "主线程先承担协调职责".to_string(), + ], + ) + } else if task_enabled && message_suggests_task(&request.message) { + ( + "已决定:升级为后台任务".to_string(), + "用户输入更接近耗时或异步推进场景,优先走后台任务链路。".to_string(), + vec![ + "检测到排队/持续执行诉求".to_string(), + "先建立任务,再回传过程与产出".to_string(), + ], + ) + } else if thinking_enabled && reasoning_supported { + ( + "已决定:先深度思考".to_string(), + "当前模型支持 reasoning,先做更充分的意图理解与方案判断,再决定是否调用搜索或工具。" + .to_string(), + vec![ + "thinking 已开启".to_string(), + "搜索与工具保持候选状态,不默认触发".to_string(), + ], + ) + } else if thinking_enabled { + ( + "已决定:轻量理解后回答".to_string(), + "当前模型不支持显式 reasoning,先做轻量意图理解,再决定是否需要搜索或其他能力。" + .to_string(), + vec![ + "thinking 已开启".to_string(), + "当前模型回退为轻量推理".to_string(), + ], + ) + } else if request_tool_policy.allows_web_search() + && message_suggests_live_search(&request.message) + { + ( + "已决定:先联网核实".to_string(), + "问题包含明显时效性或实时性特征,先搜索核实再回答更稳妥。".to_string(), + vec![ + "已检测到最新/实时信息需求".to_string(), + "搜索完成后继续组织答复".to_string(), + ], + ) + } else if message_suggests_planning(&request.message) { + ( + "已决定:先规划再输出".to_string(), + "当前请求更像计划或方案拆解,会先整理执行路径和关键步骤。".to_string(), + vec![ + "检测到计划/拆解需求".to_string(), + "优先输出结构化行动路径".to_string(), + ], + ) + } else { + ( + "已决定:直接回答优先".to_string(), + "当前请求无需默认升级为搜索或任务,先直接给出结果,必要时再调用工具。".to_string(), + vec![ + "默认保持单回合直接回答".to_string(), + "只有证据不足或时效性要求出现时才升级".to_string(), + ], + ) + }; + + ( + TauriRuntimeStatus { + phase: "preparing".to_string(), + title: "正在理解意图".to_string(), + detail: + "正在判断当前回合应该直接回答、深度思考、规划、联网核实,还是升级为任务/多代理。" + .to_string(), + checkpoints: initial_checkpoints, + }, + TauriRuntimeStatus { + phase: "routing".to_string(), + title: decided.0, + detail: decided.1, + checkpoints: decided.2, + }, + ) +} + +fn emit_projected_runtime_item_event( + app: &AppHandle, + event_name: &str, + timeline_recorder: &Arc>, + workspace_root: &str, + event: TauriAgentEvent, +) { + if let Err(error) = app.emit(event_name, &event) { + tracing::warn!("[AsterAgent] 发送 runtime item 投影事件失败: {}", error); + } + + let mut recorder = match timeline_recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + if let Err(error) = recorder.record_runtime_event(app, event_name, &event, workspace_root) { + tracing::warn!( + "[AsterAgent] 记录 runtime item 投影事件失败(已降级继续): {}", + error + ); + } +} + +pub(super) async fn emit_runtime_status_with_projection( + agent: &Agent, + app: &AppHandle, + event_name: &str, + timeline_recorder: &Arc>, + workspace_root: &str, + session_config: &aster::agents::SessionConfig, + status: TauriRuntimeStatus, +) { + match agent + .upsert_runtime_status_item( + session_config, + status.phase.clone(), + status.title.clone(), + status.detail.clone(), + status.checkpoints.clone(), + ) + .await + { + Ok(agent_event) => { + for event in lime_agent::convert_agent_event(agent_event) { + emit_projected_runtime_item_event( + app, + event_name, + timeline_recorder, + workspace_root, + event, + ); + } + } + Err(error) => { + tracing::warn!( + "[AsterAgent] 写入 runtime_status item 失败,降级仅发送 transient 事件: {}", + error + ); + } + } + + let runtime_event = TauriAgentEvent::RuntimeStatus { status }; + if let Err(error) = app.emit(event_name, &runtime_event) { + tracing::warn!("[AsterAgent] 发送 runtime_status 失败: {}", error); + } +} + +pub(super) async fn complete_runtime_status_projection( + agent: &Agent, + app: &AppHandle, + event_name: &str, + timeline_recorder: &Arc>, + workspace_root: &str, + session_config: &aster::agents::SessionConfig, +) { + match agent.complete_runtime_status_item(session_config).await { + Ok(Some(agent_event)) => { + for event in lime_agent::convert_agent_event(agent_event) { + emit_projected_runtime_item_event( + app, + event_name, + timeline_recorder, + workspace_root, + event, + ); + } + } + Ok(None) => {} + Err(error) => { + tracing::warn!("[AsterAgent] 完成 runtime_status item 失败: {}", error); + } + } +} + +pub(super) fn should_fallback_to_react_from_code_orchestrated(error: &ReplyAttemptError) -> bool { + if !error.emitted_any { + return true; + } + + let lowered = error.message.to_lowercase(); + let recoverable_hints = ["unknown subscript", "tool_search_analysis", "web_scraping"]; + + recoverable_hints.iter().any(|hint| lowered.contains(hint)) +} + +pub(super) async fn ensure_code_execution_extension_enabled(agent: &Agent) -> Result { + let extension_configs = agent.get_extension_configs().await; + if extension_configs + .iter() + .any(|cfg| cfg.name() == CODE_EXECUTION_EXTENSION_NAME) + { + return Ok(false); + } + + let extension = ExtensionConfig::Platform { + name: CODE_EXECUTION_EXTENSION_NAME.to_string(), + description: "Execute JavaScript code in a sandboxed environment".to_string(), + bundled: Some(true), + available_tools: vec![], + deferred_loading: false, + always_expose_tools: Vec::new(), + allowed_caller: None, + }; + + agent + .add_extension(extension) + .await + .map_err(|e| format!("启用 code_execution 扩展失败: {e}"))?; + + Ok(true) +} + +pub(super) async fn stream_reply_once( + agent: &Agent, + app: &AppHandle, + event_name: &str, + user_message: Message, + working_directory: Option<&Path>, + session_config: aster::agents::SessionConfig, + cancel_token: CancellationToken, + request_tool_policy: &RequestToolPolicy, + mut on_event: F, +) -> Result<(), ReplyAttemptError> +where + F: FnMut(&TauriAgentEvent), +{ + stream_message_reply_with_policy( + agent, + user_message, + working_directory, + session_config, + Some(cancel_token), + request_tool_policy, + |event| { + on_event(event); + 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 + .map(|_| ()) +} + +pub(super) fn build_runtime_user_message( + message_text: &str, + images: Option<&[ImageInput]>, +) -> Message { + let mut message = Message::user(); + + if !message_text.is_empty() { + message = message.with_text(message_text); + } + + if let Some(images) = images { + for image in images { + if image.data.trim().is_empty() || image.media_type.trim().is_empty() { + continue; + } + message = message.with_image(image.data.clone(), image.media_type.clone()); + } + } + + if message.content.is_empty() { + return Message::user().with_text(message_text); + } + + message +} diff --git a/src-tauri/src/commands/aster_agent_cmd/run_metadata.rs b/src-tauri/src/commands/aster_agent_cmd/run_metadata.rs new file mode 100644 index 000000000..a37f40656 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/run_metadata.rs @@ -0,0 +1,26 @@ +use super::*; + +#[path = "run_metadata/provider_continuation.rs"] +mod provider_continuation; +#[path = "run_metadata/request_metadata.rs"] +mod request_metadata; +#[path = "run_metadata/social_artifacts.rs"] +mod social_artifacts; + +use provider_continuation::{ + extract_provider_continuation_from_message, extract_provider_continuation_from_metadata, +}; +use request_metadata::with_string_field; + +pub(super) use provider_continuation::load_previous_provider_continuation_state; +#[cfg(test)] +pub(super) use provider_continuation::provider_routing_matches_current; +pub(super) use request_metadata::{ + build_chat_run_metadata_base, extract_harness_array, extract_harness_bool, + extract_harness_nested_object, extract_harness_string, +}; +pub(super) use social_artifacts::{build_chat_run_finish_metadata, ChatRunObservation}; +#[cfg(test)] +pub(super) use social_artifacts::{ + extract_artifact_path_from_tool_start, resolve_social_run_artifact_descriptor, +}; diff --git a/src-tauri/src/commands/aster_agent_cmd/run_metadata/provider_continuation.rs b/src-tauri/src/commands/aster_agent_cmd/run_metadata/provider_continuation.rs new file mode 100644 index 000000000..d6782631f --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/run_metadata/provider_continuation.rs @@ -0,0 +1,200 @@ +use super::*; + +fn extract_provider_continuation_value( + metadata: &HashMap, + keys: &[&str], +) -> Option { + for key in keys { + let value = metadata.get(*key).and_then(serde_json::Value::as_str); + let Some(value) = normalize_optional_text(value.map(str::to_string)) else { + continue; + }; + return Some(value); + } + None +} + +pub(super) fn extract_provider_continuation_from_metadata( + metadata: &HashMap, + capability: ProviderContinuationCapability, +) -> Option { + match capability { + ProviderContinuationCapability::HistoryReplayOnly => None, + ProviderContinuationCapability::PreviousResponseId => extract_provider_continuation_value( + metadata, + &["previous_response_id", "previousResponseId"], + ) + .map(ProviderContinuationState::previous_response_id), + ProviderContinuationCapability::ProviderSessionToken => { + extract_provider_continuation_value( + metadata, + &[ + "provider_session_token", + "providerSessionToken", + "session_token", + "sessionToken", + "conversation_id", + "conversationId", + ], + ) + .map(ProviderContinuationState::provider_session_token) + } + ProviderContinuationCapability::StickyRoutingHint => { + extract_provider_continuation_value(metadata, &["routing_hint", "routingHint"]) + .map(ProviderContinuationState::sticky_routing_hint) + } + } +} + +pub(super) fn extract_provider_continuation_from_message( + message: &TauriMessage, + capability: ProviderContinuationCapability, +) -> Option { + for content in &message.content { + if let TauriMessageContent::ToolResponse { + metadata: Some(metadata), + .. + } = content + { + if let Some(provider_continuation) = + extract_provider_continuation_from_metadata(metadata, capability) + { + return Some(provider_continuation); + } + } + } + + if message.role == "assistant" { + if capability == ProviderContinuationCapability::PreviousResponseId { + return message + .id + .clone() + .map(ProviderContinuationState::previous_response_id); + } + + if capability == ProviderContinuationCapability::ProviderSessionToken { + return message + .id + .clone() + .map(ProviderContinuationState::provider_session_token); + } + } + + None +} + +fn extract_provider_routing_from_run_metadata( + metadata: &serde_json::Value, +) -> Option { + metadata + .get("turn_input") + .and_then(|value| value.get("provider_routing")) + .cloned() + .and_then(|value| serde_json::from_value(value).ok()) +} + +fn extract_provider_continuation_from_run_metadata( + metadata: &serde_json::Value, +) -> Option { + metadata + .get("provider_continuation") + .cloned() + .or_else(|| { + metadata + .get("turn_input") + .and_then(|value| value.get("provider_continuation")) + .cloned() + }) + .and_then(|value| serde_json::from_value::(value).ok()) + .filter(|state| !matches!(state, ProviderContinuationState::HistoryReplayOnly)) +} + +fn normalize_provider_identifier(value: Option<&str>) -> Option { + normalize_optional_text(value.map(str::to_string)).map(|value| value.to_ascii_lowercase()) +} + +pub(in crate::commands::aster_agent_cmd) fn provider_routing_matches_current( + previous: &TurnProviderRoutingSnapshot, + current: &TurnProviderRoutingSnapshot, +) -> bool { + let same_provider = normalize_provider_identifier(Some(previous.provider_name.as_str())) + == normalize_provider_identifier(Some(current.provider_name.as_str())); + let same_model = normalize_provider_identifier(Some(previous.model_name.as_str())) + == normalize_provider_identifier(Some(current.model_name.as_str())); + let same_selector = match ( + normalize_provider_identifier(previous.provider_selector.as_deref()), + normalize_provider_identifier(current.provider_selector.as_deref()), + ) { + (Some(left), Some(right)) => left == right, + _ => true, + }; + + same_provider && same_model && same_selector +} + +pub(in crate::commands::aster_agent_cmd) fn load_previous_provider_continuation_state( + db: &DbConnection, + session_id: &str, + current_routing: Option<&TurnProviderRoutingSnapshot>, + capability: ProviderContinuationCapability, +) -> ProviderContinuationState { + if !capability.supports_remote_continuation() { + return ProviderContinuationState::history_replay_only(); + } + + let Some(current_routing) = current_routing else { + return ProviderContinuationState::history_replay_only(); + }; + + let conn = match crate::database::lock_db(db) { + Ok(conn) => conn, + Err(error) => { + tracing::warn!( + "[AsterAgent] 读取 provider continuation 时数据库锁定失败: session_id={}, error={}", + session_id, + error + ); + return ProviderContinuationState::history_replay_only(); + } + }; + + let runs = match lime_core::database::dao::agent_run::AgentRunDao::list_terminal_runs_by_session( + &conn, session_id, 12, 0, + ) { + Ok(runs) => runs, + Err(error) => { + tracing::warn!( + "[AsterAgent] 查询历史 terminal runs 失败,忽略 provider continuation 恢复: session_id={}, error={}", + session_id, + error + ); + return ProviderContinuationState::history_replay_only(); + } + }; + + for run in runs { + let Some(metadata_text) = run.metadata.as_deref() else { + continue; + }; + let Ok(metadata_value) = serde_json::from_str::(metadata_text) else { + continue; + }; + let Some(previous_routing) = extract_provider_routing_from_run_metadata(&metadata_value) + else { + continue; + }; + if !provider_routing_matches_current(&previous_routing, current_routing) { + continue; + } + let Some(provider_continuation) = + extract_provider_continuation_from_run_metadata(&metadata_value) + else { + continue; + }; + if provider_continuation.matches_capability(capability) { + return provider_continuation; + } + } + + ProviderContinuationState::history_replay_only() +} diff --git a/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs b/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs new file mode 100644 index 000000000..ae68342a1 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/run_metadata/request_metadata.rs @@ -0,0 +1,169 @@ +use super::*; + +fn extract_harness_object( + request_metadata: Option<&serde_json::Value>, +) -> Option<&serde_json::Map> { + let metadata = request_metadata?; + let object = metadata.as_object()?; + if let Some(harness) = object.get("harness").and_then(serde_json::Value::as_object) { + return Some(harness); + } + Some(object) +} + +pub(in crate::commands::aster_agent_cmd) fn extract_harness_string( + request_metadata: Option<&serde_json::Value>, + keys: &[&str], +) -> Option { + let harness = extract_harness_object(request_metadata)?; + keys.iter() + .filter_map(|key| harness.get(*key)) + .find_map(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +pub(in crate::commands::aster_agent_cmd) fn extract_harness_bool( + request_metadata: Option<&serde_json::Value>, + keys: &[&str], +) -> Option { + let harness = extract_harness_object(request_metadata)?; + keys.iter() + .filter_map(|key| harness.get(*key)) + .find_map(serde_json::Value::as_bool) +} + +pub(in crate::commands::aster_agent_cmd) 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) +} + +pub(in crate::commands::aster_agent_cmd) fn extract_harness_nested_object<'a>( + request_metadata: Option<&'a serde_json::Value>, + keys: &[&str], +) -> Option<&'a serde_json::Map> { + let harness = extract_harness_object(request_metadata)?; + keys.iter() + .filter_map(|key| harness.get(*key)) + .find_map(serde_json::Value::as_object) +} + +pub(in crate::commands::aster_agent_cmd) fn extend_map_with_harness_fields( + target: &mut serde_json::Map, + request_metadata: Option<&serde_json::Value>, +) { + if let Some(metadata) = request_metadata { + target.insert("request_metadata".to_string(), metadata.clone()); + } + + let Some(harness) = extract_harness_object(request_metadata) else { + return; + }; + + for (source_key, target_key) in [ + ("theme", "harness_theme"), + ("harness_theme", "harness_theme"), + ("creation_mode", "creation_mode"), + ("creationMode", "creation_mode"), + ("chat_mode", "chat_mode"), + ("chatMode", "chat_mode"), + ("session_mode", "session_mode"), + ("sessionMode", "session_mode"), + ("gate_key", "gate_key"), + ("gateKey", "gate_key"), + ("run_title", "run_title"), + ("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"), + ("browserRequirementReason", "browser_requirement_reason"), + ("browser_launch_url", "browser_launch_url"), + ("browserLaunchUrl", "browser_launch_url"), + ] { + if target.contains_key(target_key) { + continue; + } + if let Some(value) = harness.get(source_key) { + target.insert(target_key.to_string(), value.clone()); + } + } +} + +pub(in crate::commands::aster_agent_cmd) fn build_chat_run_metadata_base( + request: &AsterChatRequest, + workspace_id: &str, + effective_strategy: AsterExecutionStrategy, + request_tool_policy: &RequestToolPolicy, + auto_continue_enabled: bool, + auto_continue_metadata: Option<&AutoContinuePayload>, +) -> serde_json::Map { + let mut metadata = serde_json::Map::new(); + metadata.insert("workspace_id".to_string(), serde_json::json!(workspace_id)); + metadata.insert( + "project_id".to_string(), + serde_json::json!(request.project_id.clone()), + ); + metadata.insert( + "event_name".to_string(), + serde_json::json!(request.event_name.clone()), + ); + metadata.insert( + "execution_strategy".to_string(), + serde_json::json!(format!("{:?}", effective_strategy).to_lowercase()), + ); + metadata.insert( + "message_length".to_string(), + serde_json::json!(request.message.chars().count()), + ); + metadata.insert( + "web_search_enabled".to_string(), + serde_json::json!(request_tool_policy.effective_web_search), + ); + metadata.insert( + "web_search_mode".to_string(), + serde_json::json!(request_tool_policy.search_mode.as_str()), + ); + metadata.insert( + "auto_continue_enabled".to_string(), + serde_json::json!(auto_continue_enabled), + ); + metadata.insert( + "auto_continue".to_string(), + serde_json::json!(auto_continue_metadata), + ); + extend_map_with_harness_fields(&mut metadata, request.metadata.as_ref()); + metadata +} + +pub(in crate::commands::aster_agent_cmd) fn with_string_field( + target: &mut serde_json::Map, + key: &str, + value: Option<&str>, +) { + if target.contains_key(key) { + return; + } + if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { + target.insert(key.to_string(), serde_json::json!(value)); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/run_metadata/social_artifacts.rs b/src-tauri/src/commands/aster_agent_cmd/run_metadata/social_artifacts.rs new file mode 100644 index 000000000..587dd6a5f --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/run_metadata/social_artifacts.rs @@ -0,0 +1,519 @@ +use super::*; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(in crate::commands::aster_agent_cmd) struct SocialRunArtifactDescriptor { + pub(in crate::commands::aster_agent_cmd) artifact_id: String, + pub(in crate::commands::aster_agent_cmd) artifact_type: String, + pub(in crate::commands::aster_agent_cmd) stage: String, + pub(in crate::commands::aster_agent_cmd) stage_label: String, + pub(in crate::commands::aster_agent_cmd) version_label: String, + pub(in crate::commands::aster_agent_cmd) source_file_name: String, + pub(in crate::commands::aster_agent_cmd) branch_key: String, + pub(in crate::commands::aster_agent_cmd) platform: Option, + pub(in crate::commands::aster_agent_cmd) is_auxiliary: bool, +} + +#[derive(Debug, Clone, Default)] +pub(in crate::commands::aster_agent_cmd) struct ChatRunObservation { + pub(in crate::commands::aster_agent_cmd) artifact_paths: Vec, + pub(in crate::commands::aster_agent_cmd) primary_social_artifact: + Option, + pub(in crate::commands::aster_agent_cmd) provider_continuation: + Option, +} + +impl ChatRunObservation { + pub(in crate::commands::aster_agent_cmd) fn record_event( + &mut self, + event: &TauriAgentEvent, + workspace_root: &str, + request_metadata: Option<&serde_json::Value>, + provider_continuation_capability: ProviderContinuationCapability, + ) { + match event { + TauriAgentEvent::ToolStart { + tool_name, + arguments, + .. + } => { + if let Some(path) = extract_artifact_path_from_tool_start( + tool_name, + arguments.as_deref(), + workspace_root, + ) { + self.record_artifact_path(path, request_metadata); + } + } + TauriAgentEvent::ToolEnd { result, .. } => { + if let Some(metadata) = &result.metadata { + if let Some(provider_continuation) = extract_provider_continuation_from_metadata( + metadata, + provider_continuation_capability, + ) { + self.record_provider_continuation(provider_continuation); + } + for path in + extract_artifact_paths_from_tool_result_metadata(metadata, workspace_root) + { + self.record_artifact_path(path, request_metadata); + } + } + } + TauriAgentEvent::Message { message } => { + if let Some(provider_continuation) = extract_provider_continuation_from_message( + message, + provider_continuation_capability, + ) { + self.record_provider_continuation(provider_continuation); + } + } + TauriAgentEvent::ArtifactSnapshot { artifact } => { + if let Some(path) = + normalize_metadata_path(artifact.file_path.as_str(), workspace_root) + { + self.record_artifact_path(path, request_metadata); + } + } + _ => {} + } + } + + fn record_provider_continuation(&mut self, provider_continuation: ProviderContinuationState) { + if matches!( + provider_continuation, + ProviderContinuationState::HistoryReplayOnly + ) { + return; + } + self.provider_continuation = Some(provider_continuation); + } + + pub(in crate::commands::aster_agent_cmd) fn record_artifact_path( + &mut self, + path: String, + request_metadata: Option<&serde_json::Value>, + ) { + if path.trim().is_empty() { + return; + } + + if !self.artifact_paths.iter().any(|item| item == &path) { + self.artifact_paths.push(path.clone()); + } + + if !should_track_social_artifact(request_metadata, path.as_str()) { + return; + } + + let gate_key = extract_harness_string(request_metadata, &["gate_key", "gateKey"]); + let run_title = + extract_harness_string(request_metadata, &["run_title", "runTitle", "title"]); + let candidate = resolve_social_run_artifact_descriptor( + path.as_str(), + gate_key.as_deref(), + run_title.as_deref(), + ); + let should_replace = match self.primary_social_artifact.as_ref() { + None => true, + Some(existing) if existing.is_auxiliary && !candidate.is_auxiliary => true, + _ => false, + }; + if should_replace { + self.primary_social_artifact = Some(candidate); + } + } +} + +fn normalize_metadata_path(raw: &str, workspace_root: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let normalized = trimmed.replace('\\', "/"); + let normalized_root = workspace_root.trim().replace('\\', "/"); + + if !normalized_root.is_empty() && normalized.starts_with(normalized_root.as_str()) { + let suffix = normalized + .strip_prefix(normalized_root.as_str()) + .unwrap_or(normalized.as_str()) + .trim_start_matches('/') + .to_string(); + if !suffix.is_empty() { + return Some(suffix); + } + } + + Some(normalized) +} + +fn parse_tool_arguments(arguments: Option<&str>) -> Option { + let raw = arguments?.trim(); + if raw.is_empty() { + return None; + } + serde_json::from_str::(raw).ok() +} + +pub(in crate::commands::aster_agent_cmd) fn extract_artifact_path_from_tool_start( + tool_name: &str, + arguments: Option<&str>, + workspace_root: &str, +) -> Option { + let normalized_tool_name = tool_name.trim().to_lowercase(); + if normalized_tool_name.is_empty() { + return None; + } + + let args = parse_tool_arguments(arguments)?; + let object = args.as_object()?; + + for key in ["path", "file_path", "filePath", "output_path", "outputPath"] { + let Some(raw_path) = object.get(key).and_then(serde_json::Value::as_str) else { + continue; + }; + if normalized_tool_name.contains("write") + || normalized_tool_name.contains("create") + || normalized_tool_name.contains("output") + { + return normalize_metadata_path(raw_path, workspace_root); + } + } + + None +} + +fn push_metadata_path(target: &mut Vec, value: &serde_json::Value, workspace_root: &str) { + match value { + serde_json::Value::String(path) => { + if let Some(normalized) = normalize_metadata_path(path, workspace_root) { + if !target.iter().any(|item| item == &normalized) { + target.push(normalized); + } + } + } + serde_json::Value::Array(items) => { + for item in items { + push_metadata_path(target, item, workspace_root); + } + } + _ => {} + } +} + +fn extract_artifact_paths_from_tool_result_metadata( + metadata: &HashMap, + workspace_root: &str, +) -> Vec { + let mut paths = Vec::new(); + for key in [ + "artifact_paths", + "artifact_path", + "path", + "absolute_path", + "output_file", + "file_path", + "output_path", + "article_path", + "cover_meta_path", + "publish_path", + ] { + if let Some(value) = metadata.get(key) { + push_metadata_path(&mut paths, value, workspace_root); + } + } + paths +} + +fn should_track_social_artifact(request_metadata: Option<&serde_json::Value>, path: &str) -> bool { + if extract_harness_string(request_metadata, &["theme", "harness_theme"]) + .map(|theme| theme == "social-media") + .unwrap_or(false) + { + return true; + } + path.to_lowercase().contains("social") +} + +fn normalize_artifact_file_name(file_name: &str) -> String { + file_name.replace('\\', "/").trim().to_string() +} + +fn artifact_base_name(file_name: &str) -> String { + normalize_artifact_file_name(file_name) + .split('/') + .last() + .unwrap_or(file_name) + .to_string() +} + +fn strip_social_known_suffix(file_name: &str) -> String { + let base_name = artifact_base_name(file_name); + if let Some(value) = base_name.strip_suffix(".publish-pack.json") { + return value.to_string(); + } + if let Some(value) = base_name.strip_suffix(".cover.json") { + return value.to_string(); + } + base_name + .rsplit_once('.') + .map(|(prefix, _)| prefix.to_string()) + .unwrap_or(base_name) +} + +fn to_social_branch_key(file_name: &str) -> String { + let mut branch_key = String::new(); + let mut last_is_dash = false; + for ch in strip_social_known_suffix(file_name).chars() { + let keep = ch.is_ascii_alphanumeric() || ('\u{4e00}'..='\u{9fa5}').contains(&ch); + if keep { + branch_key.push(ch.to_ascii_lowercase()); + last_is_dash = false; + } else if !last_is_dash { + branch_key.push('-'); + last_is_dash = true; + } + } + let branch_key = branch_key.trim_matches('-').to_string(); + if branch_key.is_empty() { + "artifact".to_string() + } else { + branch_key + } +} + +fn infer_social_platform_from_text(text: &str) -> Option { + let normalized = text.to_lowercase(); + if normalized.contains("xiaohongshu") || normalized.contains("xhs") || text.contains("小红书") + { + return Some("xiaohongshu".to_string()); + } + if normalized.contains("wechat") + || normalized.contains("weixin") + || normalized.contains("gzh") + || text.contains("公众号") + || text.contains("微信") + { + return Some("wechat".to_string()); + } + if normalized.contains("zhihu") || text.contains("知乎") { + return Some("zhihu".to_string()); + } + None +} + +fn resolve_social_artifact_type( + normalized_file_name: &str, + platform: Option<&str>, + gate_key: Option<&str>, +) -> String { + let base_name = artifact_base_name(normalized_file_name).to_lowercase(); + if base_name.ends_with(".publish-pack.json") { + return "publish_package".to_string(); + } + if base_name.ends_with(".cover.json") { + return "cover_meta".to_string(); + } + if !base_name.ends_with(".md") { + return "asset".to_string(); + } + if base_name == "brief.md" || base_name.contains("brief") { + return "brief".to_string(); + } + if base_name == "draft.md" || base_name.contains("draft") { + return "draft".to_string(); + } + if base_name == "article.md" || base_name.contains("article") || base_name.contains("final") { + return "polished".to_string(); + } + if base_name == "adapted.md" || base_name.contains("adapt") { + return "platform_variant".to_string(); + } + if platform.is_some() { + return "platform_variant".to_string(); + } + match gate_key.unwrap_or_default() { + "topic_select" => "brief".to_string(), + "publish_confirm" => { + if platform.is_some() { + "platform_variant".to_string() + } else { + "polished".to_string() + } + } + _ => "draft".to_string(), + } +} + +fn resolve_social_stage_for_artifact(artifact_type: &str, gate_key: Option<&str>) -> String { + match artifact_type { + "brief" => "briefing".to_string(), + "draft" => "drafting".to_string(), + "polished" => "polishing".to_string(), + "platform_variant" => "adapting".to_string(), + "cover_meta" | "publish_package" => "publish_prep".to_string(), + _ => match gate_key.unwrap_or("idle") { + "topic_select" => "briefing".to_string(), + "publish_confirm" => "publish_prep".to_string(), + _ => "drafting".to_string(), + }, + } +} + +fn resolve_social_stage_label(stage: &str) -> String { + match stage { + "briefing" => "需求澄清".to_string(), + "drafting" => "初稿创作".to_string(), + "polishing" => "润色优化".to_string(), + "adapting" => "平台适配".to_string(), + "publish_prep" => "发布准备".to_string(), + _ => "社媒创作".to_string(), + } +} + +fn resolve_social_version_label(artifact_type: &str, platform: Option<&str>) -> String { + match artifact_type { + "brief" => "需求简报".to_string(), + "draft" => "社媒初稿".to_string(), + "polished" => "润色成稿".to_string(), + "platform_variant" => match platform { + Some("xiaohongshu") => "平台适配 · 小红书".to_string(), + Some("wechat") => "平台适配 · 公众号".to_string(), + Some("zhihu") => "平台适配 · 知乎".to_string(), + _ => "平台适配".to_string(), + }, + "cover_meta" => "封面配置".to_string(), + "publish_package" => "发布包".to_string(), + _ => "社媒产物".to_string(), + } +} + +pub(in crate::commands::aster_agent_cmd) fn resolve_social_run_artifact_descriptor( + file_name: &str, + gate_key: Option<&str>, + run_title: Option<&str>, +) -> SocialRunArtifactDescriptor { + let normalized_file_name = normalize_artifact_file_name(file_name); + let platform = infer_social_platform_from_text( + format!("{} {}", normalized_file_name, run_title.unwrap_or_default()).as_str(), + ); + let artifact_type = + resolve_social_artifact_type(normalized_file_name.as_str(), platform.as_deref(), gate_key); + let stage = resolve_social_stage_for_artifact(artifact_type.as_str(), gate_key); + let branch_key = to_social_branch_key(normalized_file_name.as_str()); + let artifact_suffix = match platform.as_deref() { + Some(platform) => format!("{branch_key}:{platform}"), + None => branch_key.clone(), + }; + + SocialRunArtifactDescriptor { + artifact_id: format!("social-media:{}:{}", artifact_type, artifact_suffix), + artifact_type: artifact_type.clone(), + stage: stage.clone(), + stage_label: resolve_social_stage_label(stage.as_str()), + version_label: resolve_social_version_label(artifact_type.as_str(), platform.as_deref()), + source_file_name: normalized_file_name, + branch_key, + platform, + is_auxiliary: matches!( + artifact_type.as_str(), + "cover_meta" | "publish_package" | "asset" + ), + } +} + +fn infer_gate_key_from_social_stage(stage: &str) -> Option<&'static str> { + match stage { + "briefing" => Some("topic_select"), + "drafting" | "polishing" => Some("write_mode"), + "adapting" | "publish_prep" => Some("publish_confirm"), + _ => None, + } +} + +pub(in crate::commands::aster_agent_cmd) fn build_chat_run_finish_metadata( + base_metadata: &serde_json::Map, + observation: &ChatRunObservation, +) -> serde_json::Value { + let mut metadata = base_metadata.clone(); + + if !observation.artifact_paths.is_empty() { + metadata.insert( + "artifact_paths".to_string(), + serde_json::json!(observation.artifact_paths.clone()), + ); + } + + if let Some(artifact) = observation.primary_social_artifact.as_ref() { + with_string_field(&mut metadata, "harness_theme", Some("social-media")); + with_string_field( + &mut metadata, + "artifact_id", + Some(artifact.artifact_id.as_str()), + ); + with_string_field( + &mut metadata, + "artifact_type", + Some(artifact.artifact_type.as_str()), + ); + with_string_field(&mut metadata, "stage", Some(artifact.stage.as_str())); + with_string_field( + &mut metadata, + "stage_label", + Some(artifact.stage_label.as_str()), + ); + with_string_field( + &mut metadata, + "version_label", + Some(artifact.version_label.as_str()), + ); + with_string_field( + &mut metadata, + "branch_key", + Some(artifact.branch_key.as_str()), + ); + with_string_field(&mut metadata, "platform", artifact.platform.as_deref()); + with_string_field( + &mut metadata, + "source_file_name", + Some(artifact.source_file_name.as_str()), + ); + let version_id = format!("artifact:{}", artifact.source_file_name); + with_string_field(&mut metadata, "version_id", Some(version_id.as_str())); + + if !metadata.contains_key("gate_key") { + with_string_field( + &mut metadata, + "gate_key", + infer_gate_key_from_social_stage(artifact.stage.as_str()), + ); + } + if !metadata.contains_key("run_title") { + with_string_field( + &mut metadata, + "run_title", + Some(artifact.version_label.as_str()), + ); + } + } + + if let Some(provider_continuation) = observation.provider_continuation.as_ref() { + if let Ok(provider_continuation_value) = serde_json::to_value(provider_continuation) { + metadata.insert( + "provider_continuation".to_string(), + provider_continuation_value, + ); + } + metadata.insert( + "provider_continuation_observed".to_string(), + serde_json::json!(true), + ); + with_string_field( + &mut metadata, + "provider_continuation_kind", + Some(provider_continuation.kind()), + ); + } + + serde_json::Value::Object(metadata) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs new file mode 100644 index 000000000..711edb8f1 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -0,0 +1,984 @@ +use super::*; + +async fn execute_aster_chat_request( + app: &AppHandle, + state: &AsterAgentState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + logs: &LogState, + config_manager: &GlobalConfigManagerState, + mcp_manager: &McpManagerState, + automation_state: &AutomationServiceState, + request: AsterChatRequest, +) -> Result<(), String> { + tracing::info!( + "[AsterAgent] 发送流式消息: session={}, event={}", + request.session_id, + request.event_name + ); + + // 确保 Agent 已初始化(使用带数据库的版本,注入 SessionStore) + let is_init = state.is_initialized().await; + tracing::warn!("[AsterAgent] Agent 初始化状态: {}", is_init); + if !is_init { + tracing::warn!("[AsterAgent] Agent 未初始化,开始初始化..."); + state.init_agent_with_db(db).await?; + tracing::warn!("[AsterAgent] Agent 初始化完成"); + } else { + tracing::warn!("[AsterAgent] Agent 已初始化,检查 session_store..."); + // 检查 session_store 是否存在 + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + if let Some(agent) = guard.as_ref() { + let has_store = agent.session_store().is_some(); + tracing::warn!("[AsterAgent] session_store 存在: {}", has_store); + } + } + ensure_tool_search_tool_registered(state).await?; + + // 直接使用前端传递的 session_id + // LimeSessionStore 会在 add_message 时自动创建不存在的 session + // 同时 get_session 也会自动创建不存在的 session + let session_id = &request.session_id; + + let workspace_id = request.workspace_id.trim().to_string(); + if workspace_id.is_empty() { + let message = "workspace_id 必填,请先选择项目工作区".to_string(); + logs.write() + .await + .add("error", &format!("[AsterAgent] {}", message)); + return Err(message); + } + + let manager = WorkspaceManager::new(db.clone()); + let workspace = match manager.get(&workspace_id) { + Ok(Some(workspace)) => workspace, + Ok(None) => { + let message = format!("Workspace 不存在: {workspace_id}"); + logs.write() + .await + .add("error", &format!("[AsterAgent] {}", message)); + return Err(message); + } + Err(error) => { + let message = format!("读取 workspace 失败: {error}"); + logs.write() + .await + .add("error", &format!("[AsterAgent] {}", message)); + return Err(message); + } + }; + let ensured = match ensure_workspace_ready_with_auto_relocate(&manager, &workspace) { + Ok(result) => result, + Err(message) => { + logs.write() + .await + .add("error", &format!("[AsterAgent] {}", message)); + return Err(message); + } + }; + let workspace_root = ensured.root_path.to_string_lossy().to_string(); + let runtime_config = config_manager.config(); + apply_web_search_runtime_env(&runtime_config); + let auto_continue_config = request + .auto_continue + .clone() + .map(AutoContinuePayload::normalized); + let auto_continue_enabled = auto_continue_config + .as_ref() + .map(|config| config.enabled) + .unwrap_or(false); + if let Some(config) = auto_continue_config + .as_ref() + .filter(|config| config.enabled) + { + tracing::info!( + "[AsterAgent] 自动续写策略已启用: source={:?}, fast_mode={}, continuation_length={}, sensitivity={}", + config.source, + config.fast_mode_enabled, + config.continuation_length, + config.sensitivity + ); + } + + if ensured.repaired { + let warning_message = ensured.warning.unwrap_or_else(|| { + format!( + "检测到工作区目录缺失,已自动创建并继续执行: {}", + workspace_root + ) + }); + logs.write() + .await + .add("warn", &format!("[AsterAgent] {}", warning_message)); + let warning_event = TauriAgentEvent::Warning { + code: Some(WORKSPACE_PATH_AUTO_CREATED_WARNING_CODE.to_string()), + message: warning_message, + }; + if let Err(error) = app.emit(&request.event_name, &warning_event) { + tracing::error!("[AsterAgent] 发送工作区自动恢复提醒失败: {}", error); + } + } + + let mut session_state_snapshot = SessionStateSnapshot::from_persisted_metadata( + session_id, + AsterAgentWrapper::get_persisted_session_metadata_sync(db, session_id)?, + ); + + if session_state_snapshot.needs_working_dir_update(&workspace_root) { + tracing::info!( + "[AsterAgent] workspace 变更,自动更新 session working_dir: {} -> {}", + session_state_snapshot.working_dir().unwrap_or_default(), + workspace_root + ); + AsterAgentWrapper::update_session_working_dir_sync(db, session_id, &workspace_root)?; + session_state_snapshot = + session_state_snapshot.with_working_dir(Some(workspace_root.clone())); + } + + // 启动并注入 MCP extensions 到 Aster Agent + let (_start_ok, start_fail) = ensure_lime_mcp_servers_running(db, mcp_manager).await; + if start_fail > 0 { + tracing::warn!( + "[AsterAgent] 部分 MCP server 自动启动失败 ({} 失败),后续可用工具可能不完整", + start_fail + ); + } + + let (_mcp_ok, mcp_fail) = inject_mcp_extensions(state, mcp_manager).await; + if mcp_fail > 0 { + tracing::warn!( + "[AsterAgent] 部分 MCP extension 注入失败 ({} 失败),Agent 可能无法使用某些 MCP 工具", + mcp_fail + ); + } + + let runtime_chat_mode = resolve_runtime_chat_mode(request.metadata.as_ref()); + let mode_default_web_search = default_web_search_enabled_for_chat_mode(runtime_chat_mode); + let (request_web_search, request_search_mode) = + apply_browser_requirement_to_request_tool_policy( + request.metadata.as_ref(), + request.web_search, + request.search_mode, + ); + + // 构建请求级工具策略: + // - web_search=true 默认只表示“允许搜索” + // - 仅显式 search_mode=required 时才强制预搜索 + let request_tool_policy = resolve_request_tool_policy_with_mode( + request_web_search, + request_search_mode, + mode_default_web_search, + ); + tracing::info!( + "[AsterAgent][WebSearchGuard] session={}, chat_mode={:?}, request_web_search={:?}, request_search_mode={:?}, effective_request_web_search={:?}, effective_request_search_mode={:?}, mode_default_web_search={}, effective_web_search={}, search_mode={}", + session_id, + runtime_chat_mode, + request.web_search, + request.search_mode, + request_web_search, + request_search_mode, + mode_default_web_search, + request_tool_policy.effective_web_search, + request_tool_policy.search_mode.as_str() + ); + + let include_context_trace = runtime_config.memory.enabled; + let has_persisted_session = session_state_snapshot.has_persisted_session(); + tracing::info!( + "[AsterAgent] session_state_snapshot={}", + serde_json::to_string(&session_state_snapshot).unwrap_or_else(|_| "{}".to_string()) + ); + let mut turn_input_builder = TurnInputEnvelopeBuilder::new(session_id, workspace_id.as_str()); + turn_input_builder + .set_project_id(request.project_id.clone()) + .set_has_persisted_session(has_persisted_session) + .set_request_tool_policy(Some(TurnRequestToolPolicySnapshot::from( + &request_tool_policy, + ))) + .set_working_dir(Some(workspace_root.clone())) + .set_effective_user_message(request.message.clone()) + .set_include_context_trace(include_context_trace) + .set_turn_context_metadata_from_value(request.metadata.as_ref()); + + // 构建 system_prompt:优先使用项目上下文,其次使用 session 的 system_prompt + // 同时读取会话已持久化的 execution_strategy + let persisted_strategy = + AsterExecutionStrategy::from_db_value(session_state_snapshot.execution_strategy()); + let session_prompt = if let Some(prompt) = session_state_snapshot.system_prompt() { + tracing::debug!( + "[AsterAgent] 找到 session,system_prompt: {:?}", + Some(prompt.len()) + ); + Some(prompt.to_string()) + } else { + if !session_state_snapshot.has_persisted_session() { + tracing::debug!("[AsterAgent] Lime 数据库中未找到 session: {}", session_id); + } + None + }; + + let project_prompt = if let Some(ref project_id) = request.project_id { + match AsterAgentState::build_project_system_prompt(db, project_id) { + Ok(prompt) => { + tracing::info!( + "[AsterAgent] 已加载项目上下文: project_id={}, prompt_len={}", + project_id, + prompt.len() + ); + Some(prompt) + } + Err(e) => { + tracing::warn!( + "[AsterAgent] 加载项目上下文失败: {}, 继续使用 session prompt", + e + ); + None + } + } + } else { + None + }; + + let (resolved_prompt, system_prompt_source) = if let Some(project_prompt) = project_prompt { + (Some(project_prompt), TurnSystemPromptSource::Project) + } else if let Some(session_prompt) = session_prompt { + (Some(session_prompt), TurnSystemPromptSource::Session) + } else if let Some(ref frontend_prompt) = request.system_prompt { + if !frontend_prompt.trim().is_empty() { + tracing::info!( + "[AsterAgent] 使用前端传入的 system_prompt, len={}", + frontend_prompt.len() + ); + ( + Some(frontend_prompt.clone()), + TurnSystemPromptSource::Frontend, + ) + } else { + (None, TurnSystemPromptSource::None) + } + } else { + (None, TurnSystemPromptSource::None) + }; + turn_input_builder.set_base_system_prompt(system_prompt_source, resolved_prompt.clone()); + + let prompt_with_runtime_agents = + merge_system_prompt_with_runtime_agents(resolved_prompt, Some(Path::new(&workspace_root))); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::RuntimeAgents, + prompt_with_runtime_agents.clone(), + ); + + let prompt_with_memory = merge_system_prompt_with_memory_context( + prompt_with_runtime_agents, + &runtime_config, + MemoryPromptContext::with_working_dir(Path::new(&workspace_root)), + ); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::Memory, + prompt_with_memory.clone(), + ); + + let prompt_with_web_search = + merge_system_prompt_with_web_search(prompt_with_memory, &runtime_config); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::WebSearch, + prompt_with_web_search.clone(), + ); + + let prompt_with_request_policy = + merge_system_prompt_with_request_tool_policy(prompt_with_web_search, &request_tool_policy); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::RequestToolPolicy, + prompt_with_request_policy.clone(), + ); + + let prompt_with_elicitation = merge_system_prompt_with_elicitation_context( + prompt_with_request_policy, + request.metadata.as_ref(), + ); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::Elicitation, + prompt_with_elicitation.clone(), + ); + + let prompt_with_team_preference = merge_system_prompt_with_team_preference( + prompt_with_elicitation, + request.metadata.as_ref(), + ); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::TeamPreference, + prompt_with_team_preference.clone(), + ); + + let system_prompt = merge_system_prompt_with_auto_continue( + prompt_with_team_preference, + auto_continue_config.as_ref(), + ); + turn_input_builder.apply_prompt_stage( + TurnPromptAugmentationStageKind::AutoContinue, + system_prompt.clone(), + ); + + let requested_strategy = request.execution_strategy.unwrap_or(persisted_strategy); + let effective_strategy = requested_strategy.effective_for_message(&request.message); + turn_input_builder + .set_requested_execution_strategy(Some(requested_strategy.as_db_value().to_string())) + .set_effective_execution_strategy(Some(effective_strategy.as_db_value().to_string())); + + if let Some(explicit_strategy) = request.execution_strategy { + if has_persisted_session { + if let Err(error) = AsterAgentWrapper::update_session_execution_strategy_sync( + db, + session_id, + explicit_strategy.as_db_value(), + ) { + tracing::warn!( + "[AsterAgent] 更新会话执行策略失败: session={}, strategy={}, error={}", + session_id, + explicit_strategy.as_db_value(), + error + ); + } + } + } + + tracing::info!( + "[AsterAgent] 执行策略: requested={:?}, effective={:?}", + requested_strategy, + effective_strategy + ); + + // 如果提供了 Provider 配置,则配置 Provider + if let Some(provider_config) = &request.provider_config { + tracing::info!( + "[AsterAgent] 收到 provider_config: provider_id={:?}, provider_name={}, model_name={}, has_api_key={}, base_url={:?}", + provider_config.provider_id, + provider_config.provider_name, + provider_config.model_name, + provider_config.api_key.is_some(), + provider_config.base_url + ); + 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, + credential_path: None, + }; + // 如果前端提供了 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 + .provider_id + .as_deref() + .unwrap_or(&provider_config.provider_name); + state + .configure_provider_from_pool( + db, + provider_selector, + &provider_config.model_name, + session_id, + ) + .await?; + persist_session_provider_routing(session_id, provider_selector).await?; + } + } + + // 检查 Provider 是否已配置 + if !state.is_provider_configured().await { + return Err("Provider 未配置,请先调用 aster_agent_configure_provider".to_string()); + } + let effective_provider_config = state.get_provider_config().await; + let provider_routing_snapshot = + effective_provider_config + .as_ref() + .map(|config| TurnProviderRoutingSnapshot { + provider_name: config.provider_name.clone(), + provider_selector: config.provider_selector.clone(), + model_name: config.model_name.clone(), + credential_uuid: config.credential_uuid.clone(), + configured_from_request: request.provider_config.is_some(), + used_inline_api_key: request + .provider_config + .as_ref() + .and_then(|config| config.api_key.as_ref()) + .is_some(), + }); + turn_input_builder.set_provider_routing(provider_routing_snapshot.clone()); + let provider_continuation_capability = effective_provider_config + .as_ref() + .map(|config| config.provider_continuation_capability()) + .unwrap_or(ProviderContinuationCapability::HistoryReplayOnly); + let configured_provider_continuation_state = effective_provider_config + .as_ref() + .map(|config| config.provider_continuation_state()) + .unwrap_or_else(ProviderContinuationState::history_replay_only); + let restored_provider_continuation_state = load_previous_provider_continuation_state( + db, + session_id, + provider_routing_snapshot.as_ref(), + provider_continuation_capability, + ); + let provider_continuation_state = if matches!( + restored_provider_continuation_state, + ProviderContinuationState::HistoryReplayOnly + ) { + configured_provider_continuation_state + } else { + tracing::info!( + "[AsterAgent] 恢复上一条 terminal run 的 provider continuation: session_id={}, kind={}", + session_id, + restored_provider_continuation_state.kind() + ); + restored_provider_continuation_state + }; + turn_input_builder + .set_provider_continuation_capability(provider_continuation_capability) + .set_provider_continuation(provider_continuation_state); + + let sandbox_outcome = apply_workspace_sandbox_permissions( + state, + 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 + .map_err(|e| format!("注入 workspace 安全策略失败: {e}"))?; + + match sandbox_outcome { + WorkspaceSandboxApplyOutcome::Applied { sandbox_type } => { + tracing::info!( + "[AsterAgent] 已启用 workspace 本地 sandbox: root={}, type={}", + workspace_root, + sandbox_type + ); + } + WorkspaceSandboxApplyOutcome::DisabledByConfig => { + tracing::info!( + "[AsterAgent] workspace 本地 sandbox 已关闭,继续使用普通执行模式: root={}", + workspace_root + ); + } + WorkspaceSandboxApplyOutcome::UnavailableFallback { + warning_message, + notify_user, + } => { + tracing::warn!( + "[AsterAgent] workspace 本地 sandbox 不可用,已降级为普通执行: root={}, warning={}", + workspace_root, + warning_message + ); + if notify_user { + let warning_event = TauriAgentEvent::Warning { + code: Some(WORKSPACE_SANDBOX_FALLBACK_WARNING_CODE.to_string()), + message: warning_message, + }; + if let Err(e) = app.emit(&request.event_name, &warning_event) { + tracing::error!("[AsterAgent] 发送 sandbox 降级提醒失败: {}", e); + } + } + } + } + + let tracker = ExecutionTracker::new(db.clone()); + let cancel_token = state.create_cancel_token(session_id).await; + let auto_continue_metadata = auto_continue_config.clone(); + let request_metadata = request.metadata.clone(); + sync_browser_assist_runtime_hint(session_id, request_metadata.as_ref()).await; + let model_skill_tool_enabled = should_enable_model_skill_tool(request_metadata.as_ref()); + let run_observation = Arc::new(Mutex::new(ChatRunObservation::default())); + let run_observation_for_finalize = run_observation.clone(); + + let agent_arc = state.get_agent_arc(); + let runtime_snapshot = { + let guard = agent_arc.read().await; + let agent = guard.as_ref().ok_or("Agent not initialized")?; + match agent.runtime_snapshot(session_id).await { + Ok(snapshot) => Some(snapshot), + Err(error) => { + tracing::warn!( + "[AsterAgent] 提交 turn 前读取 runtime snapshot 失败: session_id={}, error={}", + session_id, + error + ); + None + } + } + }; + let runtime_projection_snapshot = + RuntimeProjectionSnapshot::from_snapshot(session_id, runtime_snapshot.as_ref()); + tracing::info!( + "[AsterAgent] runtime_projection_snapshot={}", + serde_json::to_string(&runtime_projection_snapshot).unwrap_or_else(|_| "{}".to_string()) + ); + let resolved_thread_id = runtime_projection_snapshot + .primary_thread_id() + .map(str::to_string) + .unwrap_or_else(|| session_id.to_string()); + let resolved_turn_id = request + .turn_id + .clone() + .unwrap_or_else(|| Uuid::new_v4().to_string()); + let turn_state = TurnState::new( + session_id, + workspace_id.as_str(), + resolved_thread_id.clone(), + resolved_turn_id.clone(), + requested_strategy.as_db_value(), + effective_strategy.as_db_value(), + TurnRequestToolPolicySnapshot::from(&request_tool_policy), + include_context_trace, + runtime_chat_mode_label(runtime_chat_mode), + ); + tracing::info!( + "[AsterAgent] turn_state={}", + serde_json::to_string(&turn_state).unwrap_or_else(|_| "{}".to_string()) + ); + turn_input_builder + .set_thread_id(turn_state.thread_id.clone()) + .set_turn_id(turn_state.turn_id.clone()); + let turn_input_envelope = turn_input_builder.build(); + let turn_input_diagnostics = turn_input_envelope.diagnostics_snapshot(); + tracing::info!( + "[AsterAgent] turn_input_envelope={}", + serde_json::to_string(&turn_input_diagnostics).unwrap_or_else(|_| "{}".to_string()) + ); + + let mut run_start_metadata = build_chat_run_metadata_base( + &request, + workspace_id.as_str(), + effective_strategy, + &request_tool_policy, + auto_continue_enabled, + auto_continue_metadata.as_ref(), + ); + if let Ok(session_state_value) = serde_json::to_value(&session_state_snapshot) { + run_start_metadata.insert("session_state".to_string(), session_state_value); + } + if let Ok(runtime_projection_value) = serde_json::to_value(&runtime_projection_snapshot) { + run_start_metadata.insert("runtime_projection".to_string(), runtime_projection_value); + } + if let Ok(turn_state_value) = serde_json::to_value(&turn_state) { + run_start_metadata.insert("turn_state".to_string(), turn_state_value); + } + if let Ok(turn_input_value) = serde_json::to_value(&turn_input_diagnostics) { + run_start_metadata.insert("turn_input".to_string(), turn_input_value); + } + let run_start_metadata_for_finalize = run_start_metadata.clone(); + let timeline_recorder = Arc::new(Mutex::new(AgentTimelineRecorder::create( + db.clone(), + turn_state.thread_id.clone(), + turn_state.turn_id.clone(), + request.message.clone(), + )?)); + let runtime_status_session_config = { + let mut session_config_builder = SessionConfigBuilder::new(session_id) + .thread_id(turn_state.thread_id.clone()) + .turn_id(turn_state.turn_id.clone()); + if let Some(turn_context) = turn_input_envelope.turn_context_override() { + session_config_builder = session_config_builder.turn_context(turn_context); + } + session_config_builder.build() + }; + + // 获取 Agent Arc 并保持 guard 在整个流处理期间存活 + let guard = agent_arc.read().await; + let agent = guard.as_ref().ok_or("Agent not initialized")?; + if let Err(error) = agent + .ensure_runtime_turn_initialized( + &runtime_status_session_config, + Some(request.message.clone()), + ) + .await + { + tracing::warn!( + "[AsterAgent] 初始化 runtime turn 失败,后续降级继续: {}", + error + ); + } + + let (initial_runtime_status, decided_runtime_status) = build_turn_runtime_statuses( + &request, + effective_strategy, + &request_tool_policy, + request + .provider_config + .as_ref() + .map(|config| config.model_name.as_str()), + ); + for status in [initial_runtime_status, decided_runtime_status] { + emit_runtime_status_with_projection( + agent, + app, + &request.event_name, + &timeline_recorder, + workspace_root.as_str(), + &runtime_status_session_config, + status, + ) + .await; + } + let resolved_thread_id_for_session = turn_state.thread_id.clone(); + let resolved_turn_id_for_session = turn_state.turn_id.clone(); + let turn_input_envelope_for_session = turn_input_envelope.clone(); + + let build_session_config = || { + let mut session_config_builder = SessionConfigBuilder::new(session_id) + .thread_id(resolved_thread_id_for_session.clone()) + .turn_id(resolved_turn_id_for_session.clone()); + if let Some(prompt) = turn_input_envelope_for_session.system_prompt() { + session_config_builder = session_config_builder.system_prompt(prompt.to_string()); + } + if let Some(turn_context) = turn_input_envelope_for_session.turn_context_override() { + session_config_builder = session_config_builder.turn_context(turn_context); + } + session_config_builder = session_config_builder + .include_context_trace(turn_input_envelope_for_session.include_context_trace()); + session_config_builder.build() + }; + + lime_agent::tools::set_skill_tool_session_access(session_id, model_skill_tool_enabled); + let final_result = tracker + .with_run_custom( + RunSource::Chat, + Some("agent_runtime_submit_turn".to_string()), + Some(session_id.to_string()), + Some(serde_json::Value::Object(run_start_metadata.clone())), + async { + let mut added_code_execution = false; + if effective_strategy == AsterExecutionStrategy::CodeOrchestrated { + added_code_execution = ensure_code_execution_extension_enabled(agent).await?; + } + + let primary_result = stream_reply_once( + agent, + app, + &request.event_name, + build_runtime_user_message(&request.message, request.images.as_deref()), + Some(Path::new(&workspace_root)), + build_session_config(), + cancel_token.clone(), + &request_tool_policy, + { + let run_observation = run_observation.clone(); + let app = app.clone(); + let event_name = request.event_name.clone(); + let timeline_recorder = timeline_recorder.clone(); + let workspace_root = workspace_root.clone(); + let request_metadata = request_metadata.clone(); + let provider_continuation_capability = provider_continuation_capability; + move |event| { + let mut observation = match run_observation.lock() { + Ok(guard) => guard, + Err(error) => { + tracing::warn!( + "[AsterAgent] run observation lock poisoned,继续复用内部状态" + ); + error.into_inner() + } + }; + observation.record_event( + event, + workspace_root.as_str(), + request_metadata.as_ref(), + provider_continuation_capability, + ); + let mut recorder = match timeline_recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + if let Err(error) = recorder.record_runtime_event( + &app, + &event_name, + event, + workspace_root.as_str(), + ) { + tracing::warn!( + "[AsterAgent] 记录时间线事件失败(已降级继续): {}", + error + ); + } + } + }, + ) + .await; + + let run_result: Result<(), String> = match primary_result { + Ok(()) => Ok(()), + Err(primary_error) + if effective_strategy == AsterExecutionStrategy::CodeOrchestrated + && should_fallback_to_react_from_code_orchestrated(&primary_error) => + { + tracing::warn!( + "[AsterAgent] 编排模式执行失败,自动降级到 ReAct: {}", + primary_error.message + ); + if added_code_execution { + if let Err(e) = + agent.remove_extension(CODE_EXECUTION_EXTENSION_NAME).await + { + tracing::warn!( + "[AsterAgent] 降级前移除 code_execution 扩展失败: {}", + e + ); + } + added_code_execution = false; + } + stream_reply_once( + agent, + &app, + &request.event_name, + build_runtime_user_message( + &request.message, + request.images.as_deref(), + ), + Some(Path::new(&workspace_root)), + build_session_config(), + cancel_token.clone(), + &request_tool_policy, + { + let run_observation = run_observation.clone(); + let app = app.clone(); + let event_name = request.event_name.clone(); + let timeline_recorder = timeline_recorder.clone(); + let workspace_root = workspace_root.clone(); + let request_metadata = request_metadata.clone(); + let provider_continuation_capability = + provider_continuation_capability; + move |event| { + let mut observation = match run_observation.lock() { + Ok(guard) => guard, + Err(error) => { + tracing::warn!( + "[AsterAgent] run observation lock poisoned,继续复用内部状态" + ); + error.into_inner() + } + }; + observation.record_event( + event, + workspace_root.as_str(), + request_metadata.as_ref(), + provider_continuation_capability, + ); + let mut recorder = match timeline_recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + if let Err(error) = recorder.record_runtime_event( + &app, + &event_name, + event, + workspace_root.as_str(), + ) { + tracing::warn!( + "[AsterAgent] 记录时间线事件失败(已降级继续): {}", + error + ); + } + } + }, + ) + .await + .map_err(|fallback_err| fallback_err.message) + } + Err(primary_error) => Err(primary_error.message), + }; + + if added_code_execution { + if let Err(e) = agent.remove_extension(CODE_EXECUTION_EXTENSION_NAME).await { + tracing::warn!( + "[AsterAgent] 移除 code_execution 扩展失败,后续会话可能继续保留编排模式: {}", + e + ); + } + } + + run_result + }, + move |result| { + let observation = match run_observation_for_finalize.lock() { + Ok(guard) => guard.clone(), + Err(error) => { + tracing::warn!( + "[AsterAgent] finalize run metadata 时 observation lock 已 poisoned" + ); + error.into_inner().clone() + } + }; + let metadata = + build_chat_run_finish_metadata(&run_start_metadata_for_finalize, &observation); + + match result { + Ok(_) => RunFinishDecision { + status: lime_core::database::dao::agent_run::AgentRunStatus::Success, + error_code: None, + error_message: None, + metadata: Some(metadata), + }, + Err(err) => RunFinishDecision { + status: lime_core::database::dao::agent_run::AgentRunStatus::Error, + error_code: Some("chat_stream_failed".to_string()), + error_message: Some(err.clone()), + metadata: Some(metadata), + }, + } + }, + ) + .await; + lime_agent::tools::clear_skill_tool_session_access(session_id); + + match final_result { + Ok(()) => { + complete_runtime_status_projection( + agent, + app, + &request.event_name, + &timeline_recorder, + workspace_root.as_str(), + &runtime_status_session_config, + ) + .await; + { + let mut recorder = match timeline_recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + if let Err(error) = recorder.complete_turn_success(app, &request.event_name) { + tracing::warn!("[AsterAgent] 完成 turn 时间线失败(已降级继续): {}", error); + } + } + let done_event = TauriAgentEvent::FinalDone { usage: None }; + 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( + agent, + app, + &request.event_name, + &timeline_recorder, + workspace_root.as_str(), + &runtime_status_session_config, + ) + .await; + { + let mut recorder = match timeline_recorder.lock() { + Ok(guard) => guard, + Err(error) => error.into_inner(), + }; + if let Err(timeline_error) = recorder.fail_turn(app, &request.event_name, &e) { + tracing::warn!( + "[AsterAgent] 记录失败 turn 时间线失败(已降级继续): {}", + timeline_error + ); + } + } + let error_event = TauriAgentEvent::Error { message: e.clone() }; + 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); + } + } + + // 清理取消令牌 + state.remove_cancel_token(session_id).await; + + Ok(()) +} + +fn build_queued_turn_preview(message: &str) -> String { + let compact = message.split_whitespace().collect::>().join(" "); + if compact.is_empty() { + return "空白输入".to_string(); + } + + let preview = compact.chars().take(80).collect::(); + if compact.chars().count() > 80 { + format!("{preview}...") + } else { + preview + } +} + +pub(crate) fn build_queued_turn_task( + mut request: AsterChatRequest, +) -> Result, String> { + let queued_turn_id = request + .queued_turn_id + .clone() + .unwrap_or_else(|| Uuid::new_v4().to_string()); + request.queued_turn_id = Some(queued_turn_id.clone()); + + let image_count = request + .images + .as_ref() + .map(|images| images.len()) + .unwrap_or(0); + let payload = + serde_json::to_value(&request).map_err(|e| format!("序列化排队 turn 失败: {e}"))?; + + Ok(QueuedTurnTask { + queued_turn_id, + session_id: request.session_id.clone(), + event_name: request.event_name.clone(), + message_preview: build_queued_turn_preview(&request.message), + message_text: request.message.clone(), + created_at: chrono::Utc::now().timestamp_millis(), + image_count, + payload, + }) +} + +fn deserialize_queued_turn_request(payload: serde_json::Value) -> Result { + serde_json::from_value(payload).map_err(|e| format!("反序列化排队 turn 失败: {e}")) +} + +pub(crate) fn build_runtime_queue_executor() -> RuntimeQueueExecutor { + Arc::new(|context, payload| { + async move { + let request = deserialize_queued_turn_request(payload)?; + execute_aster_chat_request( + &context.app, + &context.state, + &context.db, + &context.api_key_provider_service, + &context.logs, + &context.config_manager, + &context.mcp_manager, + &context.automation_state, + request, + ) + .await + } + .boxed() + }) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs new file mode 100644 index 000000000..38baa5cd9 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs @@ -0,0 +1,157 @@ +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +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) + } +} + +pub(crate) 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(()) +} + +pub(crate) fn resolve_session_provider_selector( + session: &aster::session::Session, +) -> Option { + SessionProviderRoutingState::from_session(session).map(|state| state.provider_selector) +} + +pub(crate) async fn create_runtime_session_internal( + db: &DbConnection, + working_dir: Option, + workspace_id: String, + name: Option, + execution_strategy: Option, +) -> Result { + tracing::info!("[AsterAgent] 创建会话: name={:?}", name); + + let workspace_id = workspace_id.trim().to_string(); + if workspace_id.is_empty() { + return Err("workspace_id 必填,请先选择项目工作区".to_string()); + } + + let manager = WorkspaceManager::new(db.clone()); + let workspace = manager + .get(&workspace_id) + .map_err(|e| format!("读取 workspace 失败: {e}"))? + .ok_or_else(|| format!("Workspace 不存在: {workspace_id}"))?; + let ensured = ensure_workspace_ready_with_auto_relocate(&manager, &workspace)?; + let workspace_root = ensured.root_path.to_string_lossy().to_string(); + + if ensured.repaired { + tracing::warn!( + "[AsterAgent] 会话创建阶段检测到 workspace 目录异常并已修复: {}{}", + workspace_root, + if ensured.relocated { + "(已迁移)" + } else { + "" + } + ); + } + + let resolved_working_dir = working_dir + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .or_else(|| Some(workspace_root.clone())); + + AsterAgentWrapper::create_session_sync( + db, + name, + resolved_working_dir, + workspace_id, + Some( + execution_strategy + .unwrap_or(AsterExecutionStrategy::React) + .as_db_value() + .to_string(), + ), + ) +} + +pub(crate) fn update_runtime_session_execution_strategy_internal( + db: &DbConnection, + session_id: &str, + execution_strategy: AsterExecutionStrategy, +) -> Result<(), String> { + AsterAgentWrapper::update_session_execution_strategy_sync( + db, + session_id, + execution_strategy.as_db_value(), + ) +} + +pub(crate) fn list_runtime_sessions_internal( + db: &DbConnection, +) -> Result, String> { + tracing::info!("[AsterAgent] 列出会话"); + AsterAgentWrapper::list_sessions_sync(db) +} + +pub(crate) fn rename_runtime_session_internal( + db: &DbConnection, + session_id: &str, + name: &str, +) -> Result<(), String> { + tracing::info!("[AsterAgent] 重命名会话: {}", session_id); + AsterAgentWrapper::rename_session_sync(db, session_id, name) +} + +pub(crate) async fn delete_runtime_session_internal( + db: &DbConnection, + session_id: &str, +) -> Result<(), String> { + tracing::info!("[AsterAgent] 删除会话: {}", session_id); + AsterAgentWrapper::delete_session(db, session_id).await?; + Ok(()) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs new file mode 100644 index 000000000..899f72f27 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs @@ -0,0 +1,973 @@ +use super::*; + +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, +} + +pub(crate) struct SubagentControlRuntime { + app_handle: AppHandle, + state: AsterAgentState, + pub(crate) 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 { + pub(crate) 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, + }, + )) +} + +pub(crate) 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)) + } +} + +pub(crate) 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, +} + +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 +} + +pub(crate) 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 + ); + } + } +} + +pub(crate) 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) +} + +pub(crate) 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) +} + +pub(crate) 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)), + }) +} + +pub(crate) 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 }) +} + +pub(crate) 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; + } +} + +pub(crate) 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, + }) +} + +pub(crate) 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, + }) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs new file mode 100644 index 000000000..9e446950b --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -0,0 +1,1938 @@ +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use lime_agent::request_tool_policy::resolve_request_tool_policy; + use regex::Regex; + use std::ffi::OsString; + use std::path::{Path, PathBuf}; + use std::sync::{Mutex, OnceLock}; + use tempfile::TempDir; + + struct DummyTool { + name: String, + description: String, + schema: serde_json::Value, + } + + impl DummyTool { + fn new(name: &str, description: &str, schema: serde_json::Value) -> Self { + Self { + name: name.to_string(), + description: description.to_string(), + schema, + } + } + } + + #[async_trait] + impl Tool for DummyTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.description + } + + fn input_schema(&self) -> serde_json::Value { + self.schema.clone() + } + + async fn execute( + &self, + _params: serde_json::Value, + _context: &ToolContext, + ) -> Result { + Ok(ToolResult::success("ok")) + } + } + + fn durable_memory_test_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + struct DurableMemoryEnvGuard { + previous: Option, + } + + impl DurableMemoryEnvGuard { + fn set(path: &Path) -> Self { + let previous = lime_core::env_compat::var_os(&[ + lime_agent::LIME_DURABLE_MEMORY_ROOT_ENV, + lime_agent::LEGACY_DURABLE_MEMORY_ROOT_ENV, + ]); + std::env::set_var(lime_agent::LIME_DURABLE_MEMORY_ROOT_ENV, path.as_os_str()); + std::env::remove_var(lime_agent::LEGACY_DURABLE_MEMORY_ROOT_ENV); + Self { previous } + } + } + + impl Drop for DurableMemoryEnvGuard { + fn drop(&mut self) { + if let Some(value) = &self.previous { + std::env::set_var(lime_agent::LIME_DURABLE_MEMORY_ROOT_ENV, value); + } else { + std::env::remove_var(lime_agent::LIME_DURABLE_MEMORY_ROOT_ENV); + } + std::env::remove_var(lime_agent::LEGACY_DURABLE_MEMORY_ROOT_ENV); + } + } + + 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#"{ + "message": "Hello", + "session_id": "test-session", + "event_name": "agent_stream", + "workspace_id": "workspace-test" + }"#; + + let request: AsterChatRequest = serde_json::from_str(json).unwrap(); + assert_eq!(request.message, "Hello"); + assert_eq!(request.session_id, "test-session"); + assert_eq!(request.event_name, "agent_stream"); + assert_eq!(request.workspace_id, "workspace-test"); + assert_eq!(request.execution_strategy, None); + assert_eq!(request.auto_continue, None); + } + + #[test] + fn test_message_suggests_live_search_accepts_explicit_search_verbs() { + assert!(message_suggests_live_search( + "请帮我搜一下哥德尔不完备定理的历史背景" + )); + assert!(message_suggests_live_search( + "please look up kyoto travel tips" + )); + assert!(!message_suggests_live_search( + "帮我解释一下什么是向量数据库" + )); + } + + #[test] + fn test_aster_chat_request_deserialize_with_execution_strategy() { + let json = r#"{ + "message": "Hello", + "session_id": "test-session", + "event_name": "agent_stream", + "workspace_id": "workspace-test", + "execution_strategy": "code_orchestrated" + }"#; + + let request: AsterChatRequest = serde_json::from_str(json).unwrap(); + assert_eq!( + request.execution_strategy, + Some(AsterExecutionStrategy::CodeOrchestrated) + ); + } + + #[test] + fn test_aster_chat_request_deserialize_with_web_search_flag() { + let json = r#"{ + "message": "Hello", + "session_id": "test-session", + "event_name": "agent_stream", + "workspace_id": "workspace-test", + "web_search": true + }"#; + + let request: AsterChatRequest = serde_json::from_str(json).unwrap(); + assert_eq!(request.web_search, Some(true)); + } + + #[test] + fn test_aster_chat_request_deserialize_with_auto_continue_payload() { + let json = r#"{ + "message": "Hello", + "session_id": "test-session", + "event_name": "agent_stream", + "workspace_id": "workspace-test", + "auto_continue": { + "enabled": true, + "fast_mode_enabled": true, + "continuation_length": 2, + "sensitivity": 88, + "source": "document_canvas" + } + }"#; + + let request: AsterChatRequest = serde_json::from_str(json).unwrap(); + assert_eq!( + request.auto_continue, + Some(AutoContinuePayload { + enabled: true, + fast_mode_enabled: true, + continuation_length: 2, + sensitivity: 88, + source: Some("document_canvas".to_string()), + }) + ); + } + + #[test] + fn test_aster_chat_request_deserialize_with_auto_continue_camel_case_aliases() { + let json = r#"{ + "message": "Hello", + "session_id": "test-session", + "event_name": "agent_stream", + "workspace_id": "workspace-test", + "autoContinue": { + "enabled": true, + "fastModeEnabled": true, + "continuationLength": 1, + "sensitivity": 45 + } + }"#; + + let request: AsterChatRequest = serde_json::from_str(json).unwrap(); + assert_eq!( + request.auto_continue, + Some(AutoContinuePayload { + enabled: true, + fast_mode_enabled: true, + continuation_length: 1, + sensitivity: 45, + source: None, + }) + ); + } + + #[test] + fn test_aster_chat_request_deserialize_with_metadata() { + let json = r#"{ + "message": "Hello", + "session_id": "test-session", + "event_name": "agent_stream", + "workspace_id": "workspace-test", + "metadata": { + "harness": { + "theme": "social-media", + "gate_key": "write_mode", + "run_title": "社媒初稿" + } + } + }"#; + + let request: AsterChatRequest = serde_json::from_str(json).unwrap(); + assert_eq!( + request + .metadata + .as_ref() + .and_then(|value| value.get("harness")) + .and_then(|value| value.get("theme")) + .and_then(serde_json::Value::as_str), + Some("social-media") + ); + } + + #[test] + fn test_resolve_runtime_chat_mode_prefers_explicit_chat_mode() { + let metadata = serde_json::json!({ + "harness": { + "theme": "social-media", + "chat_mode": "general" + } + }); + + assert_eq!( + resolve_runtime_chat_mode(Some(&metadata)), + RuntimeChatMode::General + ); + } + + #[test] + fn test_resolve_runtime_chat_mode_falls_back_to_general_theme_group() { + let metadata = serde_json::json!({ + "harness": { + "theme": "planning" + } + }); + + assert_eq!( + resolve_runtime_chat_mode(Some(&metadata)), + RuntimeChatMode::General + ); + } + + #[test] + fn test_default_web_search_enabled_for_chat_mode_requires_explicit_opt_in() { + assert!(!default_web_search_enabled_for_chat_mode( + RuntimeChatMode::Agent + )); + assert!(!default_web_search_enabled_for_chat_mode( + RuntimeChatMode::Creator + )); + assert!(!default_web_search_enabled_for_chat_mode( + RuntimeChatMode::General + )); + } + + #[test] + fn test_browser_required_task_disables_web_search_policy() { + let metadata = serde_json::json!({ + "harness": { + "browser_requirement": "required_with_user_step" + } + }); + + assert_eq!( + apply_browser_requirement_to_request_tool_policy( + Some(&metadata), + Some(true), + Some(RequestToolPolicyMode::Allowed), + ), + (Some(false), Some(RequestToolPolicyMode::Disabled)) + ); + } + + #[test] + fn test_optional_browser_task_keeps_original_web_search_policy() { + let metadata = serde_json::json!({ + "harness": { + "browser_requirement": "optional" + } + }); + + assert_eq!( + apply_browser_requirement_to_request_tool_policy( + Some(&metadata), + Some(true), + Some(RequestToolPolicyMode::Allowed), + ), + (Some(true), Some(RequestToolPolicyMode::Allowed)) + ); + } + + #[test] + fn test_should_enable_model_skill_tool_defaults_to_false() { + let metadata = serde_json::json!({ + "harness": { + "theme": "general", + "session_mode": "default" + } + }); + + assert!(!should_enable_model_skill_tool(Some(&metadata))); + assert!(!should_enable_model_skill_tool(None)); + } + + #[test] + fn test_should_enable_model_skill_tool_allows_theme_workbench() { + let metadata = serde_json::json!({ + "harness": { + "theme": "social-media", + "session_mode": "theme_workbench" + } + }); + + assert!(should_enable_model_skill_tool(Some(&metadata))); + } + + #[test] + fn test_should_enable_model_skill_tool_respects_explicit_override() { + let metadata = serde_json::json!({ + "harness": { + "theme": "social-media", + "session_mode": "theme_workbench", + "allow_model_skills": false + } + }); + + assert!(!should_enable_model_skill_tool(Some(&metadata))); + } + + #[test] + fn test_extract_browser_assist_runtime_hint_from_harness_metadata() { + let metadata = serde_json::json!({ + "harness": { + "theme": "general", + "browser_assist": { + "profile_key": "general_browser_assist", + "preferred_backend": "cdp_direct", + "auto_launch": true, + "launch_url": "https://www.google.com" + } + } + }); + + assert_eq!( + extract_browser_assist_runtime_hint(Some(&metadata)), + Some(BrowserAssistRuntimeHint { + profile_key: "general_browser_assist".to_string(), + preferred_backend: Some(BrowserBackendType::CdpDirect), + auto_launch: true, + launch_url: Some("https://www.google.com".to_string()), + }) + ); + } + + #[test] + fn test_resolve_browser_backend_keeps_explicit_backend() { + let params = serde_json::json!({ + "backend": "cdp_direct" + }); + let session_hint = BrowserAssistRuntimeHint { + profile_key: "general_browser_assist".to_string(), + preferred_backend: Some(BrowserBackendType::AsterCompat), + auto_launch: true, + launch_url: None, + }; + + assert_eq!( + LimeBrowserMcpTool::resolve_backend("find", ¶ms, Some(&session_hint)), + Some(BrowserBackendType::CdpDirect) + ); + } + + #[test] + fn test_resolve_browser_backend_does_not_force_cdp_for_unsupported_action() { + let params = serde_json::json!({}); + let session_hint = BrowserAssistRuntimeHint { + profile_key: "general_browser_assist".to_string(), + preferred_backend: Some(BrowserBackendType::CdpDirect), + auto_launch: true, + launch_url: None, + }; + + assert_eq!( + LimeBrowserMcpTool::resolve_backend("find", ¶ms, Some(&session_hint)), + None + ); + assert_eq!( + LimeBrowserMcpTool::resolve_backend("javascript_tool", ¶ms, Some(&session_hint)), + None + ); + } + + #[test] + fn test_resolve_browser_backend_keeps_cdp_for_supported_action() { + let params = serde_json::json!({}); + let session_hint = BrowserAssistRuntimeHint { + profile_key: "general_browser_assist".to_string(), + preferred_backend: Some(BrowserBackendType::CdpDirect), + auto_launch: true, + launch_url: None, + }; + + assert_eq!( + LimeBrowserMcpTool::resolve_backend("navigate", ¶ms, Some(&session_hint)), + Some(BrowserBackendType::CdpDirect) + ); + assert_eq!( + LimeBrowserMcpTool::resolve_backend("read_page", ¶ms, Some(&session_hint)), + Some(BrowserBackendType::CdpDirect) + ); + } + + #[test] + fn test_is_browser_assist_enabled_respects_explicit_flag() { + let disabled_metadata = serde_json::json!({ + "harness": { + "browser_assist": { + "enabled": false, + "profile_key": "general_browser_assist" + } + } + }); + let enabled_metadata = serde_json::json!({ + "harness": { + "browser_assist": { + "profile_key": "general_browser_assist" + } + } + }); + + assert!(!is_browser_assist_enabled(Some(&disabled_metadata))); + assert!(is_browser_assist_enabled(Some(&enabled_metadata))); + assert!(!is_browser_assist_enabled(None)); + } + + #[test] + fn test_append_browser_assist_session_permissions_adds_session_scoped_rules() { + let metadata = serde_json::json!({ + "harness": { + "browser_assist": { + "enabled": true, + "profile_key": "general_browser_assist" + } + } + }); + let mut permissions = Vec::new(); + + append_browser_assist_session_permissions( + &mut permissions, + "session-browser-1", + Some(&metadata), + ); + + let allow_rule = permissions + .iter() + .find(|permission| permission.tool == BROWSER_ASSIST_ALLOW_PATTERN) + .expect("should add browser assist allow rule"); + assert!(allow_rule.allowed); + assert_eq!(allow_rule.priority, 1100); + assert_eq!(allow_rule.conditions.len(), 1); + assert_eq!( + allow_rule.conditions[0].field.as_deref(), + Some("session_id") + ); + assert_eq!( + allow_rule.conditions[0].value, + serde_json::json!("session-browser-1") + ); + + let deny_rule = permissions + .iter() + .find(|permission| permission.tool == "mcp__playwright__*") + .expect("should add playwright deny rule"); + assert!(!deny_rule.allowed); + assert_eq!(deny_rule.priority, 1200); + assert_eq!(deny_rule.conditions, allow_rule.conditions); + } + + #[test] + fn test_agent_runtime_submit_turn_request_maps_to_aster_chat_request() { + let json = r#"{ + "message": "Hello runtime", + "session_id": "runtime-session", + "event_name": "runtime_stream", + "workspace_id": "workspace-runtime", + "turn_config": { + "execution_strategy": "auto", + "web_search": true, + "system_prompt": "runtime prompt", + "provider_config": { + "provider_id": "custom-provider", + "provider_name": "custom-provider", + "model_name": "gpt-5.3-codex" + }, + "metadata": { + "source": "hook-facade" + } + } + }"#; + + let request: AgentRuntimeSubmitTurnRequest = serde_json::from_str(json).unwrap(); + let mapped: AsterChatRequest = request.into(); + + assert_eq!(mapped.message, "Hello runtime"); + assert_eq!(mapped.session_id, "runtime-session"); + assert_eq!(mapped.event_name, "runtime_stream"); + assert_eq!(mapped.workspace_id, "workspace-runtime"); + assert_eq!( + mapped.execution_strategy, + Some(AsterExecutionStrategy::Auto) + ); + assert_eq!(mapped.web_search, Some(true)); + assert_eq!(mapped.system_prompt.as_deref(), Some("runtime prompt")); + assert_eq!( + mapped + .provider_config + .as_ref() + .and_then(|config| config.provider_id.as_deref()), + Some("custom-provider") + ); + assert_eq!( + mapped + .metadata + .as_ref() + .and_then(|value| value.get("source")) + .and_then(serde_json::Value::as_str), + Some("hook-facade") + ); + } + + #[test] + fn test_build_runtime_user_message_includes_images() { + let message = build_runtime_user_message( + "这个是什么", + Some(&[ImageInput { + data: "aGVsbG8=".to_string(), + media_type: "image/png".to_string(), + }]), + ); + + assert_eq!(message.as_concat_text(), "这个是什么"); + assert_eq!(message.content.len(), 2); + + if let MessageContent::Image(image) = &message.content[1] { + assert_eq!(image.data, "aGVsbG8="); + assert_eq!(image.mime_type, "image/png"); + } else { + panic!("expected image content in runtime user message"); + } + } + + #[test] + fn test_build_runtime_action_user_data_prefers_structured_payload() { + let request = AgentRuntimeRespondActionRequest { + session_id: "session-1".to_string(), + request_id: "req-1".to_string(), + action_type: AgentRuntimeActionType::AskUser, + confirmed: true, + response: Some("{\"answer\":\"A\"}".to_string()), + user_data: Some(serde_json::json!({ "answer": "B" })), + metadata: None, + event_name: None, + }; + + assert_eq!( + build_runtime_action_user_data(&request), + serde_json::json!({ "answer": "B" }) + ); + } + + #[test] + fn test_build_runtime_action_user_data_parses_json_response() { + let request = AgentRuntimeRespondActionRequest { + session_id: "session-1".to_string(), + request_id: "req-1".to_string(), + action_type: AgentRuntimeActionType::Elicitation, + confirmed: true, + response: Some("{\"answer\":\"A\"}".to_string()), + user_data: None, + metadata: None, + event_name: None, + }; + + assert_eq!( + build_runtime_action_user_data(&request), + serde_json::json!({ "answer": "A" }) + ); + } + + #[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( + "write_file", + Some(r##"{"path":"social-posts/demo.md","content":"# 标题"}"##), + "/tmp/workspace", + ); + + assert_eq!(path.as_deref(), Some("social-posts/demo.md")); + } + + #[test] + fn test_resolve_social_run_artifact_descriptor_matches_social_draft() { + let descriptor = resolve_social_run_artifact_descriptor( + "social-posts/draft.md", + Some("write_mode"), + Some("社媒初稿"), + ); + + assert_eq!(descriptor.artifact_type, "draft"); + assert_eq!(descriptor.stage, "drafting"); + assert_eq!(descriptor.version_label, "社媒初稿"); + assert!(!descriptor.is_auxiliary); + } + + #[test] + fn test_build_chat_run_finish_metadata_includes_social_fields() { + let base = build_chat_run_metadata_base( + &AsterChatRequest { + message: "hello".to_string(), + session_id: "session-1".to_string(), + event_name: "event-1".to_string(), + images: None, + provider_config: None, + project_id: Some("project-1".to_string()), + workspace_id: "workspace-1".to_string(), + web_search: Some(false), + search_mode: None, + execution_strategy: Some(AsterExecutionStrategy::React), + auto_continue: None, + system_prompt: None, + metadata: Some(serde_json::json!({ + "harness": { + "theme": "social-media", + "gate_key": "write_mode" + } + })), + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }, + "workspace-1", + AsterExecutionStrategy::React, + &RequestToolPolicy { + search_mode: RequestToolPolicyMode::Disabled, + effective_web_search: false, + required_tools: vec![], + allowed_tools: vec![], + disallowed_tools: vec![], + }, + false, + None, + ); + let mut observation = ChatRunObservation::default(); + observation.record_artifact_path( + "social-posts/draft.md".to_string(), + Some(&serde_json::json!({ + "harness": { + "theme": "social-media", + "gate_key": "write_mode" + } + })), + ); + + let metadata = build_chat_run_finish_metadata(&base, &observation); + + assert_eq!( + metadata + .get("artifact_paths") + .and_then(serde_json::Value::as_array), + Some(&vec![serde_json::json!("social-posts/draft.md")]) + ); + assert_eq!( + metadata + .get("artifact_type") + .and_then(serde_json::Value::as_str), + Some("draft") + ); + assert_eq!( + metadata.get("stage").and_then(serde_json::Value::as_str), + Some("drafting") + ); + assert_eq!( + metadata + .get("version_id") + .and_then(serde_json::Value::as_str), + Some("artifact:social-posts/draft.md") + ); + } + + #[test] + fn test_chat_run_observation_records_previous_response_id_from_message_event() { + let mut observation = ChatRunObservation::default(); + observation.record_event( + &TauriAgentEvent::Message { + message: TauriMessage { + id: Some("resp-1".to_string()), + role: "assistant".to_string(), + content: vec![TauriMessageContent::Text { + text: "hello".to_string(), + }], + timestamp: 0, + }, + }, + "/tmp/workspace", + None, + ProviderContinuationCapability::PreviousResponseId, + ); + + assert_eq!( + observation.provider_continuation, + Some(ProviderContinuationState::previous_response_id("resp-1")) + ); + } + + #[test] + fn test_chat_run_observation_records_provider_session_token_from_message_event() { + let mut observation = ChatRunObservation::default(); + observation.record_event( + &TauriAgentEvent::Message { + message: TauriMessage { + id: Some("conv-1".to_string()), + role: "assistant".to_string(), + content: vec![TauriMessageContent::Text { + text: "hello".to_string(), + }], + timestamp: 0, + }, + }, + "/tmp/workspace", + None, + ProviderContinuationCapability::ProviderSessionToken, + ); + + assert_eq!( + observation.provider_continuation, + Some(ProviderContinuationState::provider_session_token("conv-1")) + ); + } + + #[test] + fn test_build_chat_run_finish_metadata_includes_provider_continuation() { + let mut observation = ChatRunObservation::default(); + observation.provider_continuation = + Some(ProviderContinuationState::previous_response_id("resp-1")); + + let metadata = build_chat_run_finish_metadata(&serde_json::Map::new(), &observation); + + assert_eq!( + metadata.get("provider_continuation"), + Some(&serde_json::json!({ + "kind": "previous_response_id", + "previous_response_id": "resp-1" + })) + ); + assert_eq!( + metadata + .get("provider_continuation_kind") + .and_then(serde_json::Value::as_str), + Some("previous_response_id") + ); + } + + #[test] + fn test_provider_routing_matches_current_allows_missing_selector_on_historical_run() { + let previous = TurnProviderRoutingSnapshot { + provider_name: "OpenAI".to_string(), + provider_selector: None, + model_name: "o3-mini".to_string(), + credential_uuid: None, + configured_from_request: false, + used_inline_api_key: false, + }; + let current = TurnProviderRoutingSnapshot { + provider_name: "openai".to_string(), + provider_selector: Some("openai".to_string()), + model_name: "o3-mini".to_string(), + credential_uuid: Some("cred-1".to_string()), + configured_from_request: true, + used_inline_api_key: true, + }; + + assert!(provider_routing_matches_current(&previous, ¤t)); + } + + #[test] + fn test_aster_execution_strategy_default_is_auto() { + assert_eq!( + AsterExecutionStrategy::default(), + AsterExecutionStrategy::Auto + ); + } + + #[test] + fn test_aster_execution_strategy_from_db_value_none_is_auto() { + assert_eq!( + AsterExecutionStrategy::from_db_value(None), + AsterExecutionStrategy::Auto + ); + } + + #[test] + fn test_aster_execution_strategy_from_db_value_unknown_is_auto() { + assert_eq!( + AsterExecutionStrategy::from_db_value(Some("unknown")), + AsterExecutionStrategy::Auto + ); + } + + #[test] + fn test_aster_execution_strategy_auto_prefers_react_when_tool_search_explicit() { + let strategy = + AsterExecutionStrategy::Auto.effective_for_message("请先调用 tool_search 再继续"); + assert_eq!(strategy, AsterExecutionStrategy::React); + } + + #[test] + fn test_aster_execution_strategy_auto_prefers_react_for_generic_web_search() { + let strategy = + AsterExecutionStrategy::Auto.effective_for_message("帮我联网搜索今天的 AI 新闻"); + assert_eq!(strategy, AsterExecutionStrategy::React); + } + + #[test] + fn test_aster_execution_strategy_auto_defaults_react_for_code_task() { + let strategy = AsterExecutionStrategy::Auto + .effective_for_message("请抓取这个仓库并修复 Rust 编译错误,然后给出补丁"); + assert_eq!(strategy, AsterExecutionStrategy::React); + } + + #[test] + fn test_aster_execution_strategy_code_orchestrated_still_prefers_react_for_web_search() { + let strategy = AsterExecutionStrategy::CodeOrchestrated + .effective_for_message("请使用 WebSearch 工具检索并给出来源"); + assert_eq!(strategy, AsterExecutionStrategy::React); + } + + #[test] + fn test_aster_execution_strategy_code_orchestrated_forces_react_for_websearch_instruction() { + let strategy = AsterExecutionStrategy::CodeOrchestrated + .effective_for_message("请必须使用 WebSearch 工具检索,不要用已有知识回答"); + assert_eq!(strategy, AsterExecutionStrategy::React); + } + + #[test] + fn test_merge_system_prompt_with_request_tool_policy_adds_policy_when_enabled() { + let policy = resolve_request_tool_policy(Some(true), false); + let merged = + merge_system_prompt_with_request_tool_policy(Some("你是助手".to_string()), &policy) + .expect("should have merged prompt"); + assert!(merged.contains(REQUEST_TOOL_POLICY_MARKER)); + assert!(merged.contains("WebSearch")); + } + + #[test] + fn test_merge_system_prompt_with_request_tool_policy_keeps_original_when_disabled() { + let base = Some("你好".to_string()); + let policy = resolve_request_tool_policy(Some(false), false); + let merged = merge_system_prompt_with_request_tool_policy(base.clone(), &policy); + assert_eq!(merged, base); + } + + #[test] + fn test_merge_system_prompt_with_request_tool_policy_no_duplicate_marker() { + let base = Some(format!("{REQUEST_TOOL_POLICY_MARKER}\n已有策略")); + let policy = resolve_request_tool_policy(Some(true), false); + let merged = merge_system_prompt_with_request_tool_policy(base.clone(), &policy); + assert_eq!(merged, base); + } + + #[test] + fn test_merge_system_prompt_with_auto_continue_appends_prompt() { + let config = AutoContinuePayload { + enabled: true, + fast_mode_enabled: false, + continuation_length: 1, + sensitivity: 55, + source: Some("theme_workbench_document_auto_continue".to_string()), + }; + let merged = + merge_system_prompt_with_auto_continue(Some("你是助手".to_string()), Some(&config)) + .expect("should contain merged prompt"); + assert!(merged.contains(AUTO_CONTINUE_PROMPT_MARKER)); + assert!(merged.contains("续写长度")); + assert!(merged.contains("theme_workbench_document_auto_continue")); + } + + #[test] + fn test_merge_system_prompt_with_auto_continue_skip_when_disabled() { + let config = AutoContinuePayload { + enabled: false, + fast_mode_enabled: false, + continuation_length: 1, + sensitivity: 55, + source: None, + }; + let base = Some("你是助手".to_string()); + let merged = merge_system_prompt_with_auto_continue(base.clone(), Some(&config)); + assert_eq!(merged, base); + } + + #[test] + fn test_merge_system_prompt_with_elicitation_context_appends_prompt() { + let metadata = serde_json::json!({ + "elicitation_context": { + "source": "legacy_questionnaire", + "mode": "compatibility_bridge", + "entries": [ + { + "label": "目标受众", + "summary": "客户" + }, + { + "label": "语气偏好", + "summary": "友好专业" + } + ] + } + }); + + let merged = merge_system_prompt_with_elicitation_context( + Some("你是助手".to_string()), + Some(&metadata), + ) + .expect("should contain merged prompt"); + + assert!(merged.contains(ELICITATION_CONTEXT_PROMPT_MARKER)); + assert!(merged.contains("目标受众")); + assert!(merged.contains("友好专业")); + assert!(merged.contains("legacy_questionnaire")); + } + + #[test] + fn test_merge_system_prompt_with_elicitation_context_skips_duplicate_marker() { + let metadata = serde_json::json!({ + "elicitation_context": { + "entries": [ + { + "label": "目标受众", + "summary": "客户" + } + ] + } + }); + let base = Some(format!("{ELICITATION_CONTEXT_PROMPT_MARKER}\n已有信息")); + let merged = merge_system_prompt_with_elicitation_context(base.clone(), Some(&metadata)); + assert_eq!(merged, base); + } + + #[test] + fn test_merge_system_prompt_with_elicitation_context_formats_non_string_values() { + let metadata = serde_json::json!({ + "elicitation_context": { + "entries": [ + { + "label": "渠道偏好", + "value": ["公众号", "视频号"] + }, + { + "label": "是否需要 CTA", + "value": true + }, + { + "label": "目标轮次", + "value": 3 + } + ] + } + }); + + let merged = merge_system_prompt_with_elicitation_context( + Some("你是助手".to_string()), + Some(&metadata), + ) + .expect("should contain merged prompt"); + + assert!(merged.contains("渠道偏好")); + assert!(merged.contains("公众号、视频号")); + assert!(merged.contains("是否需要 CTA")); + assert!(merged.contains("是")); + assert!(merged.contains("目标轮次")); + assert!(merged.contains("3")); + } + + #[test] + fn test_should_fallback_to_react_from_code_orchestrated_when_no_event_emitted() { + let error = ReplyAttemptError { + message: "Stream error: timeout".to_string(), + emitted_any: false, + }; + assert!(should_fallback_to_react_from_code_orchestrated(&error)); + } + + #[test] + fn test_should_fallback_to_react_from_code_orchestrated_when_unknown_subscript() { + let error = ReplyAttemptError { + message: "Agent provider execution failed: Unknown subscript 'web_scraping'" + .to_string(), + emitted_any: true, + }; + assert!(should_fallback_to_react_from_code_orchestrated(&error)); + } + + #[test] + fn test_should_not_fallback_to_react_from_code_orchestrated_for_general_error() { + let error = ReplyAttemptError { + message: "Agent provider execution failed: quota exceeded".to_string(), + emitted_any: true, + }; + assert!(!should_fallback_to_react_from_code_orchestrated(&error)); + } + + #[test] + fn test_validate_elicitation_submission_rejects_empty_session_id() { + let result = validate_elicitation_submission(" ", "req-1"); + assert_eq!(result, Err("session_id 不能为空".to_string())); + } + + #[test] + fn test_validate_elicitation_submission_rejects_empty_request_id() { + let result = validate_elicitation_submission("session-1", " "); + assert_eq!(result, Err("request_id 不能为空".to_string())); + } + + #[test] + fn test_validate_elicitation_submission_trims_session_id() { + let result = validate_elicitation_submission(" session-1 ", "req-1"); + 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("需要确认"); + let normalized = normalize_workspace_tool_permission_behavior(permission, true); + assert_eq!(normalized.behavior, PermissionBehavior::Allow); + assert!(normalized.message.is_none()); + } + + #[test] + fn test_normalize_workspace_tool_permission_behavior_non_auto_denies_warning() { + let permission = PermissionCheckResult::ask("需要确认"); + let normalized = normalize_workspace_tool_permission_behavior(permission, false); + assert_eq!(normalized.behavior, PermissionBehavior::Deny); + assert!(normalized + .message + .as_deref() + .unwrap_or_default() + .contains("当前模式不支持交互确认")); + } + + #[test] + fn test_build_workspace_shell_allow_pattern_strict_mode_rejects_python_command() { + let escaped_root = regex::escape("/tmp/workspace"); + let pattern = build_workspace_shell_allow_pattern(&escaped_root, false); + let regex = Regex::new(&pattern).unwrap(); + + assert!(regex.is_match("rg -n \"foo\" .")); + assert!(!regex.is_match("python -m pip install playwright")); + } + + #[test] + fn test_build_workspace_shell_allow_pattern_auto_mode_allows_common_commands() { + let escaped_root = regex::escape("/tmp/workspace"); + let pattern = build_workspace_shell_allow_pattern(&escaped_root, true); + let regex = Regex::new(&pattern).unwrap(); + + assert!(regex.is_match("python -m pip install playwright")); + assert!(regex.is_match("npm install && npm run build")); + 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", + "role_key": "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("roleKey: explorer")); + assert!(prompt.contains("skills: repo-exploration")); + assert!(prompt.contains("映射到对应结构化字段")); + } + + #[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!({ + "cmd": "echo hello", + "timeout": 10 + }); + + let normalized: serde_json::Value = normalize_shell_command_params(&input); + assert_eq!( + normalized + .get("command") + .and_then(serde_json::Value::as_str), + Some("echo hello") + ); + } + + #[test] + fn test_normalize_shell_command_params_keeps_existing_command() { + let input = serde_json::json!({ + "command": "pwd", + "cmd": "echo should_not_override" + }); + + let normalized: serde_json::Value = normalize_shell_command_params(&input); + assert_eq!( + normalized + .get("command") + .and_then(serde_json::Value::as_str), + Some("pwd") + ); + } + + #[test] + fn test_normalize_params_for_durable_memory_support_maps_read_path() { + let _lock = durable_memory_test_lock().lock().expect("lock env"); + let tmp = TempDir::new().expect("create temp dir"); + let _env = DurableMemoryEnvGuard::set(tmp.path()); + + let input = serde_json::json!({ + "path": "/memories/preferences.md" + }); + let normalized: serde_json::Value = normalize_params_for_durable_memory_support("read", &input) + .expect("normalize read params"); + let expected = tmp + .path() + .join("preferences.md") + .to_string_lossy() + .to_string(); + + assert_eq!( + normalized.get("path").and_then(serde_json::Value::as_str), + Some(expected.as_str()) + ); + } + + #[test] + fn test_normalize_params_for_durable_memory_support_rewrites_glob_pattern() { + let _lock = durable_memory_test_lock().lock().expect("lock env"); + let tmp = TempDir::new().expect("create temp dir"); + let _env = DurableMemoryEnvGuard::set(tmp.path()); + + let input = serde_json::json!({ + "pattern": "/memories/**/*.md" + }); + let normalized: serde_json::Value = + normalize_params_for_durable_memory_support("glob", &input) + .expect("normalize glob params"); + let expected_root = tmp.path().to_string_lossy().to_string(); + + assert_eq!( + normalized.get("path").and_then(serde_json::Value::as_str), + Some(expected_root.as_str()) + ); + assert_eq!( + normalized + .get("pattern") + .and_then(serde_json::Value::as_str), + Some("**/*.md") + ); + } + + #[test] + fn test_normalize_params_for_durable_memory_support_rejects_glob_parent_segments() { + let _lock = durable_memory_test_lock().lock().expect("lock env"); + let tmp = TempDir::new().expect("create temp dir"); + let _env = DurableMemoryEnvGuard::set(tmp.path()); + + let input = serde_json::json!({ + "pattern": "/memories/../escape.md" + }); + let error = normalize_params_for_durable_memory_support("glob", &input) + .expect_err("should reject parent path"); + + assert!(error.to_string().contains("不允许包含 `..`")); + } + + #[test] + fn test_encode_tool_result_for_harness_observability_appends_metadata_block() { + let result = ToolResult::success("任务已完成") + .with_metadata("output_file", serde_json::json!("/tmp/task.log")) + .with_metadata("exit_code", serde_json::json!(0)); + + let encoded = encode_tool_result_for_harness_observability(result); + assert!(encoded.success); + assert!(encoded + .output + .as_deref() + .unwrap_or_default() + .contains(LIME_TOOL_METADATA_BEGIN)); + assert!(encoded + .output + .as_deref() + .unwrap_or_default() + .contains("\"output_file\":\"/tmp/task.log\"")); + } + + #[test] + fn test_encode_tool_result_for_harness_observability_converts_error_to_success_output() { + let result = + ToolResult::error("执行失败").with_metadata("failed_count", serde_json::json!(1)); + + let encoded = encode_tool_result_for_harness_observability(result); + assert!(encoded.success); + let output = encoded.output.as_deref().unwrap_or_default(); + assert!(output.contains("执行失败")); + assert!(output.contains(LIME_TOOL_METADATA_BEGIN)); + assert!(output.contains("\"reported_success\":false")); + } + + #[test] + fn test_encode_tool_result_for_harness_observability_is_idempotent() { + let initial = ToolResult::success(format!( + "ok\n\n{LIME_TOOL_METADATA_BEGIN}\n{{\"reported_success\":false}}\n{LIME_TOOL_METADATA_END}" + )) + .with_metadata("reported_success", serde_json::json!(false)); + + let encoded = encode_tool_result_for_harness_observability(initial); + let output = encoded.output.as_deref().unwrap_or_default(); + assert_eq!(output.matches(LIME_TOOL_METADATA_BEGIN).count(), 1); + assert_eq!(output.matches(LIME_TOOL_METADATA_END).count(), 1); + } + + #[test] + fn test_shared_task_manager_returns_same_instance() { + let first = shared_task_manager(); + let second = shared_task_manager(); + assert!(Arc::ptr_eq(&first, &second)); + } + + #[test] + fn test_parse_subagent_role_supports_aliases() { + assert_eq!( + parse_subagent_role(Some("explore")).unwrap(), + SubAgentRole::Explorer + ); + assert_eq!( + parse_subagent_role(Some("plan")).unwrap(), + SubAgentRole::Planner + ); + assert_eq!( + parse_subagent_role(Some("code")).unwrap(), + SubAgentRole::Executor + ); + assert_eq!(parse_subagent_role(None).unwrap(), SubAgentRole::Executor); + } + + #[test] + fn test_build_subagent_task_definition_uses_role_defaults() { + let input = SubAgentTaskToolInput { + prompt: "分析当前 harness 缺口".to_string(), + task_type: None, + description: None, + role: Some("explorer".to_string()), + timeout_secs: Some(45), + model: None, + return_summary: None, + allowed_tools: None, + denied_tools: None, + max_tokens: None, + }; + + let task = build_subagent_task_definition(&input, SubAgentRole::Explorer).unwrap(); + assert_eq!(task.task_type, "explore"); + assert_eq!(task.timeout.map(|value| value.as_secs()), Some(45)); + assert!(task.return_summary); + } + + #[test] + fn test_build_subagent_task_definition_applies_optional_fields() { + let input = SubAgentTaskToolInput { + prompt: "实现 harness 面板".to_string(), + task_type: Some("code".to_string()), + description: Some("实现前端面板".to_string()), + role: Some("executor".to_string()), + timeout_secs: Some(120), + model: Some("claude-sonnet-4-20250514".to_string()), + return_summary: Some(false), + allowed_tools: Some(vec!["read_file".to_string(), "write_file".to_string()]), + denied_tools: Some(vec!["execute_command".to_string()]), + max_tokens: Some(4096), + }; + + let task = build_subagent_task_definition(&input, SubAgentRole::Executor).unwrap(); + assert_eq!(task.task_type, "code"); + assert_eq!(task.description.as_deref(), Some("实现前端面板")); + assert_eq!(task.model.as_deref(), Some("claude-sonnet-4-20250514")); + assert!(!task.return_summary); + assert_eq!( + task.allowed_tools, + Some(vec!["read_file".to_string(), "write_file".to_string()]) + ); + assert_eq!(task.denied_tools, Some(vec!["execute_command".to_string()])); + 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!({ + "x-lime": { + "deferred_loading": true, + "always_visible": false, + "allowed_callers": ["assistant", "code_execution"], + "input_examples": [{"query":"rust"}], + "tags": ["mcp", "filesystem"] + } + }); + let (deferred, always_visible, allowed_callers, tags, input_examples) = + ToolSearchBridgeTool::parse_schema_metadata("docs_search", &schema); + assert!(deferred); + assert!(!always_visible); + assert_eq!( + allowed_callers, + vec!["assistant".to_string(), "code_execution".to_string()] + ); + assert_eq!(tags, vec!["mcp".to_string(), "filesystem".to_string()]); + assert_eq!(input_examples, vec![serde_json::json!({"query":"rust"})]); + } + + #[test] + fn test_tool_search_parse_schema_metadata_infers_builtin_input_examples() { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "query": {"type":"string"} + }, + "required": ["query"] + }); + let (_, _, _, _, input_examples) = + ToolSearchBridgeTool::parse_schema_metadata("WebSearch", &schema); + assert!(!input_examples.is_empty()); + assert!(input_examples[0].get("query").is_some()); + } + + #[test] + fn test_tool_search_score_match_prefers_exact_name() { + let exact = ToolSearchBridgeTool::score_match( + "web_fetch", + "fetch webpage", + &["web".to_string()], + "web_fetch", + ); + let partial = ToolSearchBridgeTool::score_match( + "fetch_web", + "web fetch helper", + &["web".to_string()], + "web_fetch", + ); + 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!({ + "prompt": " 封面图描述 ", + "size": " " + }); + + let prompt = SocialGenerateCoverImageTool::parse_non_empty_string(¶ms, "prompt", None); + let size = SocialGenerateCoverImageTool::parse_non_empty_string( + ¶ms, + "size", + Some(SOCIAL_IMAGE_DEFAULT_SIZE), + ); + + assert_eq!(prompt, Some("封面图描述".to_string())); + assert_eq!(size, Some(SOCIAL_IMAGE_DEFAULT_SIZE.to_string())); + } + + #[test] + fn test_social_generate_cover_image_extract_first_image_payload() { + let response = serde_json::json!({ + "data": [ + { + "url": "https://example.com/image.png", + "revised_prompt": "优化后的提示词" + } + ] + }); + + let (image_url, image_b64, revised_prompt) = + SocialGenerateCoverImageTool::extract_first_image_payload(&response).unwrap(); + assert_eq!(image_url, Some("https://example.com/image.png".to_string())); + assert_eq!(image_b64, None); + assert_eq!(revised_prompt, Some("优化后的提示词".to_string())); + } + + #[test] + fn test_social_generate_cover_image_extract_first_image_payload_rejects_empty_data() { + let response = serde_json::json!({ "data": [] }); + let result = SocialGenerateCoverImageTool::extract_first_image_payload(&response); + + assert!(result.is_err()); + assert!(result + .err() + .unwrap_or_default() + .contains("图像接口返回 data 为空")); + } + + #[test] + fn test_social_generate_cover_image_normalize_server_host() { + assert_eq!( + SocialGenerateCoverImageTool::normalize_server_host("0.0.0.0"), + "127.0.0.1".to_string() + ); + assert_eq!( + SocialGenerateCoverImageTool::normalize_server_host("::"), + "127.0.0.1".to_string() + ); + assert_eq!( + SocialGenerateCoverImageTool::normalize_server_host(" localhost "), + "localhost".to_string() + ); + } + + #[tokio::test] + async fn test_tool_search_bridge_tool_end_to_end_filters_by_caller_and_deferred() { + let registry = Arc::new(tokio::sync::RwLock::new(aster::tools::ToolRegistry::new())); + { + let mut guard = registry.write().await; + guard.register(Box::new(DummyTool::new( + "docs_search", + "Search docs", + serde_json::json!({ + "type": "object", + "x-lime": { + "deferred_loading": true, + "allowed_callers": ["assistant"], + "tags": ["docs", "search"] + } + }), + ))); + guard.register(Box::new(DummyTool::new( + "admin_secret", + "Admin-only tool", + serde_json::json!({ + "type": "object", + "x-lime": { + "deferred_loading": true, + "allowed_callers": ["code_execution"], + "tags": ["admin"] + } + }), + ))); + guard.register(Box::new(DummyTool::new( + "weather", + "Weather by city", + serde_json::json!({ + "type": "object", + "x-lime": { + "deferred_loading": false, + "tags": ["weather"] + } + }), + ))); + } + + let tool = ToolSearchBridgeTool::new(registry.clone(), None); + let context = ToolContext::new(PathBuf::from(".")); + + let hidden_result = tool + .execute( + serde_json::json!({ + "query": "search", + "caller": "assistant", + "include_deferred": false, + "include_schema": true + }), + &context, + ) + .await + .expect("tool_search should succeed"); + let hidden_output = hidden_result.output.expect("tool_search output"); + let hidden_json: serde_json::Value = + serde_json::from_str(&hidden_output).expect("parse tool_search output"); + assert_eq!(hidden_json["count"], serde_json::json!(0)); + + let visible_result = tool + .execute( + serde_json::json!({ + "query": "search", + "caller": "assistant", + "include_deferred": true, + "include_schema": true + }), + &context, + ) + .await + .expect("tool_search should succeed"); + let visible_output = visible_result.output.expect("tool_search output"); + let visible_json: serde_json::Value = + serde_json::from_str(&visible_output).expect("parse tool_search output"); + let tools = visible_json["tools"] + .as_array() + .expect("tools should be array"); + + assert_eq!(visible_json["count"], serde_json::json!(1)); + assert_eq!(tools[0]["name"], serde_json::json!("docs_search")); + assert_eq!(tools[0]["deferred_loading"], serde_json::json!(true)); + assert!(tools[0].get("input_schema").is_some()); + assert!(tools[0] + .get("input_examples") + .and_then(|v| v.as_array()) + .is_some()); + assert!(tools.iter().all(|tool| tool["name"] != "admin_secret")); + } +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs new file mode 100644 index 000000000..360205872 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime.rs @@ -0,0 +1,226 @@ +use super::*; + +#[path = "tool_runtime/browser_tools.rs"] +mod browser_tools; +#[path = "tool_runtime/creation_tools.rs"] +mod creation_tools; +#[path = "tool_runtime/search_bridge.rs"] +mod search_bridge; +#[path = "tool_runtime/social_tools.rs"] +pub(crate) mod social_tools; +#[path = "tool_runtime/subagent_tools.rs"] +mod subagent_tools; +#[path = "tool_runtime/workspace_tools.rs"] +mod workspace_tools; + +pub(crate) use browser_tools::ensure_browser_mcp_tools_registered; +#[allow(unused_imports)] +pub(crate) use browser_tools::LimeBrowserMcpTool; +pub(crate) use creation_tools::ensure_creation_task_tools_registered; +pub(crate) use search_bridge::ensure_tool_search_tool_registered; +#[allow(unused_imports)] +pub(crate) use search_bridge::ToolSearchBridgeTool; +pub(crate) use social_tools::ensure_social_image_tool_registered; +pub(crate) use social_tools::social_generate_cover_image_cmd; +#[allow(unused_imports)] +pub(crate) use social_tools::SocialGenerateCoverImageTool; +#[allow(unused_imports)] +pub(crate) use subagent_tools::{ + build_subagent_task_definition, build_subagent_task_runtime_message, + collect_subagent_task_compat_warnings, extract_runtime_subagent_result_text, + parse_subagent_role, SubAgentTaskToolInput, +}; +use workspace_tools::WorkspaceSandboxedBashTool; +#[cfg(test)] +pub(crate) use workspace_tools::{ + encode_tool_result_for_harness_observability, normalize_params_for_durable_memory_support, + normalize_shell_command_params, normalize_workspace_tool_permission_behavior, +}; + +async fn resolve_agent_registry( + state: &AsterAgentState, +) -> Result< + ( + Arc>, + Option>, + ), + String, +> { + let agent_arc = state.get_agent_arc(); + let guard = agent_arc.read().await; + let agent = guard + .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); + Ok((registry_arc, Some(extension_manager))) +} + +fn unregister_named_tools(registry: &mut aster::tools::ToolRegistry, tool_names: &[&str]) { + for tool_name in 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 { + browser_tools::register_browser_mcp_tools_to_registry(registry); + } else { + browser_tools::unregister_browser_mcp_tools_from_registry(registry); + } + + if surface.creator { + social_tools::register_social_image_tool_to_registry(registry, config_manager); + creation_tools::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 权限模板 +pub(crate) async fn apply_workspace_sandbox_permissions( + state: &AsterAgentState, + config_manager: &GlobalConfigManagerState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + 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(); + if workspace_root.is_empty() { + return Err("workspace 根目录为空".to_string()); + } + + 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, + 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); + WorkspaceSandboxApplyOutcome::Applied { sandbox_type } + } + Err(reason) => { + if sandbox_policy.strict { + return Err(format!( + "workspace 本地 sandbox 严格模式已启用,初始化失败: {reason}" + )); + } + WorkspaceSandboxApplyOutcome::UnavailableFallback { + warning_message: build_workspace_sandbox_warning_message(&reason), + notify_user: sandbox_policy.notify_on_fallback, + } + } + } + }; + + let mut permissions = + build_workspace_execution_permissions(WorkspaceExecutionPermissionInput { + surface: tool_surface, + workspace_root, + auto_mode, + execution_policy_input, + }); + + if tool_surface.browser_assist { + for tool_name in browser_tools::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); + + let (registry_arc, _) = resolve_agent_registry(state).await?; + let mut registry = registry_arc.write().await; + let mut permission_manager = ToolPermissionManager::new(None); + for permission in permissions { + permission_manager.add_permission(permission, PermissionScope::Session); + } + registry.set_permission_manager(Arc::new(permission_manager)); + + let task_manager = shared_task_manager(); + workspace_tools::register_workspace_runtime_tools( + &mut registry, + task_manager, + should_auto_approve_tool_warnings("Task", auto_mode, execution_policy_input), + sandboxed_bash_tool, + ); + + let subagent_runtime = SubagentControlRuntime::new( + app_handle.clone(), + state, + db, + api_key_provider_service, + logs, + config_manager, + mcp_manager, + automation_state, + ); + subagent_tools::register_subagent_runtime_tools(&mut registry, subagent_runtime); + + 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(), + ); + workspace_tools::wrap_registry_native_tools_for_workspace_runtime(&mut registry); + + Ok(apply_outcome) +} + +/// 图片输入 +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageInput { + pub data: String, + pub media_type: String, +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs new file mode 100644 index 000000000..88a403e1c --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/browser_tools.rs @@ -0,0 +1,268 @@ +use super::*; + +#[derive(Debug, Clone)] +pub(crate) struct LimeBrowserMcpTool { + tool_name: String, + action_name: String, + description: String, + input_schema: serde_json::Value, +} + +impl LimeBrowserMcpTool { + fn new( + tool_name: String, + action_name: String, + description: String, + input_schema: serde_json::Value, + ) -> Self { + Self { + tool_name, + action_name, + description, + input_schema, + } + } + + fn parse_backend(params: &serde_json::Value) -> Option { + let raw = params.get("backend")?.as_str()?.trim().to_ascii_lowercase(); + parse_browser_backend_hint(&raw) + } + + fn supports_cdp_direct_action(action_name: &str) -> bool { + matches!( + action_name.trim().to_ascii_lowercase().as_str(), + "tabs_context_mcp" + | "tabs_create_mcp" + | "navigate" + | "click" + | "type" + | "form_input" + | "scroll" + | "scroll_page" + | "refresh_page" + | "go_back" + | "go_forward" + | "get_page_info" + | "read_page" + | "get_page_text" + | "read_console_messages" + | "read_network_requests" + ) + } + + pub(crate) fn resolve_backend( + action_name: &str, + params: &serde_json::Value, + session_hint: Option<&BrowserAssistRuntimeHint>, + ) -> Option { + if let Some(explicit_backend) = Self::parse_backend(params) { + return Some(explicit_backend); + } + + match session_hint.and_then(|hint| hint.preferred_backend.clone()) { + Some(BrowserBackendType::CdpDirect) + if !Self::supports_cdp_direct_action(action_name) => + { + None + } + other => other, + } + } + + fn extract_profile_key(params: &serde_json::Value, context: &ToolContext) -> Option { + if let Some(value) = params.get("profile_key").and_then(|v| v.as_str()) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + context + .environment + .get(BROWSER_PROFILE_KEY_ENV_KEYS[0]) + .cloned() + .or_else(|| { + context + .environment + .get(BROWSER_PROFILE_KEY_ENV_KEYS[1]) + .cloned() + }) + } + + fn extract_launch_url(action_name: &str, params: &serde_json::Value) -> Option { + let normalized = action_name.trim().to_ascii_lowercase(); + if normalized == "navigate" + || normalized.ends_with("navigate") + || normalized == "tabs_create_mcp" + || normalized.ends_with("tabs_create_mcp") + || normalized == "open_url" + || normalized.ends_with("open_url") + { + return params + .get("url") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + } + None + } +} + +#[async_trait] +impl Tool for LimeBrowserMcpTool { + fn name(&self) -> &str { + &self.tool_name + } + + fn description(&self) -> &str { + &self.description + } + + fn input_schema(&self) -> serde_json::Value { + self.input_schema.clone() + } + + fn options(&self) -> ToolOptions { + ToolOptions::new() + .with_max_retries(1) + .with_base_timeout(Duration::from_secs(90)) + .with_dynamic_timeout(false) + } + + async fn execute( + &self, + params: serde_json::Value, + _context: &ToolContext, + ) -> Result { + let session_hint = get_browser_assist_runtime_hint(&_context.session_id).await; + let backend = Self::resolve_backend(&self.action_name, ¶ms, session_hint.as_ref()); + let profile_key = Self::extract_profile_key(¶ms, _context) + .or_else(|| session_hint.as_ref().map(|hint| hint.profile_key.clone())); + if let (Some(hint), Some(profile_key)) = (session_hint.as_ref(), profile_key.as_ref()) { + if hint.auto_launch { + let launch_url = Self::extract_launch_url(&self.action_name, ¶ms) + .or_else(|| hint.launch_url.clone()); + ensure_managed_chrome_profile_global(profile_key.clone(), launch_url) + .await + .map_err(|error| { + ToolError::execution_failed(format!("自动启动浏览器协助会话失败: {error}")) + })?; + } + } + let timeout_ms = params.get("timeout_ms").and_then(|v| v.as_u64()); + let request = BrowserActionRequest { + profile_key, + backend, + action: self.action_name.clone(), + args: params, + timeout_ms, + }; + + let result = browser_execute_action_global(request) + .await + .map_err(|e| ToolError::execution_failed(format!("浏览器动作执行失败: {e}")))?; + + let payload = serde_json::to_string_pretty(&result) + .unwrap_or_else(|_| format!("{{\"success\": {}}}", result.success)); + let browser_session_metadata = if result.session_id.is_some() { + result + .data + .as_ref() + .and_then(|value| value.get("browser_session")) + .cloned() + .or_else(|| { + Some(serde_json::json!({ + "session_id": result.session_id.clone(), + "target_id": result.target_id.clone(), + })) + }) + } else { + None + }; + + if result.success { + let mut tool_result = ToolResult::success(payload) + .with_metadata("tool_family", serde_json::json!("browser")) + .with_metadata("action", serde_json::json!(self.action_name)) + .with_metadata( + "selected_backend", + serde_json::json!(result.backend.clone()), + ) + .with_metadata("attempt_count", serde_json::json!(result.attempts.len())) + .with_metadata("attempts", serde_json::json!(result.attempts.clone())) + .with_metadata("result", serde_json::json!(result.clone())); + if let Some(browser_session) = browser_session_metadata { + tool_result = tool_result.with_metadata("browser_session", browser_session); + } + Ok(tool_result) + } else { + let mut tool_result = ToolResult::error( + result + .error + .clone() + .unwrap_or_else(|| "浏览器动作执行失败".to_string()), + ) + .with_metadata("tool_family", serde_json::json!("browser")) + .with_metadata("action", serde_json::json!(self.action_name)) + .with_metadata( + "selected_backend", + serde_json::json!(result.backend.clone()), + ) + .with_metadata("attempts", serde_json::json!(result.attempts.clone())) + .with_metadata("result", serde_json::json!(result.clone())); + if let Some(browser_session) = browser_session_metadata { + tool_result = tool_result.with_metadata("browser_session", browser_session); + } + Ok(tool_result) + } + } +} + +pub(super) fn browser_mcp_tool_names() -> Vec { + let mut names = Vec::new(); + for tool in get_chrome_mcp_tools() { + names.push(format!("{}{}", browser_runtime_tool_prefix(), tool.name)); + } + names +} + +pub(super) fn register_browser_mcp_tools_to_registry(registry: &mut aster::tools::ToolRegistry) { + let tool_defs = get_chrome_mcp_tools(); + for tool_def in tool_defs { + for prefix in ["mcp__lime-browser__"] { + let full_name = format!("{prefix}{}", tool_def.name); + if registry.contains(&full_name) { + continue; + } + let tool = LimeBrowserMcpTool::new( + full_name, + tool_def.name.clone(), + tool_def.description.clone(), + tool_def.input_schema.clone(), + ); + registry.register(Box::new(tool)); + } + } +} + +pub(super) fn unregister_browser_mcp_tools_from_registry( + registry: &mut aster::tools::ToolRegistry, +) { + for tool_name in browser_mcp_tool_names() { + registry.unregister(&tool_name); + } +} + +pub(crate) async fn ensure_browser_mcp_tools_registered( + state: &AsterAgentState, +) -> Result<(), String> { + let (registry_arc, extension_manager) = resolve_agent_registry(state).await?; + let mut registry = registry_arc.write().await; + register_browser_mcp_tools_to_registry(&mut registry); + search_bridge::register_tool_search_tool_to_registry( + &mut registry, + registry_arc.clone(), + extension_manager, + ); + Ok(()) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs new file mode 100644 index 000000000..72a407d9c --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs @@ -0,0 +1,784 @@ +use super::*; + +fn is_safe_relative_path(path: &Path) -> bool { + if path.is_absolute() { + return false; + } + !path.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) +} + +fn resolve_output_relative_path( + task_type: &str, + output_path: Option<&str>, +) -> Result { + if let Some(raw) = output_path { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(ToolError::invalid_params( + "outputPath 不能为空字符串".to_string(), + )); + } + let candidate = PathBuf::from(trimmed); + if !is_safe_relative_path(&candidate) { + return Err(ToolError::invalid_params( + "outputPath 必须是安全的相对路径,且不能包含 '..'".to_string(), + )); + } + return Ok(candidate); + } + + let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string(); + let suffix = uuid::Uuid::new_v4().simple().to_string(); + Ok(PathBuf::from(".lime") + .join("tasks") + .join(task_type) + .join(format!("{timestamp}-{suffix}.json"))) +} + +fn submit_creation_task_record( + app_handle: &AppHandle, + context: &ToolContext, + task_type: &str, + title: Option, + payload: serde_json::Value, + output_path: Option<&str>, +) -> Result { + let output_rel_path = resolve_output_relative_path(task_type, output_path)?; + let output_abs_path = context.working_directory.join(&output_rel_path); + + let parent = output_abs_path + .parent() + .ok_or_else(|| ToolError::execution_failed("无法解析任务文件父目录".to_string()))?; + std::fs::create_dir_all(parent) + .map_err(|error| ToolError::execution_failed(format!("创建任务目录失败: {error}")))?; + + let task_id = uuid::Uuid::new_v4().to_string(); + let task_record = serde_json::json!({ + "task_id": task_id, + "task_type": task_type, + "title": title, + "payload": payload, + "status": "pending_submit", + "created_at": chrono::Utc::now().to_rfc3339() + }); + let task_content = + serde_json::to_string_pretty(&task_record).unwrap_or_else(|_| task_record.to_string()); + + std::fs::write(&output_abs_path, task_content.as_bytes()) + .map_err(|error| ToolError::execution_failed(format!("写入任务文件失败: {error}")))?; + + let emitted_payload = serde_json::json!({ + "task_id": task_id, + "task_type": task_type, + "path": output_rel_path.to_string_lossy().to_string(), + "absolute_path": output_abs_path.to_string_lossy().to_string() + }); + if let Err(error) = app_handle.emit("lime://creation_task_submitted", &emitted_payload) { + tracing::warn!( + "[AsterAgent] creation_task_submitted 事件发送失败: {}", + error + ); + } + + let output_payload = serde_json::json!({ + "success": true, + "task_id": task_id, + "task_type": task_type, + "path": output_rel_path.to_string_lossy().to_string(), + "absolute_path": output_abs_path.to_string_lossy().to_string(), + "record": task_record + }); + let output = serde_json::to_string_pretty(&output_payload) + .unwrap_or_else(|_| output_payload.to_string()); + Ok(ToolResult::success(output) + .with_metadata("task_id", serde_json::json!(task_id)) + .with_metadata("task_type", serde_json::json!(task_type)) + .with_metadata("path", serde_json::json!(output_abs_path.to_string_lossy()))) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BroadcastTaskInput { + content: String, + #[serde(default)] + title: Option, + #[serde(default)] + audience: Option, + #[serde(default)] + tone: Option, + #[serde(default)] + duration_hint_minutes: Option, + #[serde(default)] + output_path: Option, +} + +#[derive(Clone)] +struct LimeCreateBroadcastTaskTool { + app_handle: AppHandle, +} + +impl LimeCreateBroadcastTaskTool { + fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +#[async_trait] +impl Tool for LimeCreateBroadcastTaskTool { + fn name(&self) -> &str { + LIME_CREATE_BROADCAST_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "创建播客内容整理任务(broadcast_generate)。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "content": { "type": "string", "description": "可播报正文内容。" }, + "title": { "type": "string", "description": "任务标题(可选)。" }, + "audience": { "type": "string", "description": "目标听众(可选)。" }, + "tone": { "type": "string", "description": "语气风格(可选)。" }, + "durationHintMinutes": { "type": "integer", "minimum": 1, "maximum": 180, "description": "建议时长(分钟,可选)。" }, + "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } + }, + "required": ["content"], + "additionalProperties": false, + "x-lime": { + "always_visible": true, + "tags": ["broadcast", "task", "creation"], + "allowed_callers": ["assistant", "skill"] + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: BroadcastTaskInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if input.content.trim().is_empty() { + return Err(ToolError::invalid_params( + "content 不能为空字符串".to_string(), + )); + } + let payload = serde_json::json!({ + "content": input.content, + "audience": input.audience, + "tone": input.tone, + "durationHintMinutes": input.duration_hint_minutes + }); + submit_creation_task_record( + &self.app_handle, + context, + "broadcast_generate", + input.title, + payload, + input.output_path.as_deref(), + ) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoverTaskInput { + prompt: String, + #[serde(default)] + title: Option, + #[serde(default)] + platform: Option, + #[serde(default)] + size: Option, + #[serde(default)] + image_url: Option, + #[serde(default)] + status: Option, + #[serde(default)] + remark: Option, + #[serde(default)] + output_path: Option, +} + +#[derive(Clone)] +struct LimeCreateCoverTaskTool { + app_handle: AppHandle, +} + +impl LimeCreateCoverTaskTool { + fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +#[async_trait] +impl Tool for LimeCreateCoverTaskTool { + fn name(&self) -> &str { + LIME_CREATE_COVER_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "创建封面生成任务记录(cover_generate)。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "prompt": { "type": "string", "description": "封面提示词。" }, + "title": { "type": "string", "description": "任务标题(可选)。" }, + "platform": { "type": "string", "description": "目标平台(可选)。" }, + "size": { "type": "string", "description": "尺寸(可选)。" }, + "imageUrl": { "type": "string", "description": "生成后的封面 URL(可选)。" }, + "status": { "type": "string", "description": "状态(成功/失败,可选)。" }, + "remark": { "type": "string", "description": "备注(可选)。" }, + "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } + }, + "required": ["prompt"], + "additionalProperties": false, + "x-lime": { + "always_visible": true, + "tags": ["cover", "image", "task"], + "allowed_callers": ["assistant", "skill"] + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: CoverTaskInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if input.prompt.trim().is_empty() { + return Err(ToolError::invalid_params( + "prompt 不能为空字符串".to_string(), + )); + } + let payload = serde_json::json!({ + "prompt": input.prompt, + "platform": input.platform, + "size": input.size, + "imageUrl": input.image_url, + "status": input.status, + "remark": input.remark + }); + submit_creation_task_record( + &self.app_handle, + context, + "cover_generate", + input.title, + payload, + input.output_path.as_deref(), + ) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ResourceSearchTaskInput { + resource_type: String, + query: String, + #[serde(default)] + title: Option, + #[serde(default)] + usage: Option, + #[serde(default)] + count: Option, + #[serde(default)] + filters: Option, + #[serde(default)] + output_path: Option, +} + +#[derive(Clone)] +struct LimeCreateResourceSearchTaskTool { + app_handle: AppHandle, +} + +impl LimeCreateResourceSearchTaskTool { + fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +#[async_trait] +impl Tool for LimeCreateResourceSearchTaskTool { + fn name(&self) -> &str { + LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "创建资源检索任务(modal_resource_search)。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "resourceType": { "type": "string", "description": "资源类型,例如 image/bgm/sfx。" }, + "query": { "type": "string", "description": "检索关键词。" }, + "title": { "type": "string", "description": "任务标题(可选)。" }, + "usage": { "type": "string", "description": "用途说明(可选)。" }, + "count": { "type": "integer", "minimum": 1, "maximum": 50, "description": "候选数量(可选)。" }, + "filters": { "type": "object", "description": "过滤条件(可选)。" }, + "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } + }, + "required": ["resourceType", "query"], + "additionalProperties": false, + "x-lime": { + "always_visible": true, + "tags": ["resource", "search", "task"], + "allowed_callers": ["assistant", "skill"] + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: ResourceSearchTaskInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if input.resource_type.trim().is_empty() || input.query.trim().is_empty() { + return Err(ToolError::invalid_params( + "resourceType/query 不能为空字符串".to_string(), + )); + } + let payload = serde_json::json!({ + "resourceType": input.resource_type, + "query": input.query, + "usage": input.usage, + "count": input.count, + "filters": input.filters + }); + submit_creation_task_record( + &self.app_handle, + context, + "modal_resource_search", + input.title, + payload, + input.output_path.as_deref(), + ) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ImageTaskInput { + prompt: String, + #[serde(default)] + title: Option, + #[serde(default)] + style: Option, + #[serde(default)] + size: Option, + #[serde(default)] + count: Option, + #[serde(default)] + usage: Option, + #[serde(default)] + output_path: Option, +} + +#[derive(Clone)] +struct LimeCreateImageTaskTool { + app_handle: AppHandle, +} + +impl LimeCreateImageTaskTool { + fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +#[async_trait] +impl Tool for LimeCreateImageTaskTool { + fn name(&self) -> &str { + LIME_CREATE_IMAGE_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "创建图片生成任务(image_generate)。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "prompt": { "type": "string", "description": "图像提示词。" }, + "title": { "type": "string", "description": "任务标题(可选)。" }, + "style": { "type": "string", "description": "风格(可选)。" }, + "size": { "type": "string", "description": "尺寸(可选)。" }, + "count": { "type": "integer", "minimum": 1, "maximum": 20, "description": "生成数量(可选)。" }, + "usage": { "type": "string", "description": "用途(可选)。" }, + "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } + }, + "required": ["prompt"], + "additionalProperties": false, + "x-lime": { + "always_visible": true, + "tags": ["image", "task", "generation"], + "allowed_callers": ["assistant", "skill"] + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: ImageTaskInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if input.prompt.trim().is_empty() { + return Err(ToolError::invalid_params( + "prompt 不能为空字符串".to_string(), + )); + } + let payload = serde_json::json!({ + "prompt": input.prompt, + "style": input.style, + "size": input.size, + "count": input.count, + "usage": input.usage + }); + submit_creation_task_record( + &self.app_handle, + context, + "image_generate", + input.title, + payload, + input.output_path.as_deref(), + ) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UrlParseTaskInput { + url: String, + #[serde(default)] + title: Option, + #[serde(default)] + summary: Option, + #[serde(default)] + key_points: Option>, + #[serde(default)] + extract_status: Option, + #[serde(default)] + output_path: Option, +} + +#[derive(Clone)] +struct LimeCreateUrlParseTaskTool { + app_handle: AppHandle, +} + +impl LimeCreateUrlParseTaskTool { + fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +#[async_trait] +impl Tool for LimeCreateUrlParseTaskTool { + fn name(&self) -> &str { + LIME_CREATE_URL_PARSE_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "创建链接解析任务(url_parse)。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "url": { "type": "string", "description": "目标 URL。" }, + "title": { "type": "string", "description": "任务标题(可选)。" }, + "summary": { "type": "string", "description": "摘要(可选)。" }, + "keyPoints": { "type": "array", "items": { "type": "string" }, "description": "关键要点(可选)。" }, + "extractStatus": { "type": "string", "description": "提取状态(可选)。" }, + "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } + }, + "required": ["url"], + "additionalProperties": false, + "x-lime": { + "always_visible": true, + "tags": ["url", "parse", "task"], + "allowed_callers": ["assistant", "skill"] + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: UrlParseTaskInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if input.url.trim().is_empty() { + return Err(ToolError::invalid_params("url 不能为空字符串".to_string())); + } + let payload = serde_json::json!({ + "url": input.url, + "summary": input.summary, + "keyPoints": input.key_points, + "extractStatus": input.extract_status + }); + submit_creation_task_record( + &self.app_handle, + context, + "url_parse", + input.title, + payload, + input.output_path.as_deref(), + ) + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TypesettingTaskInput { + content: String, + #[serde(default)] + title: Option, + #[serde(default)] + target_platform: Option, + #[serde(default)] + rules: Option, + #[serde(default)] + output_path: Option, +} + +#[derive(Clone)] +struct LimeCreateTypesettingTaskTool { + app_handle: AppHandle, +} + +impl LimeCreateTypesettingTaskTool { + fn new(app_handle: AppHandle) -> Self { + Self { app_handle } + } +} + +#[async_trait] +impl Tool for LimeCreateTypesettingTaskTool { + fn name(&self) -> &str { + LIME_CREATE_TYPESETTING_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "创建排版优化任务(typesetting)。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "content": { "type": "string", "description": "待排版内容。" }, + "title": { "type": "string", "description": "任务标题(可选)。" }, + "targetPlatform": { "type": "string", "description": "目标平台(可选)。" }, + "rules": { "type": "object", "description": "排版规则(可选)。" }, + "outputPath": { "type": "string", "description": "可选输出路径(相对工作目录)。" } + }, + "required": ["content"], + "additionalProperties": false, + "x-lime": { + "always_visible": true, + "tags": ["typesetting", "task", "text"], + "allowed_callers": ["assistant", "skill"] + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: TypesettingTaskInput = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if input.content.trim().is_empty() { + return Err(ToolError::invalid_params( + "content 不能为空字符串".to_string(), + )); + } + let payload = serde_json::json!({ + "content": input.content, + "targetPlatform": input.target_platform, + "rules": input.rules + }); + submit_creation_task_record( + &self.app_handle, + context, + "typesetting", + input.title, + payload, + input.output_path.as_deref(), + ) + } +} + +#[derive(Clone)] +struct LimeCreateVideoGenerationTaskTool { + db: DbConnection, + api_key_provider_service: Arc, +} + +impl LimeCreateVideoGenerationTaskTool { + fn new(db: DbConnection, api_key_provider_service: Arc) -> Self { + Self { + db, + api_key_provider_service, + } + } +} + +#[async_trait] +impl Tool for LimeCreateVideoGenerationTaskTool { + fn name(&self) -> &str { + LIME_CREATE_VIDEO_TASK_TOOL_NAME + } + + fn description(&self) -> &str { + "调用 Lime 视频任务服务,创建真实的视频生成任务。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "projectId": { "type": "string", "description": "项目 ID。" }, + "providerId": { "type": "string", "description": "视频服务 Provider ID。" }, + "model": { "type": "string", "description": "模型名。" }, + "prompt": { "type": "string", "description": "视频生成提示词。" }, + "aspectRatio": { "type": "string", "description": "画幅比例,例如 16:9、9:16。" }, + "resolution": { "type": "string", "description": "分辨率,例如 720p。" }, + "duration": { "type": "integer", "description": "时长(秒)。" }, + "imageUrl": { "type": "string", "description": "首帧图 URL(可选)。" }, + "endImageUrl": { "type": "string", "description": "末帧图 URL(可选)。" }, + "seed": { "type": "integer", "description": "随机种子(可选)。" }, + "generateAudio": { "type": "boolean", "description": "是否生成音频(可选)。" }, + "cameraFixed": { "type": "boolean", "description": "是否固定镜头(可选)。" } + }, + "required": ["projectId", "providerId", "model", "prompt"], + "additionalProperties": false, + "x-lime": { + "always_visible": true, + "tags": ["video", "task", "generation"], + "allowed_callers": ["assistant", "skill"], + "input_examples": [ + { + "projectId": "project-demo", + "providerId": "volcengine", + "model": "doubao-seedance-1-0-pro-250528", + "prompt": "未来城市清晨,镜头缓慢推进,电影感", + "aspectRatio": "16:9", + "duration": 5 + } + ] + } + }) + } + + async fn execute( + &self, + params: serde_json::Value, + _context: &ToolContext, + ) -> Result { + let request: CreateVideoGenerationRequest = serde_json::from_value(params) + .map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?; + if request.project_id.trim().is_empty() + || request.provider_id.trim().is_empty() + || request.model.trim().is_empty() + || request.prompt.trim().is_empty() + { + return Err(ToolError::invalid_params( + "projectId/providerId/model/prompt 均不能为空".to_string(), + )); + } + + let service = VideoGenerationService::new(); + let created = service + .create_task(&self.db, self.api_key_provider_service.as_ref(), request) + .await + .map_err(|error| ToolError::execution_failed(format!("创建视频任务失败: {error}")))?; + + let payload = serde_json::json!({ + "success": true, + "task": created + }); + let output = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string()); + Ok(ToolResult::success(output)) + } +} + +pub(super) fn register_creation_task_tools_to_registry( + registry: &mut aster::tools::ToolRegistry, + db: DbConnection, + api_key_provider_service: Arc, + app_handle: AppHandle, +) { + if !registry.contains(LIME_CREATE_VIDEO_TASK_TOOL_NAME) { + registry.register(Box::new(LimeCreateVideoGenerationTaskTool::new( + db.clone(), + api_key_provider_service.clone(), + ))); + } + if !registry.contains(LIME_CREATE_BROADCAST_TASK_TOOL_NAME) { + registry.register(Box::new(LimeCreateBroadcastTaskTool::new( + app_handle.clone(), + ))); + } + if !registry.contains(LIME_CREATE_COVER_TASK_TOOL_NAME) { + registry.register(Box::new(LimeCreateCoverTaskTool::new(app_handle.clone()))); + } + if !registry.contains(LIME_CREATE_RESOURCE_SEARCH_TASK_TOOL_NAME) { + registry.register(Box::new(LimeCreateResourceSearchTaskTool::new( + app_handle.clone(), + ))); + } + if !registry.contains(LIME_CREATE_IMAGE_TASK_TOOL_NAME) { + registry.register(Box::new(LimeCreateImageTaskTool::new(app_handle.clone()))); + } + if !registry.contains(LIME_CREATE_URL_PARSE_TASK_TOOL_NAME) { + registry.register(Box::new(LimeCreateUrlParseTaskTool::new( + app_handle.clone(), + ))); + } + if !registry.contains(LIME_CREATE_TYPESETTING_TASK_TOOL_NAME) { + registry.register(Box::new(LimeCreateTypesettingTaskTool::new(app_handle))); + } +} + +pub(crate) async fn ensure_creation_task_tools_registered( + state: &AsterAgentState, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + app_handle: &AppHandle, +) -> Result<(), String> { + let (registry_arc, _) = resolve_agent_registry(state).await?; + let mut registry = registry_arc.write().await; + register_creation_task_tools_to_registry( + &mut registry, + db.clone(), + api_key_provider_service.0.clone(), + app_handle.clone(), + ); + Ok(()) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs new file mode 100644 index 000000000..08ffe20c1 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/search_bridge.rs @@ -0,0 +1,333 @@ +use super::*; + +pub(crate) struct ToolSearchBridgeTool { + registry: Arc>, + extension_manager: Option>, +} + +impl ToolSearchBridgeTool { + pub(crate) fn new( + registry: Arc>, + extension_manager: Option>, + ) -> Self { + Self { + registry, + extension_manager, + } + } + + fn with_input_examples_in_schema( + schema: &serde_json::Value, + input_examples: &[serde_json::Value], + ) -> serde_json::Value { + if input_examples.is_empty() { + return schema.clone(); + } + + let mut enriched = schema.clone(); + let Some(root) = enriched.as_object_mut() else { + return schema.clone(); + }; + let extension = root + .entry("x-lime".to_string()) + .or_insert_with(|| serde_json::json!({})); + let Some(extension_obj) = extension.as_object_mut() else { + return schema.clone(); + }; + if extension_obj.get("input_examples").is_none() + && extension_obj.get("inputExamples").is_none() + { + extension_obj.insert( + "input_examples".to_string(), + serde_json::Value::Array(input_examples.to_vec()), + ); + } + enriched + } + + #[cfg(test)] + pub(crate) fn parse_schema_metadata( + tool_name: &str, + schema: &serde_json::Value, + ) -> ( + bool, // deferred_loading + bool, // always_visible + Vec, // allowed_callers + Vec, // tags + Vec, // input_examples + ) { + let metadata = lime_core::tool_calling::extract_tool_surface_metadata(tool_name, schema); + + ( + 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, + ) + } + + pub(crate) fn score_match(name: &str, description: &str, tags: &[String], query: &str) -> i32 { + lime_core::tool_calling::score_tool_match(name, description, tags, query) + } + + pub(crate) 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_NAME + } + + fn description(&self) -> &str { + "统一搜索当前会话工具面:包含原生 registry 工具与 extension/MCP 工具。对 deferred 工具会返回加载提示。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "工具名称/描述关键词" }, + "caller": { "type": "string", "description": "调用方,例如 assistant/code_execution" }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100 }, + "include_deferred": { "type": "boolean", "description": "是否包含延迟加载工具" }, + "include_schema": { "type": "boolean", "description": "是否返回完整输入 schema" } + }, + "required": [] + }) + } + + fn options(&self) -> ToolOptions { + ToolOptions::new() + .with_max_retries(1) + .with_base_timeout(Duration::from_secs(15)) + .with_dynamic_timeout(false) + } + + async fn execute( + &self, + params: serde_json::Value, + _context: &ToolContext, + ) -> Result { + let query = params + .get("query") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + let caller = params + .get("caller") + .and_then(|v| v.as_str()) + .unwrap_or("assistant") + .trim() + .to_ascii_lowercase(); + let include_deferred = params + .get("include_deferred") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let include_schema = params + .get("include_schema") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .map(|v| v.clamp(1, 100) as usize) + .unwrap_or(10); + + let registry = self.registry.read().await; + let definitions = registry.get_definitions(); + + let mut scored = definitions + .into_iter() + .filter(|d| d.name != self.name()) + .filter_map(|definition| { + 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 !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 { + return None; + } + + let item = if include_schema { + let enriched_schema = Self::with_input_examples_in_schema( + &definition.input_schema, + &input_examples, + ); + serde_json::json!({ + "source": "native_registry", + "name": definition.name, + "description": definition.description, + "input_schema": enriched_schema, + "deferred_loading": deferred_loading, + "always_visible": always_visible, + "allowed_callers": allowed_callers, + "input_examples": input_examples, + "tags": tags + }) + } else { + serde_json::json!({ + "source": "native_registry", + "name": definition.name, + "description": definition.description, + "deferred_loading": deferred_loading, + "always_visible": always_visible, + "allowed_callers": allowed_callers, + "input_examples": input_examples, + "tags": tags + }) + }; + Some((score, item)) + }) + .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"] + .as_str() + .unwrap_or_default() + .cmp(b_item["name"].as_str().unwrap_or_default()) + }) + }); + + let result = scored + .into_iter() + .take(limit) + .map(|(_, item)| item) + .collect::>(); + let text = serde_json::to_string_pretty(&serde_json::json!({ + "query": query, + "caller": caller, + "count": result.len(), + "tools": result + })) + .map_err(|e| ToolError::execution_failed(format!("tool_search 序列化失败: {e}")))?; + + Ok(ToolResult::success(text)) + } +} + +pub(super) fn register_tool_search_tool_to_registry( + registry: &mut aster::tools::ToolRegistry, + registry_arc: Arc>, + extension_manager: Option>, +) { + if registry.contains(TOOL_SEARCH_TOOL_NAME) { + return; + } + registry.register(Box::new(ToolSearchBridgeTool::new( + registry_arc, + extension_manager, + ))); +} + +pub(crate) async fn ensure_tool_search_tool_registered( + state: &AsterAgentState, +) -> Result<(), String> { + let (registry_arc, extension_manager) = resolve_agent_registry(state).await?; + let mut registry = registry_arc.write().await; + register_tool_search_tool_to_registry(&mut registry, registry_arc.clone(), extension_manager); + Ok(()) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/social_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/social_tools.rs new file mode 100644 index 000000000..358b32251 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/social_tools.rs @@ -0,0 +1,330 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct SocialGenerateCoverImageTool { + config_manager: Arc, + client: reqwest::Client, +} + +impl SocialGenerateCoverImageTool { + fn new(config_manager: Arc) -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(180)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { + config_manager, + client, + } + } + + pub(crate) fn normalize_server_host(host: &str) -> String { + let trimmed = host.trim(); + if trimmed.is_empty() || trimmed == "0.0.0.0" || trimmed == "::" { + return "127.0.0.1".to_string(); + } + if trimmed.starts_with('[') && trimmed.ends_with(']') { + return trimmed.to_string(); + } + if trimmed.contains(':') { + return format!("[{trimmed}]"); + } + trimmed.to_string() + } + + pub(crate) fn parse_non_empty_string( + params: &serde_json::Value, + key: &str, + default: Option<&str>, + ) -> Option { + if let Some(value) = params.get(key).and_then(|v| v.as_str()) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + default.map(ToString::to_string) + } + + pub(crate) fn extract_first_image_payload( + response_body: &serde_json::Value, + ) -> Result<(Option, Option, Option), String> { + let data = response_body + .get("data") + .and_then(|v| v.as_array()) + .ok_or_else(|| "图像接口返回缺少 data 字段".to_string())?; + + let first = data + .first() + .ok_or_else(|| "图像接口返回 data 为空".to_string())?; + + let image_url = first + .get("url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let image_b64 = first + .get("b64_json") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let revised_prompt = first + .get("revised_prompt") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Ok((image_url, image_b64, revised_prompt)) + } +} + +#[async_trait] +impl Tool for SocialGenerateCoverImageTool { + fn name(&self) -> &str { + SOCIAL_IMAGE_TOOL_NAME + } + + fn description(&self) -> &str { + "为社媒文章生成封面图,内部复用 Lime 的 /v1/images/generations 能力。" + } + + fn input_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "图片描述词,建议包含主体、风格、氛围、构图。" + }, + "model": { + "type": "string", + "description": "可选模型名;不传则使用默认图像模型。" + }, + "size": { + "type": "string", + "description": "图片尺寸,例如 1024x1024、1024x1792。" + }, + "response_format": { + "type": "string", + "enum": ["url", "b64_json"], + "description": "返回格式,默认 url。" + } + }, + "required": ["prompt"], + "additionalProperties": false, + "x-lime": { + "always_visible": true, + "tags": ["image", "social-media", "cover"], + "allowed_callers": ["assistant", "skill"], + "input_examples": [ + { + "prompt": "科技感蓝紫渐变背景,一位年轻创作者在笔记本前沉思,暖色轮廓光,简洁社媒封面风格", + "size": "1024x1024" + } + ] + } + }) + } + + fn options(&self) -> ToolOptions { + ToolOptions::new() + .with_max_retries(1) + .with_base_timeout(Duration::from_secs(180)) + .with_dynamic_timeout(false) + } + + async fn execute( + &self, + params: serde_json::Value, + _context: &ToolContext, + ) -> Result { + let prompt = Self::parse_non_empty_string(¶ms, "prompt", None).ok_or_else(|| { + ToolError::invalid_params("参数 prompt 必填,且不能为空字符串".to_string()) + })?; + + let runtime_config = self.config_manager.config(); + let model = + Self::parse_non_empty_string(¶ms, "model", Some(SOCIAL_IMAGE_DEFAULT_MODEL)) + .unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_MODEL.to_string()); + let size = Self::parse_non_empty_string( + ¶ms, + "size", + runtime_config.image_gen.default_size.as_deref(), + ) + .unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_SIZE.to_string()); + let response_format = Self::parse_non_empty_string( + ¶ms, + "response_format", + Some(SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT), + ) + .unwrap_or_else(|| SOCIAL_IMAGE_DEFAULT_RESPONSE_FORMAT.to_string()); + + if response_format != "url" && response_format != "b64_json" { + return Err(ToolError::invalid_params( + "response_format 仅支持 url 或 b64_json".to_string(), + )); + } + + let server_host = Self::normalize_server_host(&runtime_config.server.host); + let endpoint = format!( + "http://{}:{}/v1/images/generations", + server_host, runtime_config.server.port + ); + let request_body = serde_json::json!({ + "prompt": prompt, + "model": model, + "n": 1, + "size": size, + "response_format": response_format + }); + + let response = self + .client + .post(&endpoint) + .header( + "Authorization", + format!("Bearer {}", runtime_config.server.api_key), + ) + .json(&request_body) + .send() + .await + .map_err(|e| ToolError::execution_failed(format!("调用图像接口失败: {e}")))?; + + let status = response.status(); + let response_body: serde_json::Value = response + .json() + .await + .map_err(|e| ToolError::execution_failed(format!("图像接口响应解析失败: {e}")))?; + + if !status.is_success() { + let error_message = response_body + .get("error") + .and_then(|v| v.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("图像生成失败") + .to_string(); + let error_code = response_body + .get("error") + .and_then(|v| v.get("code")) + .and_then(|v| v.as_str()) + .unwrap_or("image_generation_failed") + .to_string(); + let result_payload = serde_json::json!({ + "success": false, + "error_code": error_code, + "error_message": error_message, + "status": status.as_u16(), + "retryable": status.is_server_error() || status.as_u16() == 429 + }); + return Ok(ToolResult::error(result_payload.to_string()) + .with_metadata("result", result_payload)); + } + + let (image_url, image_b64, revised_prompt) = + Self::extract_first_image_payload(&response_body) + .map_err(ToolError::execution_failed)?; + + if image_url.is_none() && image_b64.is_none() { + return Err(ToolError::execution_failed( + "图像接口返回中未找到 url 或 b64_json".to_string(), + )); + } + + let result_payload = serde_json::json!({ + "success": true, + "image_url": image_url, + "b64_json": image_b64, + "revised_prompt": revised_prompt, + "model": request_body.get("model").cloned(), + "size": request_body.get("size").cloned(), + "response_format": request_body.get("response_format").cloned() + }); + let output = serde_json::to_string_pretty(&result_payload) + .unwrap_or_else(|_| result_payload.to_string()); + Ok(ToolResult::success(output).with_metadata("result", result_payload)) + } +} + +pub(super) fn register_social_image_tool_to_registry( + registry: &mut aster::tools::ToolRegistry, + config_manager: Arc, +) { + if registry.contains(SOCIAL_IMAGE_TOOL_NAME) { + return; + } + registry.register(Box::new(SocialGenerateCoverImageTool::new(config_manager))); +} + +pub(crate) async fn ensure_social_image_tool_registered( + state: &AsterAgentState, + config_manager: &GlobalConfigManagerState, +) -> Result<(), String> { + let (registry_arc, _) = resolve_agent_registry(state).await?; + let mut registry = registry_arc.write().await; + register_social_image_tool_to_registry(&mut registry, config_manager.0.clone()); + Ok(()) +} + +#[tauri::command] +pub async fn social_generate_cover_image_cmd( + config_manager: State<'_, GlobalConfigManagerState>, + prompt: String, + size: Option, +) -> Result { + if prompt.trim().is_empty() { + return Err("prompt 不能为空".to_string()); + } + let runtime_config = config_manager.config(); + let server_host = + SocialGenerateCoverImageTool::normalize_server_host(&runtime_config.server.host); + let size = size + .as_deref() + .filter(|s| !s.trim().is_empty()) + .or(runtime_config.image_gen.default_size.as_deref()) + .unwrap_or(SOCIAL_IMAGE_DEFAULT_SIZE) + .to_string(); + let endpoint = format!( + "http://{}:{}/v1/images/generations", + server_host, runtime_config.server.port + ); + let request_body = serde_json::json!({ + "prompt": prompt.trim(), + "model": SOCIAL_IMAGE_DEFAULT_MODEL, + "n": 1, + "size": size, + "response_format": "url" + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(180)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + + let response = client + .post(&endpoint) + .header( + "Authorization", + format!("Bearer {}", runtime_config.server.api_key), + ) + .json(&request_body) + .send() + .await + .map_err(|e| format!("调用图像接口失败: {e}"))?; + + let status = response.status(); + let response_body: serde_json::Value = response + .json() + .await + .map_err(|e| format!("图像接口响应解析失败: {e}"))?; + + if !status.is_success() { + let msg = response_body + .get("error") + .and_then(|v| v.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("图像生成失败"); + return Err(msg.to_string()); + } + + let (image_url, _b64, _revised) = + SocialGenerateCoverImageTool::extract_first_image_payload(&response_body)?; + + image_url.ok_or_else(|| "接口返回中未找到 image_url".to_string()) +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs new file mode 100644 index 000000000..19a3207e7 --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/subagent_tools.rs @@ -0,0 +1,973 @@ +use super::*; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SubAgentTaskToolInput { + pub(crate) prompt: String, + pub(crate) task_type: Option, + pub(crate) description: Option, + pub(crate) role: Option, + pub(crate) timeout_secs: Option, + pub(crate) model: Option, + pub(crate) return_summary: Option, + pub(crate) allowed_tools: Option>, + pub(crate) denied_tools: Option>, + pub(crate) max_tokens: Option, +} + +pub(crate) fn parse_subagent_role(raw: Option<&str>) -> Result { + let normalized = raw + .map(|value| value.trim().to_ascii_lowercase()) + .unwrap_or_else(|| "executor".to_string()); + + match normalized.as_str() { + "" | "executor" | "execute" | "code" => Ok(SubAgentRole::Executor), + "planner" | "plan" => Ok(SubAgentRole::Planner), + "explorer" | "explore" | "research" => Ok(SubAgentRole::Explorer), + _ => Err(ToolError::invalid_params(format!( + "未知 SubAgent 角色: {},支持 explorer/planner/executor", + normalized + ))), + } +} + +fn default_subagent_task_type(role: SubAgentRole) -> &'static str { + match role { + SubAgentRole::Explorer => "explore", + SubAgentRole::Planner => "plan", + SubAgentRole::Executor => "code", + } +} + +pub(crate) fn build_subagent_task_definition( + input: &SubAgentTaskToolInput, + role: SubAgentRole, +) -> Result { + let prompt = input.prompt.trim(); + if prompt.is_empty() { + return Err(ToolError::invalid_params( + "SubAgentTask.prompt 不能为空".to_string(), + )); + } + + let task_type = input + .task_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(default_subagent_task_type(role)); + + let mut task = SubAgentTask::new(uuid::Uuid::new_v4().to_string(), task_type, prompt); + + if let Some(description) = input + .description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + task = task.with_description(description.to_string()); + } + + if let Some(timeout_secs) = input.timeout_secs.filter(|value| *value > 0) { + task = task.with_timeout(Duration::from_secs(timeout_secs)); + } + + if let Some(model) = input + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + task = task.with_model(model.to_string()); + } + + if let Some(return_summary) = input.return_summary { + task = task.with_summary(return_summary); + } + + if let Some(allowed_tools) = input + .allowed_tools + .as_ref() + .filter(|items| !items.is_empty()) + { + task = task.with_allowed_tools(allowed_tools.clone()); + } + + if let Some(denied_tools) = input + .denied_tools + .as_ref() + .filter(|items| !items.is_empty()) + { + task = task.with_denied_tools(denied_tools.clone()); + } + + if let Some(max_tokens) = input.max_tokens.filter(|value| *value > 0) { + task = task.with_max_tokens(max_tokens); + } + + Ok(task) +} + +pub(crate) fn build_subagent_task_runtime_message( + input: &SubAgentTaskToolInput, + task: &SubAgentTask, + role: SubAgentRole, +) -> String { + let mut sections = Vec::new(); + + if let Some(description) = input + .description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + 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") +} + +pub(crate) 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")) + } +} + +pub(crate) 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(|| { + 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() + .map(str::trim) + .filter(|value| !value.is_empty()) + .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()); + + 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 { + runtime: SubagentControlRuntime, +} + +impl SubAgentTaskTool { + fn new(runtime: SubagentControlRuntime) -> Self { + Self { runtime } + } +} + +#[async_trait] +impl Tool for SubAgentTaskTool { + fn name(&self) -> &str { + "SubAgentTask" + } + + 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 { + serde_json::json!({ + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "子代理要执行的任务说明" + }, + "taskType": { + "type": "string", + "description": "任务类型,例如 explore、plan、code、review" + }, + "description": { + "type": "string", + "description": "展示给用户的任务标题" + }, + "role": { + "type": "string", + "description": "子代理角色:explorer、planner、executor" + }, + "timeoutSecs": { + "type": "integer", + "minimum": 1, + "description": "单个子任务超时时间(秒)" + }, + "model": { + "type": "string", + "description": "可选模型名" + }, + "returnSummary": { + "type": "boolean", + "description": "是否优先返回摘要" + }, + "allowedTools": { + "type": "array", + "items": { "type": "string" }, + "description": "显式允许的工具列表" + }, + "deniedTools": { + "type": "array", + "items": { "type": "string" }, + "description": "显式拒绝的工具列表" + }, + "maxTokens": { + "type": "integer", + "minimum": 1, + "description": "子代理最大 token 限制" + } + }, + "required": ["prompt"], + "additionalProperties": false + }) + } + + fn options(&self) -> ToolOptions { + ToolOptions::new() + .with_max_retries(0) + .with_base_timeout(Duration::from_secs(900)) + .with_dynamic_timeout(false) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input: SubAgentTaskToolInput = serde_json::from_value(params) + .map_err(|err| ToolError::invalid_params(format!("SubAgentTask 参数无效: {err}")))?; + 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 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 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 = 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(), + "status": status, + "timed_out": wait_result.timed_out, + "compat_mode": "subagent_task->spawn_agent", + "compat_warnings": compat_warnings, + }); + + let success = !wait_result.timed_out && status.kind == SubagentRuntimeStatusKind::Completed; + let result = if success { + ToolResult::success(summary) + } else { + 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(), + )) + } +} + +pub(super) fn register_subagent_runtime_tools( + registry: &mut aster::tools::ToolRegistry, + runtime: SubagentControlRuntime, +) { + registry.register(Box::new(SubAgentTaskTool::new(runtime.clone()))); + registry.register(Box::new(SpawnAgentTool::new(runtime.clone()))); + registry.register(Box::new(SendInputTool::new(runtime.clone()))); + registry.register(Box::new(WaitAgentTool::new(runtime.clone()))); + registry.register(Box::new(ResumeAgentTool::new(runtime.clone()))); + registry.register(Box::new(CloseAgentTool::new(runtime))); +} diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs new file mode 100644 index 000000000..25efe89cc --- /dev/null +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/workspace_tools.rs @@ -0,0 +1,821 @@ +use super::*; + +pub(super) struct WorkspaceSandboxedBashTool { + delegate: BashTool, + sandbox_type_name: String, + base_sandbox_config: ProcessSandboxConfig, + auto_approve_warnings: bool, +} + +impl WorkspaceSandboxedBashTool { + pub(super) fn new(workspace_root: &str, auto_approve_warnings: bool) -> Result { + let workspace_root = workspace_root.trim(); + if workspace_root.is_empty() { + return Err("workspace 根目录为空".to_string()); + } + + let sandbox_type = detect_best_sandbox(); + let sandbox_type_name = format!("{sandbox_type:?}"); + if sandbox_type_name == "None" { + return Err(format!( + "未检测到可用本地 sandbox 执行器。{}", + workspace_sandbox_platform_hint() + )); + } + + let workspace_path = PathBuf::from(workspace_root); + let mut read_only_paths = vec![ + PathBuf::from("/usr"), + PathBuf::from("/bin"), + PathBuf::from("/sbin"), + PathBuf::from("/etc"), + PathBuf::from("/System"), + PathBuf::from("/Library"), + workspace_path.clone(), + ]; + read_only_paths.sort(); + read_only_paths.dedup(); + + let mut writable_paths = vec![workspace_path.clone(), PathBuf::from("/tmp")]; + if cfg!(target_os = "macos") { + writable_paths.push(PathBuf::from("/private/tmp")); + } + writable_paths.sort(); + writable_paths.dedup(); + + let base_sandbox_config = ProcessSandboxConfig { + enabled: true, + sandbox_type, + allowed_paths: vec![workspace_path], + denied_paths: Vec::new(), + network_access: false, + environment_variables: HashMap::new(), + read_only_paths, + writable_paths, + allow_dev_access: false, + allow_proc_access: false, + allow_sys_access: false, + env_whitelist: Vec::new(), + tmpfs_size: "64M".to_string(), + unshare_all: true, + die_with_parent: true, + new_session: true, + docker: None, + custom_args: Vec::new(), + audit_logging: None, + resource_limits: None, + }; + + Ok(Self { + delegate: BashTool::new(), + sandbox_type_name, + base_sandbox_config, + auto_approve_warnings, + }) + } + + pub(super) fn sandbox_type(&self) -> &str { + &self.sandbox_type_name + } + + fn build_sandbox_config( + &self, + context: &ToolContext, + timeout_secs: u64, + ) -> ProcessSandboxConfig { + let mut config = self.base_sandbox_config.clone(); + + let mut environment_variables = HashMap::new(); + environment_variables.insert("ASTER_TERMINAL".to_string(), "1".to_string()); + for (key, value) in &context.environment { + environment_variables.insert(key.clone(), value.clone()); + } + if let Ok(path_env) = std::env::var("PATH") { + environment_variables + .entry("PATH".to_string()) + .or_insert(path_env); + } + + config.environment_variables = environment_variables; + config.resource_limits = Some(ResourceLimits { + max_memory: Some(1024 * 1024 * 1024), + max_cpu: Some(70), + max_processes: Some(32), + max_file_size: Some(50 * 1024 * 1024), + max_execution_time: Some(timeout_secs.saturating_mul(1000)), + max_file_descriptors: Some(256), + }); + config + } + + #[cfg(not(target_os = "windows"))] + fn quote_shell(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) + } + + fn build_shell_command(&self, command: &str, _context: &ToolContext) -> (String, Vec) { + #[cfg(target_os = "windows")] + { + return ( + "powershell".to_string(), + vec![ + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + command.to_string(), + ], + ); + } + + #[cfg(not(target_os = "windows"))] + { + let working_dir = _context.working_directory.to_string_lossy().to_string(); + let wrapped_command = format!("cd {} && {}", Self::quote_shell(&working_dir), command); + ("sh".to_string(), vec!["-lc".to_string(), wrapped_command]) + } + } + + fn format_output(stdout: &str, stderr: &str, exit_code: i32) -> String { + let mut output = String::new(); + + if !stdout.is_empty() { + output.push_str(stdout); + } + + if !stderr.is_empty() { + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + if !stdout.is_empty() { + output.push_str("--- stderr ---\n"); + } + output.push_str(stderr); + } + + if exit_code != 0 && output.is_empty() { + output = format!("Command exited with code {exit_code}"); + } + + if output.len() <= MAX_OUTPUT_LENGTH { + return output; + } + + let bytes = output.as_bytes(); + let truncated = String::from_utf8_lossy(&bytes[..MAX_OUTPUT_LENGTH]).to_string(); + format!( + "{}\n\n[output truncated: {} bytes total]", + truncated, + output.len() + ) + } +} + +pub(crate) fn normalize_shell_command_params(params: &serde_json::Value) -> serde_json::Value { + let mut normalized = params.clone(); + if let Some(object) = normalized.as_object_mut() { + let has_command = object + .get("command") + .and_then(|value| value.as_str()) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + + if !has_command { + if let Some(cmd_value) = object.get("cmd").cloned() { + if cmd_value + .as_str() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + object.insert("command".to_string(), cmd_value); + } + } + } + } + normalized +} + +pub(crate) fn normalize_workspace_tool_permission_behavior( + permission: PermissionCheckResult, + auto_approve_warnings: bool, +) -> PermissionCheckResult { + if permission.behavior != PermissionBehavior::Ask { + return permission; + } + + let warning = permission + .message + .unwrap_or_else(|| "命令包含潜在风险操作".to_string()); + + if auto_approve_warnings { + tracing::warn!("[AsterAgent] Auto 模式自动通过 bash 风险提示: {}", warning); + return PermissionCheckResult { + behavior: PermissionBehavior::Allow, + message: None, + updated_params: permission.updated_params, + }; + } + + PermissionCheckResult { + behavior: PermissionBehavior::Deny, + message: Some(format!( + "{warning}。当前模式不支持交互确认,请切换到 Auto 模式或调整命令。" + )), + updated_params: permission.updated_params, + } +} + +fn append_workspace_bash_summary( + mut output: String, + exit_code: i32, + stdout_length: usize, + stderr_length: usize, + sandboxed: bool, + sandbox_type: &str, +) -> String { + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + + let output_truncated = output.contains("[output truncated:"); + output.push_str("\n[Lime 执行摘要]\n"); + output.push_str(&format!("exit_code: {exit_code}\n")); + output.push_str(&format!("stdout_length: {stdout_length}\n")); + output.push_str(&format!("stderr_length: {stderr_length}\n")); + output.push_str(&format!("sandboxed: {sandboxed}\n")); + output.push_str(&format!("sandbox_type: {sandbox_type}\n")); + output.push_str(&format!("output_truncated: {output_truncated}")); + output +} + +fn output_contains_lime_metadata_block(output: &str) -> bool { + output.contains(LIME_TOOL_METADATA_BEGIN) && output.contains(LIME_TOOL_METADATA_END) +} + +fn append_lime_tool_metadata_block( + mut content: String, + metadata: &serde_json::Map, +) -> String { + if output_contains_lime_metadata_block(&content) { + return content; + } + + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + if !content.is_empty() { + content.push('\n'); + } + + let metadata_json = serde_json::to_string(metadata).unwrap_or_else(|_| "{}".to_string()); + content.push_str(LIME_TOOL_METADATA_BEGIN); + content.push('\n'); + content.push_str(&metadata_json); + content.push('\n'); + content.push_str(LIME_TOOL_METADATA_END); + content +} + +pub(crate) fn encode_tool_result_for_harness_observability(result: ToolResult) -> ToolResult { + let mut metadata = result.metadata.clone(); + let base_content = if result.success { + result.output.unwrap_or_default() + } else { + metadata + .entry("reported_success".to_string()) + .or_insert_with(|| serde_json::json!(false)); + result + .error + .unwrap_or_else(|| "工具执行失败,但未返回错误详情".to_string()) + }; + + if result.success && metadata.is_empty() { + return ToolResult::success(base_content); + } + + let encoded_output = + if metadata.is_empty() || output_contains_lime_metadata_block(&base_content) { + base_content + } else { + let metadata_object = metadata + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + append_lime_tool_metadata_block(base_content, &metadata_object) + }; + + ToolResult::success(encoded_output).with_metadata_map(metadata) +} + +fn remap_virtual_memory_path_param( + params: &mut serde_json::Map, + key: &str, +) -> Result { + let Some(raw_path) = params.get(key).and_then(|value| value.as_str()) else { + return Ok(false); + }; + + let Some(mapped_path) = + resolve_virtual_memory_path(raw_path).map_err(ToolError::invalid_params)? + else { + return Ok(false); + }; + + params.insert( + key.to_string(), + serde_json::Value::String(mapped_path.to_string_lossy().to_string()), + ); + Ok(true) +} + +fn remap_virtual_memory_glob_pattern( + params: &mut serde_json::Map, +) -> Result { + let Some(pattern) = params.get("pattern").and_then(|value| value.as_str()) else { + return Ok(false); + }; + if !is_virtual_memory_path(pattern) { + return Ok(false); + } + + let relative_pattern = virtual_memory_relative_path(pattern).unwrap_or_default(); + if relative_pattern.split('/').any(|segment| segment == "..") { + return Err(ToolError::invalid_params( + "glob.pattern 中的 `/memories/` 路径不允许包含 `..`".to_string(), + )); + } + + let root_path = resolve_virtual_memory_path(DURABLE_MEMORY_VIRTUAL_ROOT) + .map_err(ToolError::invalid_params)? + .ok_or_else(|| ToolError::invalid_params("无法解析 durable memory 根目录".to_string()))?; + + let normalized_pattern = relative_pattern.trim_start_matches('/'); + let normalized_pattern = if normalized_pattern.is_empty() { + "**/*".to_string() + } else { + normalized_pattern.to_string() + }; + + params.insert( + "path".to_string(), + serde_json::Value::String(root_path.to_string_lossy().to_string()), + ); + params.insert( + "pattern".to_string(), + serde_json::Value::String(normalized_pattern), + ); + Ok(true) +} + +pub(crate) fn normalize_params_for_durable_memory_support( + tool_name: &str, + params: &serde_json::Value, +) -> Result { + let Some(map) = params.as_object() else { + return Ok(params.clone()); + }; + + let mut normalized = map.clone(); + let mut changed = false; + + match tool_name { + "read" | "write" | "edit" | "grep" => { + changed |= remap_virtual_memory_path_param(&mut normalized, "path")?; + } + "glob" => { + changed |= remap_virtual_memory_path_param(&mut normalized, "path")?; + changed |= remap_virtual_memory_glob_pattern(&mut normalized)?; + } + _ => {} + } + + if changed { + Ok(serde_json::Value::Object(normalized)) + } else { + Ok(params.clone()) + } +} + +struct DurableMemoryMappedTool { + delegate: Box, +} + +impl DurableMemoryMappedTool { + fn new(delegate: Box) -> Self { + Self { delegate } + } +} + +#[async_trait] +impl Tool for DurableMemoryMappedTool { + fn name(&self) -> &str { + self.delegate.name() + } + + fn description(&self) -> &str { + self.delegate.description() + } + + fn dynamic_description(&self) -> Option { + self.delegate.dynamic_description() + } + + fn input_schema(&self) -> serde_json::Value { + self.delegate.input_schema() + } + + fn options(&self) -> ToolOptions { + self.delegate.options() + } + + async fn check_permissions( + &self, + params: &serde_json::Value, + context: &ToolContext, + ) -> PermissionCheckResult { + let normalized_params = + match normalize_params_for_durable_memory_support(self.name(), params) { + Ok(value) => value, + Err(error) => { + return PermissionCheckResult::deny(format!( + "durable memory 参数无效: {error}" + )); + } + }; + + let mut result = self + .delegate + .check_permissions(&normalized_params, context) + .await; + + if result.updated_params.is_none() && normalized_params != *params { + result.updated_params = Some(normalized_params); + } + result + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let normalized_params = normalize_params_for_durable_memory_support(self.name(), ¶ms)?; + self.delegate.execute(normalized_params, context).await + } +} + +struct HarnessObservedTool { + delegate: Box, +} + +impl HarnessObservedTool { + fn new(delegate: Box) -> Self { + Self { delegate } + } +} + +#[async_trait] +impl Tool for HarnessObservedTool { + fn name(&self) -> &str { + self.delegate.name() + } + + fn description(&self) -> &str { + self.delegate.description() + } + + fn dynamic_description(&self) -> Option { + self.delegate.dynamic_description() + } + + fn input_schema(&self) -> serde_json::Value { + self.delegate.input_schema() + } + + fn options(&self) -> ToolOptions { + self.delegate.options() + } + + async fn check_permissions( + &self, + params: &serde_json::Value, + context: &ToolContext, + ) -> PermissionCheckResult { + self.delegate.check_permissions(params, context).await + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + self.delegate + .execute(params, context) + .await + .map(encode_tool_result_for_harness_observability) + } +} + +fn wrap_registry_native_tools_for_harness_observability(registry: &mut aster::tools::ToolRegistry) { + let tool_names = registry + .native_tool_names() + .into_iter() + .map(|name| name.to_string()) + .collect::>(); + + for tool_name in tool_names { + let Some(tool) = registry.unregister(&tool_name) else { + continue; + }; + registry.register(Box::new(HarnessObservedTool::new(tool))); + } +} + +fn wrap_registry_native_tools_for_durable_memory_fs(registry: &mut aster::tools::ToolRegistry) { + for tool_name in ["read", "write", "edit", "glob", "grep"] { + let Some(tool) = registry.unregister(tool_name) else { + continue; + }; + registry.register(Box::new(DurableMemoryMappedTool::new(tool))); + } +} + +#[async_trait] +impl Tool for WorkspaceSandboxedBashTool { + fn name(&self) -> &str { + self.delegate.name() + } + + fn description(&self) -> &str { + self.delegate.description() + } + + fn input_schema(&self) -> serde_json::Value { + self.delegate.input_schema() + } + + fn options(&self) -> ToolOptions { + self.delegate.options() + } + + async fn check_permissions( + &self, + params: &serde_json::Value, + context: &ToolContext, + ) -> PermissionCheckResult { + let normalized_params = normalize_shell_command_params(params); + let permission = self + .delegate + .check_permissions(&normalized_params, context) + .await; + normalize_workspace_tool_permission_behavior(permission, self.auto_approve_warnings) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let normalized_params = normalize_shell_command_params(¶ms); + + if context.is_cancelled() { + return Err(ToolError::Cancelled); + } + + let permission = self.check_permissions(&normalized_params, context).await; + match permission.behavior { + PermissionBehavior::Allow => {} + PermissionBehavior::Deny => { + let message = permission + .message + .unwrap_or_else(|| "命令被安全策略拒绝".to_string()); + return Err(ToolError::permission_denied(message)); + } + PermissionBehavior::Ask => { + let message = permission + .message + .unwrap_or_else(|| "命令需要人工确认".to_string()); + return Err(ToolError::permission_denied(message)); + } + } + + let command = normalized_params + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::invalid_params("Missing required parameter: command"))?; + + let background = normalized_params + .get("background") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if background { + return Err(ToolError::invalid_params( + "本地 sandbox 模式不支持 background=true", + )); + } + + let timeout_secs = normalized_params + .get("timeout") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_BASH_TIMEOUT_SECS) + .min(MAX_BASH_TIMEOUT_SECS); + + let sandbox_config = self.build_sandbox_config(context, timeout_secs); + let (entry, args) = self.build_shell_command(command, context); + + let execution = tokio::time::timeout( + Duration::from_secs(timeout_secs), + execute_in_sandbox(&entry, &args, &sandbox_config), + ) + .await + .map_err(|_| ToolError::timeout(Duration::from_secs(timeout_secs)))? + .map_err(|e| ToolError::execution_failed(format!("sandbox 执行失败: {e}")))?; + + let output = append_workspace_bash_summary( + Self::format_output(&execution.stdout, &execution.stderr, execution.exit_code), + execution.exit_code, + execution.stdout.len(), + execution.stderr.len(), + execution.sandboxed, + &format!("{:?}", execution.sandbox_type), + ); + if execution.exit_code == 0 { + Ok(ToolResult::success(output) + .with_metadata("exit_code", serde_json::json!(execution.exit_code)) + .with_metadata("stdout_length", serde_json::json!(execution.stdout.len())) + .with_metadata("stderr_length", serde_json::json!(execution.stderr.len())) + .with_metadata("sandboxed", serde_json::json!(execution.sandboxed)) + .with_metadata( + "sandbox_type", + serde_json::json!(format!("{:?}", execution.sandbox_type)), + )) + } else { + Ok(ToolResult::success(output) + .with_metadata("exit_code", serde_json::json!(execution.exit_code)) + .with_metadata("stdout_length", serde_json::json!(execution.stdout.len())) + .with_metadata("stderr_length", serde_json::json!(execution.stderr.len())) + .with_metadata("sandboxed", serde_json::json!(execution.sandboxed)) + .with_metadata( + "sandbox_type", + serde_json::json!(format!("{:?}", execution.sandbox_type)), + ) + .with_metadata("reported_success", serde_json::json!(false))) + } + } +} + +/// 统一处理 Task 工具的 Ask 权限,避免缺少回调导致流程中断 +struct WorkspaceTaskTool { + delegate: TaskTool, + auto_approve_warnings: bool, +} + +impl WorkspaceTaskTool { + fn new(auto_approve_warnings: bool, task_manager: Arc) -> Self { + Self { + delegate: TaskTool::with_manager(task_manager), + auto_approve_warnings, + } + } +} + +#[async_trait] +impl Tool for WorkspaceTaskTool { + fn name(&self) -> &str { + self.delegate.name() + } + + fn description(&self) -> &str { + self.delegate.description() + } + + fn input_schema(&self) -> serde_json::Value { + self.delegate.input_schema() + } + + fn options(&self) -> ToolOptions { + self.delegate.options() + } + + async fn check_permissions( + &self, + params: &serde_json::Value, + context: &ToolContext, + ) -> PermissionCheckResult { + let normalized_params = normalize_shell_command_params(params); + let permission = self + .delegate + .check_permissions(&normalized_params, context) + .await; + normalize_workspace_tool_permission_behavior(permission, self.auto_approve_warnings) + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let normalized_params = normalize_shell_command_params(¶ms); + self.delegate.execute(normalized_params, context).await + } +} + +struct WorkspaceTaskOutputTool { + delegate: TaskOutputTool, + task_manager: Arc, +} + +impl WorkspaceTaskOutputTool { + fn new(task_manager: Arc) -> Self { + Self { + delegate: TaskOutputTool::with_manager(task_manager.clone()), + task_manager, + } + } +} + +#[async_trait] +impl Tool for WorkspaceTaskOutputTool { + fn name(&self) -> &str { + self.delegate.name() + } + + fn description(&self) -> &str { + self.delegate.description() + } + + fn input_schema(&self) -> serde_json::Value { + self.delegate.input_schema() + } + + fn options(&self) -> ToolOptions { + self.delegate.options() + } + + async fn check_permissions( + &self, + params: &serde_json::Value, + context: &ToolContext, + ) -> PermissionCheckResult { + self.delegate.check_permissions(params, context).await + } + + async fn execute( + &self, + params: serde_json::Value, + context: &ToolContext, + ) -> Result { + let input = serde_json::from_value::(params.clone()).ok(); + let mut result = self.delegate.execute(params, context).await?; + + let Some(task_id) = input.map(|value| value.task_id) else { + return Ok(result); + }; + + let Some(state) = self.task_manager.get_status(&task_id).await else { + return Ok(result); + }; + + result = result + .with_metadata( + "output_file", + serde_json::json!(state.output_file.to_string_lossy().to_string()), + ) + .with_metadata( + "working_directory", + serde_json::json!(state.working_directory.to_string_lossy().to_string()), + ) + .with_metadata("session_id", serde_json::json!(state.session_id)) + .with_metadata("status", serde_json::json!(state.status.to_string())); + + if let Some(exit_code) = state.exit_code { + result = result.with_metadata("exit_code", serde_json::json!(exit_code)); + } + + Ok(result) + } +} + +pub(super) fn register_workspace_runtime_tools( + registry: &mut aster::tools::ToolRegistry, + task_manager: Arc, + auto_approve_warnings: bool, + sandboxed_bash_tool: Option, +) { + registry.register(Box::new(WorkspaceTaskTool::new( + auto_approve_warnings, + task_manager.clone(), + ))); + registry.register(Box::new(WorkspaceTaskOutputTool::new(task_manager.clone()))); + registry.register(Box::new(KillShellTool::with_task_manager(task_manager))); + + if let Some(workspace_bash_tool) = sandboxed_bash_tool { + registry.register(Box::new(workspace_bash_tool)); + } +} + +pub(super) fn wrap_registry_native_tools_for_workspace_runtime( + registry: &mut aster::tools::ToolRegistry, +) { + wrap_registry_native_tools_for_durable_memory_fs(registry); + wrap_registry_native_tools_for_harness_observability(registry); +} diff --git a/src-tauri/src/commands/claw_solution_cmd.rs b/src-tauri/src/commands/claw_solution_cmd.rs new file mode 100644 index 000000000..bb2b1becf --- /dev/null +++ b/src-tauri/src/commands/claw_solution_cmd.rs @@ -0,0 +1,45 @@ +use crate::database::DbConnection; +use crate::services::claw_solution_service::{ + ClawSolutionContext, ClawSolutionDetail, ClawSolutionPreparation, ClawSolutionReadinessResult, + ClawSolutionService, ClawSolutionSummary, +}; +use tauri::State; + +#[tauri::command] +pub async fn claw_solution_list( + db: State<'_, DbConnection>, +) -> Result, String> { + ClawSolutionService::default().list(&db).await +} + +#[tauri::command] +pub async fn claw_solution_detail( + db: State<'_, DbConnection>, + solution_id: String, +) -> Result { + ClawSolutionService::default() + .detail(&db, &solution_id) + .await +} + +#[tauri::command] +pub async fn claw_solution_check_readiness( + db: State<'_, DbConnection>, + solution_id: String, + _context: Option, +) -> Result { + ClawSolutionService::default() + .check_readiness(&db, &solution_id) + .await +} + +#[tauri::command] +pub async fn claw_solution_prepare( + db: State<'_, DbConnection>, + solution_id: String, + context: Option, +) -> Result { + ClawSolutionService::default() + .prepare(&db, &solution_id, context) + .await +} diff --git a/src-tauri/src/commands/config_cmd.rs b/src-tauri/src/commands/config_cmd.rs index be157c16e..4f8dc2a6a 100644 --- a/src-tauri/src/commands/config_cmd.rs +++ b/src-tauri/src/commands/config_cmd.rs @@ -5,7 +5,6 @@ use crate::config::{ use crate::models::app_type::AppType; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; use tauri::AppHandle; use tauri_plugin_autostart::ManagerExt; @@ -583,688 +582,3 @@ pub async fn open_auth_dir(path: String) -> Result { Ok(true) } - -// ============ Version Check Commands ============ - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VersionCheckResult { - pub current: String, - pub latest: Option, - #[serde(rename = "hasUpdate")] - pub has_update: bool, - #[serde(rename = "downloadUrl")] - pub download_url: Option, - pub error: Option, -} - -const FALLBACK_RELEASES_URL: &str = "https://github.com/aiclientproxy/lime/releases"; - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -struct UpdateCheckCache { - latest: Option, - download_url: Option, - etag: Option, - last_checked_unix: u64, -} - -/// 检查应用更新 -/// -/// 从 GitHub Releases API 获取最新版本信息并与当前版本比较 -#[tauri::command] -pub async fn check_for_updates() -> Result { - const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); - const GITHUB_API_URL: &str = "https://api.github.com/repos/aiclientproxy/lime/releases/latest"; - const UPDATE_CHECK_CACHE_TTL_SECS: u64 = 10 * 60; - - let now_unix = current_unix_timestamp(); - let cache_path = get_update_check_cache_path(); - let cached = load_update_check_cache(&cache_path); - - if let Some(cache) = &cached { - if is_update_cache_fresh(cache, now_unix, UPDATE_CHECK_CACHE_TTL_SECS) { - return Ok(build_version_check_result( - CURRENT_VERSION, - cache.latest.clone(), - cache.download_url.clone(), - None, - )); - } - } - - let client = reqwest::Client::new(); - let mut request = client - .get(GITHUB_API_URL) - .header("User-Agent", "Lime") - .header("Accept", "application/vnd.github+json"); - - if let Some(cache) = &cached { - if let Some(etag) = &cache.etag { - request = request.header("If-None-Match", etag); - } - } - - match request.send().await { - Ok(response) => { - if response.status() == reqwest::StatusCode::NOT_MODIFIED { - if let Some(cache) = cached { - let refreshed_cache = UpdateCheckCache { - last_checked_unix: now_unix, - ..cache.clone() - }; - let _ = save_update_check_cache(&cache_path, &refreshed_cache); - return Ok(build_version_check_result( - CURRENT_VERSION, - refreshed_cache.latest, - refreshed_cache.download_url, - None, - )); - } - } - - if response.status().is_success() { - let etag = response - .headers() - .get(reqwest::header::ETAG) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - match response.json::().await { - Ok(data) => { - let latest_version = data["tag_name"] - .as_str() - .unwrap_or("") - .trim_start_matches('v'); - - let download_url = data["html_url"].as_str().map(|s| s.to_string()); - - let new_cache = UpdateCheckCache { - latest: Some(latest_version.to_string()), - download_url: download_url.clone(), - etag, - last_checked_unix: now_unix, - }; - let _ = save_update_check_cache(&cache_path, &new_cache); - - Ok(build_version_check_result( - CURRENT_VERSION, - Some(latest_version.to_string()), - download_url, - None, - )) - } - Err(e) => Ok(build_version_from_cache_or_default( - CURRENT_VERSION, - cached.as_ref(), - Some(format!("解析更新信息失败,已回退本地缓存: {e}")), - )), - } - } else { - let error_message = match response.status() { - reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::TOO_MANY_REQUESTS => { - "GitHub API 限流,已回退本地缓存,请稍后重试".to_string() - } - status => format!("GitHub API 请求失败: {status},已回退本地缓存"), - }; - - Ok(build_version_from_cache_or_default( - CURRENT_VERSION, - cached.as_ref(), - Some(error_message), - )) - } - } - Err(e) => Ok(build_version_from_cache_or_default( - CURRENT_VERSION, - cached.as_ref(), - Some(format!("网络请求失败,已回退本地缓存: {e}")), - )), - } -} - -fn current_unix_timestamp() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0) -} - -fn get_update_check_cache_path() -> PathBuf { - let base_dir = dirs::cache_dir() - .or_else(dirs::config_dir) - .unwrap_or_else(|| PathBuf::from(".")); - - base_dir.join("lime").join("update-check-cache.json") -} - -fn is_update_cache_fresh(cache: &UpdateCheckCache, now_unix: u64, ttl_secs: u64) -> bool { - if cache.latest.is_none() { - return false; - } - - now_unix.saturating_sub(cache.last_checked_unix) < ttl_secs -} - -fn load_update_check_cache(path: &PathBuf) -> Option { - let content = std::fs::read_to_string(path).ok()?; - serde_json::from_str::(&content).ok() -} - -fn save_update_check_cache(path: &PathBuf, cache: &UpdateCheckCache) -> Result<(), String> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - - let content = serde_json::to_string(cache).map_err(|e| e.to_string())?; - std::fs::write(path, content).map_err(|e| e.to_string()) -} - -fn build_version_check_result( - current: &str, - latest: Option, - download_url: Option, - error: Option, -) -> VersionCheckResult { - let resolved_download_url = download_url.or_else(|| Some(FALLBACK_RELEASES_URL.to_string())); - let has_update = latest - .as_deref() - .map(|latest_version| version_compare(current, latest_version)) - .unwrap_or(false); - - VersionCheckResult { - current: current.to_string(), - latest, - has_update, - download_url: resolved_download_url, - error, - } -} - -fn build_version_from_cache_or_default( - current: &str, - cache: Option<&UpdateCheckCache>, - error: Option, -) -> VersionCheckResult { - if let Some(cached) = cache { - return build_version_check_result( - current, - cached.latest.clone(), - cached.download_url.clone(), - error, - ); - } - - build_version_check_result(current, None, None, error) -} - -/// 简单的版本比较函数 -/// 返回 true 如果 latest > current -fn version_compare(current: &str, latest: &str) -> bool { - // 移除 'v' 前缀 - let current = current.trim_start_matches('v'); - let latest = latest.trim_start_matches('v'); - - let current_parts: Vec = current.split('.').filter_map(|s| s.parse().ok()).collect(); - let latest_parts: Vec = latest.split('.').filter_map(|s| s.parse().ok()).collect(); - - let max_len = current_parts.len().max(latest_parts.len()); - - for i in 0..max_len { - let current_part = current_parts.get(i).unwrap_or(&0); - let latest_part = latest_parts.get(i).unwrap_or(&0); - - if latest_part > current_part { - return true; - } else if latest_part < current_part { - return false; - } - } - - false -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_version_compare() { - // 测试版本比较逻辑 - assert!(version_compare("0.14.0", "0.14.1")); - assert!(version_compare("0.14.0", "0.15.0")); - assert!(version_compare("0.14.0", "1.0.0")); - assert!(!version_compare("0.14.1", "0.14.0")); - assert!(!version_compare("0.14.0", "0.14.0")); - assert!(!version_compare("1.0.0", "0.14.0")); - } - - #[test] - fn test_get_platform_patterns() { - let patterns = get_platform_patterns(); - - // 在支持的平台上应该返回非空的模式列表 - #[cfg(any( - all( - target_os = "windows", - any(target_arch = "x86_64", target_arch = "aarch64") - ), - all( - target_os = "macos", - any(target_arch = "x86_64", target_arch = "aarch64") - ), - all( - target_os = "linux", - any(target_arch = "x86_64", target_arch = "aarch64") - ) - ))] - { - assert!(!patterns.is_empty()); - } - - // 在不支持的平台上应该返回空列表 - #[cfg(not(any( - all( - target_os = "windows", - any(target_arch = "x86_64", target_arch = "aarch64") - ), - all( - target_os = "macos", - any(target_arch = "x86_64", target_arch = "aarch64") - ), - all( - target_os = "linux", - any(target_arch = "x86_64", target_arch = "aarch64") - ) - )))] - { - assert!(patterns.is_empty()); - } - } - - #[test] - fn test_is_update_cache_fresh() { - let cache = UpdateCheckCache { - latest: Some("0.76.0".to_string()), - download_url: Some("https://example.com".to_string()), - etag: Some("etag".to_string()), - last_checked_unix: 100, - }; - - assert!(is_update_cache_fresh(&cache, 150, 60)); - assert!(!is_update_cache_fresh(&cache, 170, 60)); - - let cache_without_latest = UpdateCheckCache { - latest: None, - ..cache - }; - assert!(!is_update_cache_fresh(&cache_without_latest, 120, 60)); - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DownloadResult { - pub success: bool, - pub message: String, - #[serde(rename = "filePath")] - pub file_path: Option, -} - -/// 下载更新安装包 -/// -/// 从 GitHub Releases 下载对应平台的安装包到下载目录 -#[tauri::command] -pub async fn download_update(app_handle: AppHandle) -> Result { - // 首先检查是否有更新 - let version_info = check_for_updates().await?; - - if !version_info.has_update { - return Ok(DownloadResult { - success: false, - message: "当前已是最新版本".to_string(), - file_path: None, - }); - } - - let latest_version = version_info.latest.ok_or("无法获取最新版本信息")?; - - // 从 GitHub API 获取实际的文件列表并匹配平台 - let (filename, download_url) = get_platform_download_from_github(&latest_version).await?; - - // 获取下载目录 - let download_dir = get_download_directory(&app_handle)?; - let file_path = download_dir.join(&filename); - - // 如果文件已存在,先删除 - if file_path.exists() { - if let Err(e) = std::fs::remove_file(&file_path) { - tracing::warn!("删除旧文件失败: {}", e); - } - } - - // 下载文件 - let client = reqwest::Client::new(); - - match client - .get(&download_url) - .header("User-Agent", "Lime") - .send() - .await - { - Ok(response) => { - if !response.status().is_success() { - return Ok(DownloadResult { - success: false, - message: format!("下载失败: HTTP {}", response.status()), - file_path: None, - }); - } - - // 获取文件内容 - match response.bytes().await { - Ok(bytes) => { - // 写入文件 - match std::fs::write(&file_path, bytes) { - Ok(_) => { - tracing::info!("安装包下载成功: {:?}", file_path); - - // 尝试直接运行安装程序 - match run_installer(&file_path) { - Ok(_) => { - tracing::info!("已启动安装程序,准备退出当前应用"); - - // 延迟退出,给安装程序时间启动 - tokio::spawn(async { - tokio::time::sleep(tokio::time::Duration::from_secs(2)) - .await; - tracing::info!("自动退出应用以便安装程序运行"); - std::process::exit(0); - }); - } - Err(e) => { - tracing::warn!("启动安装程序失败: {},尝试打开文件位置", e); - // 如果无法运行安装程序,则打开文件所在目录 - if let Err(open_err) = open_file_location(&file_path) { - tracing::warn!("打开文件所在目录也失败: {}", open_err); - } - } - } - - Ok(DownloadResult { - success: true, - message: format!("下载完成: {filename}"), - file_path: Some(file_path.to_string_lossy().to_string()), - }) - } - Err(e) => Ok(DownloadResult { - success: false, - message: format!("保存文件失败: {e}"), - file_path: None, - }), - } - } - Err(e) => Ok(DownloadResult { - success: false, - message: format!("读取下载内容失败: {e}"), - file_path: None, - }), - } - } - Err(e) => Ok(DownloadResult { - success: false, - message: format!("网络请求失败: {e}"), - file_path: None, - }), - } -} - -/// 从 GitHub API 获取实际的文件列表并匹配平台 -async fn get_platform_download_from_github(version: &str) -> Result<(String, String), String> { - let api_url = - format!("https://api.github.com/repos/aiclientproxy/lime/releases/tags/v{version}"); - - let client = reqwest::Client::new(); - let response = client - .get(&api_url) - .header("User-Agent", "Lime") - .send() - .await - .map_err(|e| format!("请求 GitHub API 失败: {e}"))?; - - if !response.status().is_success() { - return Err(format!("GitHub API 请求失败: {}", response.status())); - } - - let data: serde_json::Value = response - .json() - .await - .map_err(|e| format!("解析 GitHub API 响应失败: {e}"))?; - - let assets = data["assets"] - .as_array() - .ok_or("GitHub API 响应中没有找到 assets")?; - - // 根据当前平台匹配文件 - let platform_patterns = get_platform_patterns(); - - for asset in assets { - let name = asset["name"].as_str().unwrap_or(""); - let download_url = asset["browser_download_url"].as_str().unwrap_or(""); - - for pattern in &platform_patterns { - if name.contains(pattern) { - return Ok((name.to_string(), download_url.to_string())); - } - } - } - - Err("未找到适合当前平台的安装包".to_string()) -} - -/// 获取当前平台的文件名匹配模式 -fn get_platform_patterns() -> Vec<&'static str> { - #[cfg(all(target_os = "windows", target_arch = "x86_64"))] - { - vec![ - "x64-online-setup.exe", - "x64-setup.exe", - "x64-offline-setup.exe", - "x64_en-US.msi", - ] - } - - #[cfg(all(target_os = "windows", target_arch = "aarch64"))] - { - vec![ - "arm64-online-setup.exe", - "arm64-setup.exe", - "arm64-offline-setup.exe", - "arm64_en-US.msi", - ] - } - - #[cfg(all(target_os = "macos", target_arch = "x86_64"))] - { - vec!["x64.dmg"] - } - - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - { - vec!["aarch64.dmg"] - } - - #[cfg(all(target_os = "linux", target_arch = "x86_64"))] - { - vec!["amd64.deb", "amd64.AppImage"] - } - - #[cfg(all(target_os = "linux", target_arch = "aarch64"))] - { - vec!["arm64.deb", "arm64.AppImage"] - } - - #[cfg(not(any( - all( - target_os = "windows", - any(target_arch = "x86_64", target_arch = "aarch64") - ), - all( - target_os = "macos", - any(target_arch = "x86_64", target_arch = "aarch64") - ), - all( - target_os = "linux", - any(target_arch = "x86_64", target_arch = "aarch64") - ) - )))] - { - vec![] - } -} - -/// 获取下载目录 -fn get_download_directory(app_handle: &AppHandle) -> Result { - // 优先使用系统下载目录 - if let Some(download_dir) = dirs::download_dir() { - return Ok(download_dir); - } - - // 回退到应用数据目录 - let _ = app_handle; - let app_data_dir = lime_core::app_paths::preferred_data_dir() - .map_err(|e| format!("无法获取应用数据目录: {e}"))?; - - let download_dir = app_data_dir.join("downloads"); - - // 确保目录存在 - std::fs::create_dir_all(&download_dir).map_err(|e| format!("创建下载目录失败: {e}"))?; - - Ok(download_dir) -} - -/// 运行安装程序 -fn run_installer(file_path: &PathBuf) -> Result<(), String> { - let extension = file_path - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or(""); - - match extension.to_lowercase().as_str() { - "exe" | "msi" => { - #[cfg(target_os = "windows")] - { - tracing::info!("Windows: 启动安装程序: {:?}", file_path); - std::process::Command::new(file_path) - .spawn() - .map_err(|e| format!("启动 Windows 安装程序失败: {}", e))?; - } - - #[cfg(not(target_os = "windows"))] - { - return Err("Windows 安装程序只能在 Windows 系统上运行".to_string()); - } - } - "dmg" => { - #[cfg(target_os = "macos")] - { - tracing::info!("macOS: 打开 DMG 文件: {:?}", file_path); - std::process::Command::new("open") - .arg(file_path) - .spawn() - .map_err(|e| format!("打开 macOS DMG 文件失败: {e}"))?; - } - - #[cfg(not(target_os = "macos"))] - { - return Err("DMG 文件只能在 macOS 系统上打开".to_string()); - } - } - "deb" => { - #[cfg(target_os = "linux")] - { - tracing::info!("Linux: 尝试安装 DEB 包: {:?}", file_path); - // 尝试使用系统默认的包管理器打开 - let result = std::process::Command::new("xdg-open") - .arg(&file_path) - .spawn(); - - if result.is_err() { - // 如果 xdg-open 失败,尝试使用 dpkg - tracing::info!("xdg-open 失败,尝试使用 gdebi 或提示用户手动安装"); - return Err("请手动安装 DEB 包,或使用: sudo dpkg -i filename.deb".to_string()); - } - } - - #[cfg(not(target_os = "linux"))] - { - return Err("DEB 包只能在 Linux 系统上安装".to_string()); - } - } - "appimage" => { - #[cfg(target_os = "linux")] - { - tracing::info!("Linux: 设置 AppImage 可执行权限并运行: {:?}", file_path); - // 设置可执行权限 - std::process::Command::new("chmod") - .args(&["+x", &file_path.to_string_lossy()]) - .output() - .map_err(|e| format!("设置 AppImage 可执行权限失败: {}", e))?; - - // 运行 AppImage - std::process::Command::new(&file_path) - .spawn() - .map_err(|e| format!("运行 AppImage 失败: {}", e))?; - } - - #[cfg(not(target_os = "linux"))] - { - return Err("AppImage 只能在 Linux 系统上运行".to_string()); - } - } - _ => { - return Err(format!("不支持的文件类型: {extension}")); - } - } - - Ok(()) -} - -/// 打开文件所在位置 -fn open_file_location(file_path: &PathBuf) -> Result<(), String> { - #[cfg(target_os = "windows")] - { - tracing::info!("Windows: 使用 explorer 打开文件位置: {:?}", file_path); - std::process::Command::new("explorer") - .args(["/select,", &file_path.to_string_lossy()]) - .creation_flags(0x08000000) // CREATE_NO_WINDOW - .spawn() - .map_err(|e| format!("Windows explorer 启动失败: {}", e))?; - } - - #[cfg(target_os = "macos")] - { - tracing::info!("macOS: 使用 open -R 打开文件位置: {:?}", file_path); - std::process::Command::new("open") - .args(["-R", &file_path.to_string_lossy()]) - .spawn() - .map_err(|e| format!("macOS open 命令失败: {e}"))?; - } - - #[cfg(target_os = "linux")] - { - if let Some(parent) = file_path.parent() { - tracing::info!("Linux: 使用 xdg-open 打开目录: {:?}", parent); - std::process::Command::new("xdg-open") - .arg(parent) - .spawn() - .map_err(|e| format!("Linux xdg-open 命令失败: {}", e))?; - } else { - return Err("无法获取文件的父目录".to_string()); - } - } - - #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] - { - return Err("不支持的操作系统".to_string()); - } - - Ok(()) -} diff --git a/src-tauri/src/commands/memory_management_cmd.rs b/src-tauri/src/commands/memory_management_cmd.rs index 6b9ebb310..d760c6349 100644 --- a/src-tauri/src/commands/memory_management_cmd.rs +++ b/src-tauri/src/commands/memory_management_cmd.rs @@ -17,6 +17,11 @@ use crate::services::chat_history_service::{load_memory_source_candidates, Memor use crate::services::memory_source_resolver_service::{ resolve_effective_sources, EffectiveMemorySourcesResponse, }; +use crate::services::runtime_agents_template_service::{ + ensure_workspace_local_agents_gitignore, scaffold_runtime_agents_template, + RuntimeAgentsTemplateScaffoldResult, RuntimeAgentsTemplateTarget, + WorkspaceGitignoreEnsureResult, +}; use chrono::{Local, NaiveDateTime, TimeZone}; use lime_core::app_paths; use lime_services::context_memory_service::{MemoryEntry, MemoryFileType}; @@ -412,6 +417,36 @@ pub async fn memory_update_auto_note( ) } +/// 显式生成运行时 AGENTS 模板 +#[tauri::command] +pub async fn memory_scaffold_runtime_agents_template( + target: RuntimeAgentsTemplateTarget, + working_dir: Option, + overwrite: Option, +) -> Result { + let resolved_working_dir = match target { + RuntimeAgentsTemplateTarget::Global => None, + RuntimeAgentsTemplateTarget::Workspace | RuntimeAgentsTemplateTarget::WorkspaceLocal => { + Some(resolve_working_dir(working_dir)?) + } + }; + + scaffold_runtime_agents_template( + target, + resolved_working_dir.as_deref(), + overwrite.unwrap_or(false), + ) +} + +/// 确保 Workspace `.gitignore` 忽略 `.lime/AGENTS.local.md` +#[tauri::command] +pub async fn memory_ensure_workspace_local_agents_gitignore( + working_dir: Option, +) -> Result { + let resolved_working_dir = resolve_working_dir(working_dir)?; + ensure_workspace_local_agents_gitignore(&resolved_working_dir) +} + fn resolve_memory_dir() -> PathBuf { app_paths::best_effort_runtime_subdir("memory") } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2a17d4702..1df28ee9a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod browser_environment_cmd; pub mod browser_profile_cmd; pub mod browser_runtime_cmd; pub mod channels_cmd; +pub mod claw_solution_cmd; pub mod config_cmd; pub mod connect_cmd; pub mod connection_cmd; diff --git a/src-tauri/src/commands/persona_cmd.rs b/src-tauri/src/commands/persona_cmd.rs index c6332fc59..5b0dc66eb 100644 --- a/src-tauri/src/commands/persona_cmd.rs +++ b/src-tauri/src/commands/persona_cmd.rs @@ -26,6 +26,7 @@ use crate::models::project_model::{ CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate, UpdateBrandExtensionRequest, }; use crate::services::memory_profile_prompt_service::{build_memory_prompt, MemoryPromptContext}; +use lime_agent::merge_system_prompt_with_runtime_agents; use lime_services::persona_service::PersonaService; // ============================================================================ @@ -361,10 +362,19 @@ pub async fn generate_persona( let mut session_config_builder = crate::agent::aster_state::SessionConfigBuilder::new(&session_id) .include_context_trace(true); - if let Some(memory_prompt) = + let base_runtime_prompt = merge_system_prompt_with_runtime_agents(None, None); + let merged_prompt = if let Some(memory_prompt) = build_memory_prompt(&config_manager.config(), MemoryPromptContext::default()) { - session_config_builder = session_config_builder.system_prompt(memory_prompt); + match base_runtime_prompt { + Some(base) => Some(format!("{base}\n\n{memory_prompt}")), + None => Some(memory_prompt), + } + } else { + base_runtime_prompt + }; + if let Some(prompt) = merged_prompt { + session_config_builder = session_config_builder.system_prompt(prompt); } let session_config = session_config_builder.build(); diff --git a/src-tauri/src/commands/theme_context_cmd.rs b/src-tauri/src/commands/theme_context_cmd.rs index 92d0e4f5c..b4af8ca95 100644 --- a/src-tauri/src/commands/theme_context_cmd.rs +++ b/src-tauri/src/commands/theme_context_cmd.rs @@ -14,8 +14,8 @@ 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 lime_agent::{ - resolve_request_tool_policy_with_mode, stream_reply_with_policy, RequestToolPolicyMode, - SessionConfigBuilder, + merge_system_prompt_with_runtime_agents, resolve_request_tool_policy_with_mode, + stream_reply_with_policy, RequestToolPolicyMode, SessionConfigBuilder, }; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -389,7 +389,10 @@ pub async fn aster_agent_theme_context_search( let system_prompt = lime_agent::merge_system_prompt_with_request_tool_policy( merge_system_prompt_with_web_search( merge_system_prompt_with_memory_context( - project_prompt, + merge_system_prompt_with_runtime_agents( + project_prompt, + Some(Path::new(&workspace_root)), + ), &runtime_config, MemoryPromptContext::with_working_dir(Path::new(&workspace_root)), ), diff --git a/src-tauri/src/commands/update_cmd.rs b/src-tauri/src/commands/update_cmd.rs index c7e995ffd..180f100d0 100644 --- a/src-tauri/src/commands/update_cmd.rs +++ b/src-tauri/src/commands/update_cmd.rs @@ -1,10 +1,7 @@ //! 更新检查命令模块 //! -//! 提供自动更新检查相关的 Tauri 命令 -//! -//! input: 前端调用请求 -//! output: 更新信息、配置操作结果 -//! pos: commands 层,被前端调用 +//! 提供自动更新检查相关的 Tauri 命令。 +//! 检查逻辑走静态 `latest.json` 清单,安装逻辑走 Tauri updater。 use crate::app::AppState; use crate::config; @@ -13,11 +10,22 @@ use lime_services::update_check_service::{ UpdateCheckService, UpdateCheckServiceState, UpdateInfo, }; use serde::{Deserialize, Serialize}; -use std::sync::Arc; +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; use tauri::{AppHandle, Manager, State}; -use tokio::sync::RwLock; +use tauri_plugin_updater::UpdaterExt; const DAY_SECONDS: u64 = 24 * 3600; +const UPDATE_CHECK_CACHE_TTL_SECS: u64 = 10 * 60; +const FALLBACK_RELEASES_URL: &str = "https://github.com/aiclientproxy/lime/releases"; +const DEFAULT_UPDATE_MANIFEST_URL: &str = + "https://github.com/aiclientproxy/lime/releases/latest/download/latest.json"; + +/// 编译期注入 updater 公钥;开发环境可为空,此时仅保留手动下载兜底。 +const COMPILED_UPDATER_PUBLIC_KEY: Option<&str> = option_env!("LIME_UPDATER_PUBLIC_KEY"); +/// 编译期注入 updater manifest 地址;未配置时使用 GitHub Releases latest.json。 +const COMPILED_UPDATER_ENDPOINT: Option<&str> = option_env!("LIME_UPDATER_ENDPOINT"); /// 更新检查配置(前端可见) #[derive(Debug, Clone, Serialize, Deserialize)] @@ -44,6 +52,55 @@ pub struct UpdateNotificationMetrics { pub dismiss_rate: f64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionCheckResult { + pub current: String, + pub latest: Option, + #[serde(rename = "hasUpdate")] + pub has_update: bool, + #[serde(rename = "downloadUrl")] + pub download_url: Option, + #[serde(rename = "releaseNotes")] + pub release_notes: Option, + #[serde(rename = "pubDate")] + pub pub_date: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadResult { + pub success: bool, + pub message: String, + #[serde(rename = "filePath")] + pub file_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct UpdateCheckCache { + latest: Option, + download_url: Option, + release_notes: Option, + pub_date: Option, + last_checked_unix: u64, +} + +#[derive(Debug, Deserialize)] +struct StaticUpdateManifest { + version: String, + #[serde(default)] + notes: Option, + #[serde(default)] + pub_date: Option, + platforms: HashMap, +} + +#[derive(Debug, Deserialize)] +struct StaticUpdatePlatform { + url: String, + #[allow(dead_code)] + signature: Option, +} + fn rate_percent(numerator: u64, denominator: u64) -> f64 { if denominator == 0 { return 0.0; @@ -52,13 +109,346 @@ fn rate_percent(numerator: u64, denominator: u64) -> f64 { (rate * 10.0).round() / 10.0 } -/// 手动检查更新 +fn current_unix_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn updater_manifest_url() -> &'static str { + COMPILED_UPDATER_ENDPOINT + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_UPDATE_MANIFEST_URL) +} + +fn updater_public_key() -> Option<&'static str> { + COMPILED_UPDATER_PUBLIC_KEY + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn release_tag_url(version: &str) -> String { + format!( + "https://github.com/aiclientproxy/lime/releases/tag/v{}", + version.trim_start_matches('v') + ) +} + +fn current_platform_key() -> Option<&'static str> { + #[cfg(all(target_os = "windows", target_arch = "x86_64"))] + { + return Some("windows-x86_64"); + } + + #[cfg(all(target_os = "windows", target_arch = "aarch64"))] + { + return Some("windows-aarch64"); + } + + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + { + return Some("darwin-x86_64"); + } + + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + { + return Some("darwin-aarch64"); + } + + #[allow(unreachable_code)] + None +} + +fn get_update_check_cache_path() -> PathBuf { + let base_dir = dirs::cache_dir() + .or_else(dirs::config_dir) + .unwrap_or_else(|| PathBuf::from(".")); + + base_dir.join("lime").join("update-check-cache.json") +} + +fn is_update_cache_fresh(cache: &UpdateCheckCache, now_unix: u64, ttl_secs: u64) -> bool { + if cache.latest.is_none() { + return false; + } + + now_unix.saturating_sub(cache.last_checked_unix) < ttl_secs +} + +fn load_update_check_cache(path: &PathBuf) -> Option { + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str::(&content).ok() +} + +fn save_update_check_cache(path: &PathBuf, cache: &UpdateCheckCache) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + + let content = serde_json::to_string(cache).map_err(|e| e.to_string())?; + std::fs::write(path, content).map_err(|e| e.to_string()) +} + +fn build_update_info( + latest: Option, + release_notes: Option, + pub_date: Option, + error: Option, +) -> UpdateInfo { + let current_version = UpdateCheckService::current_version().to_string(); + let latest_version = latest + .as_deref() + .map(|value| value.trim_start_matches('v').to_string()); + let download_url = latest_version + .as_deref() + .map(release_tag_url) + .or_else(|| Some(FALLBACK_RELEASES_URL.to_string())); + let has_update = latest_version + .as_deref() + .map(|latest_version| UpdateCheckService::version_compare(¤t_version, latest_version)) + .unwrap_or(false); + + UpdateInfo { + current_version, + latest_version, + has_update, + download_url: download_url.clone(), + release_notes_url: download_url, + release_notes, + pub_date, + checked_at: current_unix_timestamp(), + error, + } +} + +fn build_update_info_from_cache_or_default( + cache: Option<&UpdateCheckCache>, + error: Option, +) -> UpdateInfo { + if let Some(cached) = cache { + let mut info = build_update_info( + cached.latest.clone(), + cached.release_notes.clone(), + cached.pub_date.clone(), + error, + ); + if cached.download_url.is_some() { + info.download_url = cached.download_url.clone(); + info.release_notes_url = cached.download_url.clone(); + } + return info; + } + + build_update_info(None, None, None, error) +} + +fn build_version_check_result(info: UpdateInfo) -> VersionCheckResult { + VersionCheckResult { + current: info.current_version, + latest: info.latest_version, + has_update: info.has_update, + download_url: info.download_url, + release_notes: info.release_notes, + pub_date: info.pub_date, + error: info.error, + } +} + +fn manifest_to_cache(manifest: &StaticUpdateManifest, checked_at: u64) -> UpdateCheckCache { + UpdateCheckCache { + latest: Some(manifest.version.trim_start_matches('v').to_string()), + download_url: Some(release_tag_url(&manifest.version)), + release_notes: manifest.notes.clone(), + pub_date: manifest.pub_date.clone(), + last_checked_unix: checked_at, + } +} + +fn build_update_info_from_manifest(manifest: StaticUpdateManifest) -> UpdateInfo { + let latest_version = manifest.version.trim_start_matches('v').to_string(); + let platform_error = match current_platform_key() { + Some(platform_key) + if manifest + .platforms + .get(platform_key) + .is_some_and(|platform| !platform.url.trim().is_empty()) => + { + None + } + Some(platform_key) if manifest.platforms.contains_key(platform_key) => Some(format!( + "已检测到新版本,但当前平台 {} 的安装包地址为空,请前往发布页手动下载", + platform_key + )), + Some(platform_key) => Some(format!( + "已检测到新版本,但当前平台 {} 暂无安装包,请前往发布页手动下载", + platform_key + )), + None => Some("当前平台暂不支持应用内升级,请前往发布页手动下载".to_string()), + }; + + build_update_info( + Some(latest_version), + manifest.notes, + manifest.pub_date, + platform_error, + ) +} + +async fn fetch_update_info() -> UpdateInfo { + let now_unix = current_unix_timestamp(); + let cache_path = get_update_check_cache_path(); + let cached = load_update_check_cache(&cache_path); + + if let Some(cache) = &cached { + if is_update_cache_fresh(cache, now_unix, UPDATE_CHECK_CACHE_TTL_SECS) { + return build_update_info_from_cache_or_default(cached.as_ref(), None); + } + } + + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .build() + { + Ok(client) => client, + Err(error) => { + return build_update_info_from_cache_or_default( + cached.as_ref(), + Some(format!("创建更新检查客户端失败,已回退本地缓存: {error}")), + ); + } + }; + + match client + .get(updater_manifest_url()) + .header("User-Agent", "Lime") + .send() + .await + { + Ok(response) => { + if !response.status().is_success() { + return build_update_info_from_cache_or_default( + cached.as_ref(), + Some(format!( + "更新清单请求失败(HTTP {}),已回退本地缓存", + response.status() + )), + ); + } + + match response.json::().await { + Ok(manifest) => { + let cache = manifest_to_cache(&manifest, now_unix); + let _ = save_update_check_cache(&cache_path, &cache); + build_update_info_from_manifest(manifest) + } + Err(error) => build_update_info_from_cache_or_default( + cached.as_ref(), + Some(format!("解析更新清单失败,已回退本地缓存: {error}")), + ), + } + } + Err(error) => build_update_info_from_cache_or_default( + cached.as_ref(), + Some(format!("请求更新清单失败,已回退本地缓存: {error}")), + ), + } +} + +async fn perform_update_check(update_service: &UpdateCheckServiceState) -> UpdateInfo { + { + let service = update_service.0.read().await; + service.begin_check().await; + } + + let result = fetch_update_info().await; + + let service = update_service.0.read().await; + service.finish_check(result).await +} + +async fn install_update_via_updater(app_handle: &AppHandle) -> Result<(), String> { + let public_key = updater_public_key() + .ok_or_else(|| "当前构建未内置更新签名公钥,请前往网页下载最新版".to_string())?; + let manifest_url = url::Url::parse(updater_manifest_url()) + .map_err(|error| format!("更新清单地址无效: {error}"))?; + + let updater = app_handle + .updater_builder() + .pubkey(public_key) + .endpoints(vec![manifest_url]) + .map_err(|error| format!("初始化更新源失败: {error}"))? + .build() + .map_err(|error| format!("创建 updater 失败: {error}"))?; + + let update = updater + .check() + .await + .map_err(|error| format!("检查更新安装包失败: {error}"))? + .ok_or_else(|| "当前已是最新版本".to_string())?; + + update + .download_and_install(|_, _| {}, || {}) + .await + .map_err(|error| format!("安装更新失败: {error}"))?; + + Ok(()) +} + +/// 手动检查更新,返回完整检查结果 #[tauri::command] pub async fn check_update( update_service: State<'_, UpdateCheckServiceState>, ) -> Result { - let service = update_service.0.read().await; - Ok(service.check_for_updates().await) + Ok(perform_update_check(update_service.inner()).await) +} + +/// 手动检查更新,返回前端兼容结构 +#[tauri::command] +pub async fn check_for_updates( + update_service: State<'_, UpdateCheckServiceState>, +) -> Result { + let info = perform_update_check(update_service.inner()).await; + Ok(build_version_check_result(info)) +} + +/// 下载并安装更新 +#[tauri::command] +pub async fn download_update( + app_handle: AppHandle, + update_service: State<'_, UpdateCheckServiceState>, +) -> Result { + let update_info = perform_update_check(update_service.inner()).await; + + if !update_info.has_update { + return Ok(DownloadResult { + success: false, + message: "当前已是最新版本".to_string(), + file_path: None, + }); + } + + match install_update_via_updater(&app_handle).await { + Ok(()) => { + let app_handle_clone = app_handle.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_secs(1)).await; + app_handle_clone.restart(); + }); + + Ok(DownloadResult { + success: true, + message: "更新已安装,应用即将重启完成升级".to_string(), + file_path: None, + }) + } + Err(error) => Ok(DownloadResult { + success: false, + message: format!("{error}。请前往发布页手动下载最新版"), + file_path: None, + }), + } } /// 获取更新检查配置 @@ -169,7 +559,6 @@ pub async fn skip_update_version( config::save_config(&state.config).map_err(|e| format!("保存配置失败: {e}"))?; - // 关闭更新窗口 let _ = update_window::close_update_window(&app_handle); Ok(()) @@ -183,10 +572,7 @@ pub async fn remind_update_later( hours: Option, ) -> Result { let remind_hours = hours.unwrap_or(24).clamp(1, 24 * 30); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + let now = current_unix_timestamp(); let remind_until = now + (remind_hours as u64 * 3600); let mut state = app_state.write().await; @@ -199,7 +585,6 @@ pub async fn remind_update_later( config::save_config(&state.config).map_err(|e| format!("保存配置失败: {e}"))?; - // 关闭更新窗口 let _ = update_window::close_update_window(&app_handle); Ok(remind_until) @@ -212,10 +597,7 @@ pub async fn dismiss_update_notification( app_state: State<'_, AppState>, version: Option, ) -> Result { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + let now = current_unix_timestamp(); let mut state = app_state.write().await; let update_config = &mut state.config.experimental.update_check; @@ -251,19 +633,16 @@ pub fn close_update_window(app_handle: AppHandle) -> Result<(), String> { /// 测试更新提醒窗口(仅开发环境使用) #[tauri::command] pub fn test_update_window(app_handle: AppHandle) -> Result<(), String> { - let current_version = env!("CARGO_PKG_VERSION"); + let current_version = UpdateCheckService::current_version(); let test_info = UpdateInfo { current_version: current_version.to_string(), latest_version: Some("0.99.0".to_string()), has_update: true, - download_url: Some( - "https://github.com/aiclientproxy/lime/releases/tag/v0.99.0".to_string(), - ), - release_notes_url: None, - checked_at: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), + download_url: Some(release_tag_url("0.99.0")), + release_notes_url: Some(release_tag_url("0.99.0")), + release_notes: Some("这是用于开发环境的模拟更新提醒。".to_string()), + pub_date: Some("2026-03-21T00:00:00Z".to_string()), + checked_at: current_unix_timestamp(), error: None, }; @@ -274,10 +653,7 @@ pub fn test_update_window(app_handle: AppHandle) -> Result<(), String> { /// 更新上次检查时间 #[tauri::command] pub async fn update_last_check_timestamp(app_state: State<'_, AppState>) -> Result { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + let now = current_unix_timestamp(); let mut state = app_state.write().await; state.config.experimental.update_check.last_check_timestamp = now; @@ -289,19 +665,17 @@ pub async fn update_last_check_timestamp(app_state: State<'_, AppState>) -> Resu /// 启动后台更新检查任务 /// -/// 在应用启动时调用,根据配置定期检查更新 +/// 在应用启动时调用,根据配置定期检查更新。 pub async fn start_background_update_check( app_handle: tauri::AppHandle, - update_service: Arc>, + update_service: UpdateCheckServiceState, ) { let app_handle_clone = app_handle.clone(); tokio::spawn(async move { - // 延迟 30 秒后开始第一次检查,避免影响启动性能 tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; loop { - // 获取当前配置 let ( enabled, interval_hours, @@ -333,17 +707,17 @@ pub async fn start_background_update_check( }; if !enabled { - // 如果禁用了自动检查,每小时检查一次配置是否变化 tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; continue; } - // 检查是否需要执行更新检查 - let service = update_service.read().await; - let last_result = service.get_state().await.last_result; + let last_result = { + let service = update_service.0.read().await; + service.get_state().await.last_result + }; let latest_version = last_result .as_ref() - .and_then(|r| r.latest_version.as_deref()); + .and_then(|result| result.latest_version.as_deref()); if UpdateCheckService::should_check( last_check, @@ -351,11 +725,7 @@ pub async fn start_background_update_check( skipped_version.as_deref(), latest_version, ) { - drop(service); - - // 执行更新检查 - let service = update_service.read().await; - let result = service.check_for_updates().await; + let result = perform_update_check(&update_service).await; tracing::info!( "[更新检查] 当前版本: {}, 最新版本: {:?}, 有更新: {}", @@ -364,28 +734,23 @@ pub async fn start_background_update_check( result.has_update ); - // 更新检查时间 if let Some(app_state) = app_handle_clone.try_state::() { let mut state = app_state.write().await; state.config.experimental.update_check.last_check_timestamp = result.checked_at; let _ = config::save_config(&state.config); } - // 如果有更新且启用了通知,打开独立的更新提醒窗口 if result.has_update && show_notification { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let in_remind_later = remind_later_until.is_some_and(|ts| ts > now); - let in_backoff = next_notify_after.is_some_and(|ts| ts > now); + let now = current_unix_timestamp(); + let in_remind_later = + remind_later_until.is_some_and(|timestamp| timestamp > now); + let in_backoff = next_notify_after.is_some_and(|timestamp| timestamp > now); let same_version_daily_limited = result.latest_version.as_ref().is_some_and(|latest| { last_notified_version.as_ref() == Some(latest) && now < last_notified_at.saturating_add(DAY_SECONDS) }); - // 检查是否跳过了此版本 let should_notify = result .latest_version .as_ref() @@ -402,28 +767,77 @@ pub async fn start_background_update_check( update_config.last_notified_at = now; update_config.notification_shown_count = update_config.notification_shown_count.saturating_add(1); - if update_config.next_notify_after.is_some_and(|ts| ts <= now) { + if update_config + .next_notify_after + .is_some_and(|timestamp| timestamp <= now) + { update_config.next_notify_after = None; } let _ = config::save_config(&state.config); } - // 打开独立的更新提醒窗口 - 必须在主线程执行 let app_handle_for_ui = app_handle_clone.clone(); let result_clone = result.clone(); let _ = app_handle_clone.run_on_main_thread(move || { - if let Err(e) = + if let Err(error) = update_window::open_update_window(&app_handle_for_ui, &result_clone) { - tracing::error!("[更新检查] 打开更新窗口失败: {}", e); + tracing::error!("[更新检查] 打开更新窗口失败: {}", error); } }); } } } - // 每小时检查一次是否需要执行更新检查 tokio::time::sleep(tokio::time::Duration::from_secs(3600)).await; } }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_update_cache_fresh() { + let cache = UpdateCheckCache { + latest: Some("0.92.0".to_string()), + download_url: Some(release_tag_url("0.92.0")), + release_notes: Some("notes".to_string()), + pub_date: Some("2026-03-21T00:00:00Z".to_string()), + last_checked_unix: 100, + }; + + assert!(is_update_cache_fresh(&cache, 150, 60)); + assert!(!is_update_cache_fresh(&cache, 170, 60)); + + let cache_without_latest = UpdateCheckCache { + latest: None, + ..cache + }; + assert!(!is_update_cache_fresh(&cache_without_latest, 120, 60)); + } + + #[test] + fn test_build_update_info_from_manifest() { + let manifest = StaticUpdateManifest { + version: "v0.94.0".to_string(), + notes: Some("bug fixes".to_string()), + pub_date: Some("2026-03-21T00:00:00Z".to_string()), + platforms: HashMap::from([( + current_platform_key() + .unwrap_or("windows-x86_64") + .to_string(), + StaticUpdatePlatform { + url: "https://example.com/lime.nsis.zip".to_string(), + signature: Some("sig".to_string()), + }, + )]), + }; + + let info = build_update_info_from_manifest(manifest); + assert_eq!(info.latest_version.as_deref(), Some("0.94.0")); + assert!(info.has_update); + assert_eq!(info.error, None); + } +} diff --git a/src-tauri/src/dev_bridge/dispatcher/memory_runtime.rs b/src-tauri/src/dev_bridge/dispatcher/memory_runtime.rs index 1e7bcd3eb..4b30efbd8 100644 --- a/src-tauri/src/dev_bridge/dispatcher/memory_runtime.rs +++ b/src-tauri/src/dev_bridge/dispatcher/memory_runtime.rs @@ -63,6 +63,123 @@ pub(super) async fn try_handle( .map_err(|e| format!("清理记忆失败: {e}"))?, )? } + "memory_get_effective_sources" => { + let app_handle = require_app_handle(state)?; + let args = args_or_default(args); + let working_dir = args + .get("workingDir") + .and_then(|value| value.as_str()) + .map(ToString::to_string); + let active_relative_path = args + .get("activeRelativePath") + .and_then(|value| value.as_str()) + .map(ToString::to_string); + let global_config = app_handle.state::(); + serde_json::to_value( + crate::commands::memory_management_cmd::memory_get_effective_sources( + global_config, + working_dir, + active_relative_path, + ) + .await + .map_err(|e| format!("获取有效记忆来源失败: {e}"))?, + )? + } + "memory_get_auto_index" => { + let app_handle = require_app_handle(state)?; + let args = args_or_default(args); + let working_dir = args + .get("workingDir") + .and_then(|value| value.as_str()) + .map(ToString::to_string); + let global_config = app_handle.state::(); + serde_json::to_value( + crate::commands::memory_management_cmd::memory_get_auto_index( + global_config, + working_dir, + ) + .await + .map_err(|e| format!("获取自动记忆索引失败: {e}"))?, + )? + } + "memory_toggle_auto" => { + let app_handle = require_app_handle(state)?; + let args = args_or_default(args); + let enabled = args + .get("enabled") + .and_then(|value| value.as_bool()) + .ok_or_else(|| "缺少参数: enabled".to_string())?; + let global_config = app_handle.state::(); + serde_json::to_value( + crate::commands::memory_management_cmd::memory_toggle_auto(global_config, enabled) + .await + .map_err(|e| format!("切换自动记忆失败: {e}"))?, + )? + } + "memory_update_auto_note" => { + let app_handle = require_app_handle(state)?; + let args = args_or_default(args); + let working_dir = args + .get("workingDir") + .and_then(|value| value.as_str()) + .map(ToString::to_string); + let note = args + .get("note") + .and_then(|value| value.as_str()) + .map(ToString::to_string) + .ok_or_else(|| "缺少参数: note".to_string())?; + let topic = args + .get("topic") + .and_then(|value| value.as_str()) + .map(ToString::to_string); + let global_config = app_handle.state::(); + serde_json::to_value( + crate::commands::memory_management_cmd::memory_update_auto_note( + global_config, + working_dir, + note, + topic, + ) + .await + .map_err(|e| format!("更新自动记忆失败: {e}"))?, + )? + } + "memory_scaffold_runtime_agents_template" => { + let args = args_or_default(args); + let target = serde_json::from_value( + args.get("target") + .cloned() + .ok_or_else(|| "缺少参数: target".to_string())?, + )?; + let working_dir = args + .get("workingDir") + .and_then(|value| value.as_str()) + .map(ToString::to_string); + let overwrite = args.get("overwrite").and_then(|value| value.as_bool()); + serde_json::to_value( + crate::commands::memory_management_cmd::memory_scaffold_runtime_agents_template( + target, + working_dir, + overwrite, + ) + .await + .map_err(|e| format!("生成运行时 AGENTS 模板失败: {e}"))?, + )? + } + "memory_ensure_workspace_local_agents_gitignore" => { + let args = args_or_default(args); + let working_dir = args + .get("workingDir") + .and_then(|value| value.as_str()) + .map(ToString::to_string); + serde_json::to_value( + crate::commands::memory_management_cmd::memory_ensure_workspace_local_agents_gitignore( + working_dir, + ) + .await + .map_err(|e| format!("更新 .gitignore 失败: {e}"))?, + )? + } _ => return Ok(None), }; diff --git a/src-tauri/src/services/claw_solution_service.rs b/src-tauri/src/services/claw_solution_service.rs new file mode 100644 index 000000000..b2f5c0da9 --- /dev/null +++ b/src-tauri/src/services/claw_solution_service.rs @@ -0,0 +1,526 @@ +use crate::commands::provider_pool_cmd::check_playwright_available; +use crate::database::DbConnection; +use lime_services::api_key_provider_service::ApiKeyProviderService; +use lime_services::model_service::ModelService; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ClawSolutionReadiness { + Ready, + NeedsSetup, + NeedsCapability, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ClawSolutionActionType { + FillInput, + NavigateTheme, + LaunchBrowserAssist, + EnableTeamMode, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ClawSolutionReasonCode { + MissingModel, + MissingBrowserCapability, + MissingSkillDependency, + TeamRecommended, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClawSolutionSummary { + pub id: String, + pub title: String, + pub summary: String, + pub output_hint: String, + pub recommended_capabilities: Vec, + pub readiness: ClawSolutionReadiness, + pub readiness_message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason_code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClawSolutionDetail { + #[serde(flatten)] + pub summary: ClawSolutionSummary, + pub starter_prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub theme_target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub followup_mode: Option, + pub capability_tags: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClawSolutionReadinessResult { + pub solution_id: String, + pub readiness: ClawSolutionReadiness, + pub readiness_message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason_code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ClawSolutionContext { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_input: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClawSolutionPreparation { + pub solution_id: String, + pub action_type: ClawSolutionActionType, + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub theme_target: Option, + pub should_launch_browser_assist: bool, + pub should_enable_team_mode: bool, + pub readiness: ClawSolutionReadiness, + pub readiness_message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason_code: Option, +} + +#[derive(Debug, Clone, Copy)] +struct ClawSolutionDefinition { + id: &'static str, + title: &'static str, + summary: &'static str, + output_hint: &'static str, + starter_prompt: &'static str, + theme_target: Option<&'static str>, + followup_mode: Option<&'static str>, + capability_tags: &'static [&'static str], + recommended_capabilities: &'static [&'static str], + action_type: ClawSolutionActionType, + needs_browser_capability: bool, + should_enable_team_mode: bool, +} + +#[derive(Debug, Clone, Copy)] +struct CapabilitySnapshot { + has_available_models: bool, + browser_available: bool, +} + +const CLAW_SOLUTIONS: [ClawSolutionDefinition; 6] = [ + ClawSolutionDefinition { + id: "web-research-brief", + title: "网页研究简报", + summary: "快速整理调研范围、关键信息与结论框架,适合先把研究任务落成一版结构化简报。", + output_hint: "研究提纲 + 结论简报", + starter_prompt: "请围绕这个主题先给我做一版网页研究简报:明确研究目标、关键信息来源、核心发现、风险点,以及接下来最值得继续追踪的问题。", + theme_target: None, + followup_mode: Some("iterative"), + capability_tags: &["research", "brief"], + recommended_capabilities: &["模型", "研究"], + action_type: ClawSolutionActionType::FillInput, + needs_browser_capability: false, + should_enable_team_mode: false, + }, + ClawSolutionDefinition { + id: "social-post-starter", + title: "社媒主稿生成", + summary: "先进入社媒专项工作台,再围绕平台语境、结构和表达生成可继续迭代的首稿。", + output_hint: "社媒首稿 + 平台结构", + starter_prompt: "请先帮我起草一版社媒内容首稿:明确目标受众、平台语境、标题方向、正文结构和可继续扩写的角度。", + theme_target: Some("social-media"), + followup_mode: Some("gui"), + capability_tags: &["social-media", "draft"], + recommended_capabilities: &["模型", "社媒主题"], + action_type: ClawSolutionActionType::NavigateTheme, + needs_browser_capability: false, + should_enable_team_mode: false, + }, + ClawSolutionDefinition { + id: "frontend-concept", + title: "前端概念方案", + summary: "快速产出信息架构、关键模块与页面关系,适合产品概念、后台台架或工作台原型讨论。", + output_hint: "IA + 模块方案", + starter_prompt: "请帮我先整理一版前端概念方案:输出信息架构、核心页面、关键模块、交互流程和第一轮组件拆分建议。", + theme_target: None, + followup_mode: Some("iterative"), + capability_tags: &["frontend", "architecture"], + recommended_capabilities: &["模型", "结构化输出"], + action_type: ClawSolutionActionType::FillInput, + needs_browser_capability: false, + should_enable_team_mode: false, + }, + ClawSolutionDefinition { + id: "slide-outline", + title: "演示提纲草案", + summary: "先拿到一版可讲述的演示结构,覆盖封面、问题、观点、案例与行动建议。", + output_hint: "PPT 大纲 + 讲述线", + starter_prompt: "请基于这个目标先生成一版演示提纲:包含封面定位、目录、核心论点、案例支撑、结论和下一步行动。", + theme_target: None, + followup_mode: Some("iterative"), + capability_tags: &["slides", "outline"], + recommended_capabilities: &["模型", "结构化输出"], + action_type: ClawSolutionActionType::FillInput, + needs_browser_capability: false, + should_enable_team_mode: false, + }, + ClawSolutionDefinition { + id: "browser-assist-task", + title: "浏览器协助办事", + summary: "适合登录、表单、网页操作和信息采集任务,进入工作区后直接接管浏览器协助链路。", + output_hint: "浏览器任务执行", + starter_prompt: "请协助我完成一个浏览器任务:先明确目标网页、目标动作、约束条件和预期结果,再进入执行。", + theme_target: None, + followup_mode: Some("browser_assist"), + capability_tags: &["browser", "automation"], + recommended_capabilities: &["模型", "浏览器协助"], + action_type: ClawSolutionActionType::LaunchBrowserAssist, + needs_browser_capability: true, + should_enable_team_mode: false, + }, + ClawSolutionDefinition { + id: "team-breakdown", + title: "多代理拆任务", + summary: "适合需要并行调研、方案拆解或多角色协作的任务,进入后默认启用 team runtime 偏好。", + output_hint: "任务拆解 + 分工执行", + starter_prompt: "请把这个任务按多代理方式拆解:先定义目标和约束,再拆成并行子任务,明确每个子代理的职责、产出和回收方式。", + theme_target: None, + followup_mode: Some("team_runtime"), + capability_tags: &["team", "decomposition"], + recommended_capabilities: &["模型", "多代理"], + action_type: ClawSolutionActionType::EnableTeamMode, + needs_browser_capability: false, + should_enable_team_mode: true, + }, +]; + +#[derive(Debug, Default)] +pub struct ClawSolutionService; + +impl ClawSolutionService { + pub async fn list(&self, db: &DbConnection) -> Result, String> { + let snapshot = self.build_capability_snapshot(db, true).await?; + + Ok(CLAW_SOLUTIONS + .iter() + .map(|definition| self.to_summary(definition, snapshot)) + .collect()) + } + + pub async fn detail( + &self, + db: &DbConnection, + solution_id: &str, + ) -> Result { + let definition = self.find_definition(solution_id)?; + let snapshot = self + .build_capability_snapshot(db, definition.needs_browser_capability) + .await?; + + Ok(self.to_detail(definition, snapshot)) + } + + pub async fn check_readiness( + &self, + db: &DbConnection, + solution_id: &str, + ) -> Result { + let definition = self.find_definition(solution_id)?; + let snapshot = self + .build_capability_snapshot(db, definition.needs_browser_capability) + .await?; + let readiness = self.resolve_readiness(definition, snapshot); + + Ok(ClawSolutionReadinessResult { + solution_id: definition.id.to_string(), + readiness: readiness.readiness, + readiness_message: readiness.message, + reason_code: readiness.reason_code, + }) + } + + pub async fn prepare( + &self, + db: &DbConnection, + solution_id: &str, + context: Option, + ) -> Result { + let definition = self.find_definition(solution_id)?; + let snapshot = self + .build_capability_snapshot(db, definition.needs_browser_capability) + .await?; + let readiness = self.resolve_readiness(definition, snapshot); + let context = context.unwrap_or_default(); + + Ok(ClawSolutionPreparation { + solution_id: definition.id.to_string(), + action_type: definition.action_type, + prompt: self.build_prompt(definition, &context), + theme_target: definition.theme_target.map(str::to_string), + should_launch_browser_assist: definition.needs_browser_capability, + should_enable_team_mode: definition.should_enable_team_mode, + readiness: readiness.readiness, + readiness_message: readiness.message, + reason_code: readiness.reason_code, + }) + } + + async fn build_capability_snapshot( + &self, + db: &DbConnection, + needs_browser_capability: bool, + ) -> Result { + let has_available_models = + self.has_current_llm_configuration(db)? || self.has_compat_available_models(db)?; + let browser_available = if needs_browser_capability { + check_playwright_available().await?.available + } else { + true + }; + + Ok(CapabilitySnapshot { + has_available_models, + browser_available, + }) + } + + fn has_current_llm_configuration(&self, db: &DbConnection) -> Result { + let providers = ApiKeyProviderService::new().get_all_providers(db)?; + + Ok(providers + .into_iter() + .any(|item| item.provider.enabled && item.api_keys.iter().any(|key| key.enabled))) + } + + fn has_compat_available_models(&self, db: &DbConnection) -> Result { + Ok(!ModelService::new().get_all_available_models(db)?.is_empty()) + } + + fn find_definition( + &self, + solution_id: &str, + ) -> Result<&'static ClawSolutionDefinition, String> { + CLAW_SOLUTIONS + .iter() + .find(|definition| definition.id == solution_id) + .ok_or_else(|| format!("未找到 Claw 方案: {solution_id}")) + } + + fn to_summary( + &self, + definition: &ClawSolutionDefinition, + snapshot: CapabilitySnapshot, + ) -> ClawSolutionSummary { + let readiness = self.resolve_readiness(definition, snapshot); + ClawSolutionSummary { + id: definition.id.to_string(), + title: definition.title.to_string(), + summary: definition.summary.to_string(), + output_hint: definition.output_hint.to_string(), + recommended_capabilities: definition + .recommended_capabilities + .iter() + .map(|item| (*item).to_string()) + .collect(), + readiness: readiness.readiness, + readiness_message: readiness.message, + reason_code: readiness.reason_code, + } + } + + fn to_detail( + &self, + definition: &ClawSolutionDefinition, + snapshot: CapabilitySnapshot, + ) -> ClawSolutionDetail { + ClawSolutionDetail { + summary: self.to_summary(definition, snapshot), + starter_prompt: definition.starter_prompt.to_string(), + theme_target: definition.theme_target.map(str::to_string), + followup_mode: definition.followup_mode.map(str::to_string), + capability_tags: definition + .capability_tags + .iter() + .map(|item| (*item).to_string()) + .collect(), + } + } + + fn build_prompt( + &self, + definition: &ClawSolutionDefinition, + context: &ClawSolutionContext, + ) -> String { + let user_input = context + .user_input + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + match user_input { + Some(value) => format!("{}\n\n补充上下文:{}", definition.starter_prompt, value), + None => definition.starter_prompt.to_string(), + } + } + + fn resolve_readiness( + &self, + definition: &ClawSolutionDefinition, + snapshot: CapabilitySnapshot, + ) -> ResolvedReadiness { + if !snapshot.has_available_models { + return ResolvedReadiness::new( + ClawSolutionReadiness::NeedsSetup, + "先配置可用模型后再开始", + Some(ClawSolutionReasonCode::MissingModel), + ); + } + + if definition.needs_browser_capability && !snapshot.browser_available { + return ResolvedReadiness::new( + ClawSolutionReadiness::NeedsCapability, + "先安装或连接可用浏览器能力", + Some(ClawSolutionReasonCode::MissingBrowserCapability), + ); + } + + if definition.should_enable_team_mode { + return ResolvedReadiness::new( + ClawSolutionReadiness::Ready, + "可直接开始,进入后会启用多代理偏好", + Some(ClawSolutionReasonCode::TeamRecommended), + ); + } + + ResolvedReadiness::new(ClawSolutionReadiness::Ready, "可直接开始", None) + } +} + +#[derive(Debug, Clone)] +struct ResolvedReadiness { + readiness: ClawSolutionReadiness, + message: String, + reason_code: Option, +} + +impl ResolvedReadiness { + fn new( + readiness: ClawSolutionReadiness, + message: impl Into, + reason_code: Option, + ) -> Self { + Self { + readiness, + message: message.into(), + reason_code, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::schema::create_tables; + use lime_core::database::dao::api_key_provider::ApiProviderType; + use lime_core::database::dao::provider_pool::ProviderPoolDao; + use lime_core::models::provider_pool_model::{ + CredentialData, PoolProviderType, ProviderCredential, + }; + use lime_services::api_key_provider_service::ApiKeyProviderService; + use rusqlite::Connection; + use std::sync::{Arc, Mutex}; + + fn setup_db() -> DbConnection { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("创建数据表失败"); + Arc::new(Mutex::new(conn)) + } + + #[tokio::test] + async fn list_should_mark_ready_when_api_key_provider_is_configured() { + let db = setup_db(); + let api_key_service = ApiKeyProviderService::new(); + let provider = api_key_service + .add_custom_provider( + &db, + "测试 Anthropic 兼容 Provider".to_string(), + ApiProviderType::AnthropicCompatible, + "https://example.com/v1".to_string(), + None, + None, + None, + None, + ) + .expect("创建自定义 Provider 失败"); + + api_key_service + .update_provider( + &db, + &provider.id, + None, + None, + None, + Some(true), + None, + None, + None, + None, + None, + Some(vec!["glm-4.7".to_string()]), + ) + .expect("更新 Provider 失败"); + + api_key_service + .add_api_key(&db, &provider.id, "sk-test", Some("主 Key".to_string())) + .expect("添加 API Key 失败"); + + let summaries = ClawSolutionService::default() + .list(&db) + .await + .expect("获取 Claw 方案列表失败"); + + assert!( + summaries + .iter() + .all(|summary| summary.readiness == ClawSolutionReadiness::Ready), + "当前 API Key Provider 已配置时,Claw 不应继续提示先配置模型" + ); + } + + #[tokio::test] + async fn list_should_keep_legacy_provider_pool_as_compat_fallback() { + let db = setup_db(); + let mut credential = ProviderCredential::new( + PoolProviderType::OpenAI, + CredentialData::OpenAIKey { + api_key: "sk-test".to_string(), + base_url: None, + }, + ); + credential.supported_models = vec!["gpt-4o".to_string()]; + + { + let conn = db.lock().expect("锁定数据库失败"); + ProviderPoolDao::insert(&conn, &credential).expect("插入旧 provider_pool 凭证失败"); + } + + let summaries = ClawSolutionService::default() + .list(&db) + .await + .expect("获取 Claw 方案列表失败"); + + assert!( + summaries + .iter() + .all(|summary| summary.readiness == ClawSolutionReadiness::Ready), + "compat 口径下的 provider_pool 可用模型仍应保持可启动" + ); + } +} diff --git a/src-tauri/src/services/memory_profile_prompt_service.rs b/src-tauri/src/services/memory_profile_prompt_service.rs index 3bac7133b..ccab63f2c 100644 --- a/src-tauri/src/services/memory_profile_prompt_service.rs +++ b/src-tauri/src/services/memory_profile_prompt_service.rs @@ -3,10 +3,11 @@ //! 将设置页中的记忆画像与配置化记忆来源统一装配为可注入到 system prompt //! 的单一记忆指令片段,避免调用方继续各自决定拼装顺序。 +use lime_agent::RUNTIME_AGENTS_PROMPT_MARKER; use lime_core::config::Config; use std::path::Path; -use crate::services::memory_source_resolver_service::build_memory_sources_prompt; +use crate::services::memory_source_resolver_service::build_memory_sources_prompt_with_options; const MEMORY_PROFILE_PROMPT_MARKER: &str = "【用户记忆画像偏好】"; const MEMORY_SOURCE_PROMPT_MARKER: &str = "【记忆来源补充指令】"; @@ -106,13 +107,20 @@ fn build_memory_profile_prompt(config: &Config) -> Option { fn build_memory_sources_prompt_for_context( config: &Config, context: MemoryPromptContext<'_>, + skip_runtime_agents_overlap: bool, ) -> Option { let working_dir = context.working_dir?; if !config.memory.enabled { return None; } - build_memory_sources_prompt(config, working_dir, context.active_relative_path, 4000) + build_memory_sources_prompt_with_options( + config, + working_dir, + context.active_relative_path, + 4000, + skip_runtime_agents_overlap, + ) } fn merge_prompt_section( @@ -145,7 +153,7 @@ pub fn build_memory_prompt(config: &Config, context: MemoryPromptContext<'_>) -> merge_prompt_section( with_profile, - build_memory_sources_prompt_for_context(config, context), + build_memory_sources_prompt_for_context(config, context, false), MEMORY_SOURCE_PROMPT_MARKER, ) } @@ -159,6 +167,9 @@ pub fn merge_system_prompt_with_memory_context( config: &Config, context: MemoryPromptContext<'_>, ) -> Option { + let skip_runtime_agents_overlap = base_prompt + .as_deref() + .is_some_and(|prompt| prompt.contains(RUNTIME_AGENTS_PROMPT_MARKER)); let with_profile = merge_prompt_section( base_prompt, build_memory_profile_prompt(config), @@ -167,7 +178,7 @@ pub fn merge_system_prompt_with_memory_context( merge_prompt_section( with_profile, - build_memory_sources_prompt_for_context(config, context), + build_memory_sources_prompt_for_context(config, context, skip_runtime_agents_overlap), MEMORY_SOURCE_PROMPT_MARKER, ) } @@ -175,6 +186,7 @@ pub fn merge_system_prompt_with_memory_context( #[cfg(test)] mod tests { use super::*; + use lime_agent::RUNTIME_AGENTS_PROMPT_MARKER; use lime_core::config::Config; use std::fs; use tempfile::TempDir; @@ -237,15 +249,19 @@ mod tests { #[test] fn should_merge_memory_sources_without_profile_data() { let tmp = TempDir::new().expect("create temp dir"); - fs::write(tmp.path().join("AGENTS.md"), "# 项目记忆\n- 偏好简洁输出") - .expect("write memory file"); + fs::create_dir_all(tmp.path().join(".lime")).expect("create .lime dir"); + fs::write( + tmp.path().join(".lime/AGENTS.md"), + "# 项目记忆\n- 偏好简洁输出", + ) + .expect("write memory file"); let mut config = Config::default(); config.memory.enabled = true; config.memory.profile = Some(Default::default()); config.memory.sources.managed_policy_path = Some("missing-managed.md".to_string()); config.memory.sources.user_memory_path = Some("missing-user.md".to_string()); - config.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()]; + config.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()]; config.memory.sources.project_rule_dirs = Vec::new(); let merged = merge_system_prompt_with_memory_context( @@ -262,7 +278,8 @@ mod tests { #[test] fn should_build_combined_memory_prompt() { let tmp = TempDir::new().expect("create temp dir"); - fs::write(tmp.path().join("AGENTS.md"), "# 项目记忆\n- 保持简洁") + fs::create_dir_all(tmp.path().join(".lime")).expect("create .lime dir"); + fs::write(tmp.path().join(".lime/AGENTS.md"), "# 项目记忆\n- 保持简洁") .expect("write memory file"); let mut config = Config::default(); @@ -270,7 +287,7 @@ mod tests { let mut profile = config.memory.profile.clone().unwrap_or_default(); profile.current_status = Some("高级开发者".to_string()); config.memory.profile = Some(profile); - config.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()]; + config.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()]; config.memory.sources.project_rule_dirs = Vec::new(); config.memory.sources.managed_policy_path = Some("missing-managed.md".to_string()); config.memory.sources.user_memory_path = Some("missing-user.md".to_string()); @@ -284,4 +301,40 @@ mod tests { assert!(prompt.contains("【记忆来源补充指令】")); assert!(prompt.contains("保持简洁")); } + + #[test] + fn should_skip_runtime_agent_overlap_sources_but_keep_local_memory() { + let tmp = TempDir::new().expect("create temp dir"); + fs::create_dir_all(tmp.path().join(".lime")).expect("create .lime dir"); + fs::write(tmp.path().join(".lime/AGENTS.md"), "# 项目记忆\n- 保持简洁") + .expect("write workspace agents"); + fs::write( + tmp.path().join(".lime/AGENTS.local.md"), + "# 本机补充\n- 优先使用当前机器已安装工具", + ) + .expect("write local agents"); + + let mut config = Config::default(); + config.memory.enabled = true; + config.memory.profile = Some(Default::default()); + config.memory.sources.managed_policy_path = Some("missing-managed.md".to_string()); + config.memory.sources.user_memory_path = Some("missing-user.md".to_string()); + config.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()]; + config.memory.sources.project_local_memory_path = Some(".lime/AGENTS.local.md".to_string()); + config.memory.sources.project_rule_dirs = Vec::new(); + + let base = Some(format!( + "{RUNTIME_AGENTS_PROMPT_MARKER}\n### Workspace 运行时指令 (/tmp/workspace/.lime/AGENTS.md)\n# 项目记忆\n- 保持简洁" + )); + let merged = merge_system_prompt_with_memory_context( + base, + &config, + MemoryPromptContext::with_working_dir(tmp.path()), + ) + .expect("should merge prompt"); + + assert_eq!(merged.matches("保持简洁").count(), 1); + assert!(merged.contains("【记忆来源补充指令】")); + assert!(merged.contains("优先使用当前机器已安装工具")); + } } diff --git a/src-tauri/src/services/memory_source_resolver_service.rs b/src-tauri/src/services/memory_source_resolver_service.rs index ec2b3abb9..9613601f2 100644 --- a/src-tauri/src/services/memory_source_resolver_service.rs +++ b/src-tauri/src/services/memory_source_resolver_service.rs @@ -57,6 +57,14 @@ pub struct EffectiveMemorySourcesResponse { pub struct MemorySourceResolution { pub response: EffectiveMemorySourcesResponse, pub prompt_segments: Vec, + pub prompt_sources: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MemoryPromptSegment { + pub title: String, + pub path: String, + pub content: String, } /// 解析有效记忆来源 @@ -73,6 +81,7 @@ pub fn resolve_effective_sources( let mut sources = Vec::new(); let mut prompt_segments = Vec::new(); + let mut prompt_sources = Vec::new(); let mut seen = HashSet::new(); // 1. managed policy @@ -90,6 +99,7 @@ pub fn resolve_effective_sources( &mut seen, &mut sources, &mut prompt_segments, + &mut prompt_sources, ); // 2. user memory @@ -107,6 +117,7 @@ pub fn resolve_effective_sources( &mut seen, &mut sources, &mut prompt_segments, + &mut prompt_sources, ); // 3. cross-thread durable memory (`/memories/...`) @@ -116,16 +127,13 @@ pub fn resolve_effective_sources( &mut seen, &mut sources, &mut prompt_segments, + &mut prompt_sources, ); // 4. project hierarchy memory + rules let ancestors = collect_ancestor_dirs(working_dir); - for ancestor in &ancestors { - for rel in &memory.sources.project_memory_paths { - if rel.trim().is_empty() { - continue; - } - let candidate = ancestor.join(rel); + for rel in &memory.sources.project_memory_paths { + for candidate in resolve_project_relative_candidates(working_dir, &ancestors, rel) { resolve_file_source( "project_memory", &candidate, @@ -134,17 +142,21 @@ pub fn resolve_effective_sources( &mut seen, &mut sources, &mut prompt_segments, + &mut prompt_sources, ); } + } - if let Some(project_local_rel) = memory - .sources - .project_local_memory_path - .as_deref() - .map(str::trim) - .filter(|v| !v.is_empty()) + if let Some(project_local_rel) = memory + .sources + .project_local_memory_path + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + { + for candidate in + resolve_project_relative_candidates(working_dir, &ancestors, project_local_rel) { - let candidate = ancestor.join(project_local_rel); resolve_file_source( "project_local", &candidate, @@ -153,9 +165,12 @@ pub fn resolve_effective_sources( &mut seen, &mut sources, &mut prompt_segments, + &mut prompt_sources, ); } + } + for ancestor in &ancestors { for rel in &memory.sources.project_rule_dirs { if rel.trim().is_empty() { continue; @@ -168,6 +183,7 @@ pub fn resolve_effective_sources( &mut seen, &mut sources, &mut prompt_segments, + &mut prompt_sources, ); } } @@ -189,6 +205,7 @@ pub fn resolve_effective_sources( &mut seen, &mut sources, &mut prompt_segments, + &mut prompt_sources, ); } for rel in &memory.sources.project_rule_dirs { @@ -203,6 +220,7 @@ pub fn resolve_effective_sources( &mut seen, &mut sources, &mut prompt_segments, + &mut prompt_sources, ); } } @@ -214,6 +232,7 @@ pub fn resolve_effective_sources( working_dir, &mut sources, &mut prompt_segments, + &mut prompt_sources, &mut seen, ); @@ -230,6 +249,7 @@ pub fn resolve_effective_sources( MemorySourceResolution { response, prompt_segments, + prompt_sources, } } @@ -239,25 +259,53 @@ pub fn build_memory_sources_prompt( working_dir: &Path, active_relative_path: Option<&str>, max_chars: usize, +) -> Option { + build_memory_sources_prompt_with_options( + config, + working_dir, + active_relative_path, + max_chars, + false, + ) +} + +pub fn build_memory_sources_prompt_with_options( + config: &Config, + working_dir: &Path, + active_relative_path: Option<&str>, + max_chars: usize, + skip_runtime_agents_overlap: bool, ) -> Option { let resolution = resolve_effective_sources(config, working_dir, active_relative_path); - if resolution.prompt_segments.is_empty() { + if resolution.prompt_sources.is_empty() { return None; } let mut output = String::from("【记忆来源补充指令】\n"); output.push_str("以下内容来自配置化记忆来源,请优先遵循:\n"); + let runtime_agent_paths = if skip_runtime_agents_overlap { + runtime_agent_overlap_paths(working_dir) + } else { + HashSet::new() + }; let mut used = 0usize; - for segment in resolution.prompt_segments { - if segment.trim().is_empty() { + for segment in resolution.prompt_sources { + if should_skip_runtime_agent_overlap(&segment, &runtime_agent_paths) { + continue; + } + let rendered = format!( + "### {} ({})\n{}", + segment.title, segment.path, segment.content + ); + if rendered.trim().is_empty() { continue; } if used >= max_chars { break; } let remaining = max_chars.saturating_sub(used); - let clipped = clip_text(&segment, remaining); + let clipped = clip_text(&rendered, remaining); if clipped.trim().is_empty() { continue; } @@ -274,6 +322,23 @@ pub fn build_memory_sources_prompt( } } +fn runtime_agent_overlap_paths(working_dir: &Path) -> HashSet { + let mut paths = HashSet::new(); + paths.insert(normalize_path(&app_paths::best_effort_user_memory_path())); + paths.insert(normalize_path(&working_dir.join(".lime").join("AGENTS.md"))); + paths +} + +fn should_skip_runtime_agent_overlap( + segment: &MemoryPromptSegment, + runtime_agent_paths: &HashSet, +) -> bool { + if runtime_agent_paths.is_empty() { + return false; + } + runtime_agent_paths.contains(&normalize_path(Path::new(&segment.path))) +} + fn resolve_file_source( kind: &str, file_path: &Path, @@ -282,6 +347,7 @@ fn resolve_file_source( seen: &mut HashSet, output: &mut Vec, prompt_segments: &mut Vec, + prompt_sources: &mut Vec, ) { resolve_file_source_with_display_path( kind, @@ -292,6 +358,7 @@ fn resolve_file_source( seen, output, prompt_segments, + prompt_sources, ); } @@ -304,6 +371,7 @@ fn resolve_file_source_with_display_path( seen: &mut HashSet, output: &mut Vec, prompt_segments: &mut Vec, + prompt_sources: &mut Vec, ) { let normalized = normalize_path(file_path); if !seen.insert(normalized.clone()) { @@ -359,6 +427,11 @@ fn resolve_file_source_with_display_path( if loaded { prompt_segments.push(format!("### {} ({})\n{}", kind, display_path, content)); + prompt_sources.push(MemoryPromptSegment { + title: kind.to_string(), + path: display_path, + content, + }); } } Err(err) => { @@ -382,6 +455,7 @@ fn resolve_durable_memory_sources( seen: &mut HashSet, output: &mut Vec, prompt_segments: &mut Vec, + prompt_sources: &mut Vec, ) { let root = match resolve_durable_memory_root() { Ok(path) => path, @@ -454,6 +528,7 @@ fn resolve_durable_memory_sources( seen, output, prompt_segments, + prompt_sources, ); } } @@ -465,6 +540,7 @@ fn resolve_rule_sources( seen: &mut HashSet, output: &mut Vec, prompt_segments: &mut Vec, + prompt_sources: &mut Vec, ) { let normalized = normalize_path(rule_dir); let dir_key = normalized.join("__rules_dir__"); @@ -547,6 +623,11 @@ fn resolve_rule_sources( normalized_rule.display(), rule.content )); + prompt_sources.push(MemoryPromptSegment { + title: format!("规则: {}", rule.title), + path: normalized_rule.to_string_lossy().to_string(), + content: rule.content, + }); } } } @@ -556,6 +637,7 @@ fn resolve_auto_memory_source( working_dir: &Path, output: &mut Vec, prompt_segments: &mut Vec, + prompt_sources: &mut Vec, seen: &mut HashSet, ) { let auto_root = resolve_auto_memory_root(working_dir, &memory_config.auto); @@ -599,6 +681,11 @@ fn resolve_auto_memory_source( entry_path.display(), idx.preview_lines.join("\n") )); + prompt_sources.push(MemoryPromptSegment { + title: "auto_memory".to_string(), + path: entry_path.to_string_lossy().to_string(), + content: idx.preview_lines.join("\n"), + }); } } Err(err) => { @@ -762,7 +849,7 @@ fn expand_path(path: &str, working_dir: Option<&Path>) -> PathBuf { fn default_user_memory_path() -> PathBuf { app_paths::resolve_user_memory_path() - .unwrap_or_else(|_| app_paths::best_effort_app_data_file("AGENTS.md")) + .unwrap_or_else(|_| app_paths::best_effort_user_memory_path()) } fn default_managed_policy_path() -> PathBuf { @@ -786,6 +873,31 @@ fn normalize_path(path: &Path) -> PathBuf { path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) } +fn resolve_project_relative_candidates( + working_dir: &Path, + ancestors: &[PathBuf], + relative_path: &str, +) -> Vec { + let trimmed = relative_path.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + + if is_workspace_local_instruction_path(trimmed) { + return vec![working_dir.join(trimmed)]; + } + + ancestors + .iter() + .map(|ancestor| ancestor.join(trimmed)) + .collect() +} + +fn is_workspace_local_instruction_path(relative_path: &str) -> bool { + let normalized = relative_path.trim_start_matches("./").replace('\\', "/"); + normalized.starts_with(".lime/") +} + fn find_git_root(start: &Path) -> Option { let mut current = if start.is_file() { start.parent()?.to_path_buf() @@ -861,12 +973,13 @@ mod tests { let tmp = TempDir::new().expect("create temp dir"); let root = tmp.path(); fs::create_dir_all(root.join(".agents/rules")).expect("create rules"); - fs::write(root.join("AGENTS.md"), "# 项目记忆\n- use rust").expect("write agents"); + fs::create_dir_all(root.join(".lime")).expect("create .lime dir"); + fs::write(root.join(".lime/AGENTS.md"), "# 项目记忆\n- use rust").expect("write agents"); fs::write(root.join(".agents/rules/general.md"), "# 规则\n- KISS").expect("write rule"); let mut cfg = Config::default(); cfg.memory.enabled = true; - cfg.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()]; + cfg.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()]; cfg.memory.sources.project_rule_dirs = vec![".agents/rules".to_string()]; cfg.memory.resolve.follow_imports = true; cfg.memory.resolve.import_max_depth = 3; @@ -884,11 +997,12 @@ mod tests { let ext = tmp.path().join("extra"); fs::create_dir_all(&root).expect("create main"); fs::create_dir_all(&ext).expect("create extra"); - fs::write(ext.join("AGENTS.md"), "extra memory").expect("write extra agents"); + fs::create_dir_all(ext.join(".lime")).expect("create extra .lime"); + fs::write(ext.join(".lime/AGENTS.md"), "extra memory").expect("write extra agents"); let mut cfg = Config::default(); cfg.memory.enabled = true; - cfg.memory.sources.project_memory_paths = vec!["AGENTS.md".to_string()]; + cfg.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()]; cfg.memory.resolve.load_additional_dirs_memory = true; cfg.memory.resolve.additional_dirs = vec![ext.to_string_lossy().to_string()]; @@ -942,4 +1056,35 @@ mod tests { .iter() .any(|segment| segment.contains("/memories/MEMORY.md"))); } + + #[test] + fn workspace_local_instruction_path_should_not_walk_ancestors() { + let tmp = TempDir::new().expect("create temp dir"); + let project_root = tmp.path().join("repo"); + let nested = project_root.join("workspace"); + fs::create_dir_all(project_root.join(".git")).expect("create git marker"); + fs::create_dir_all(project_root.join(".lime")).expect("create root .lime"); + fs::create_dir_all(nested.join(".lime")).expect("create nested .lime"); + fs::write(project_root.join(".lime/AGENTS.md"), "root agents").expect("write root agents"); + fs::write(nested.join(".lime/AGENTS.md"), "workspace agents") + .expect("write workspace agents"); + + let mut cfg = Config::default(); + cfg.memory.enabled = true; + cfg.memory.sources.project_memory_paths = vec![".lime/AGENTS.md".to_string()]; + + let resolved = resolve_effective_sources(&cfg, &nested, None); + let loaded_sources: Vec<&EffectiveMemorySource> = resolved + .response + .sources + .iter() + .filter(|source| source.kind == "project_memory" && source.loaded) + .collect(); + + assert_eq!(loaded_sources.len(), 1); + assert!(loaded_sources[0].path.ends_with(".lime/AGENTS.md")); + assert_eq!(resolved.prompt_segments.len(), 1); + assert!(resolved.prompt_segments[0].contains("workspace agents")); + assert!(!resolved.prompt_segments[0].contains("root agents")); + } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 0dcc09166..d3845e76e 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -11,6 +11,7 @@ pub mod browser_environment_service; pub mod browser_profile_service; pub mod browser_runtime_window; pub mod chat_history_service; +pub mod claw_solution_service; pub mod conversation_statistics_service; pub mod environment_service; pub mod execution_tracker_service; @@ -21,6 +22,7 @@ pub mod memory_rules_loader_service; pub mod memory_source_resolver_service; pub mod novel_service; pub mod openclaw_service; +pub mod runtime_agents_template_service; pub mod sysinfo_service; pub mod update_check_service; pub mod update_window; diff --git a/src-tauri/src/services/openclaw_service.rs b/src-tauri/src/services/openclaw_service.rs index 430632994..68793b6e0 100644 --- a/src-tauri/src/services/openclaw_service.rs +++ b/src-tauri/src/services/openclaw_service.rs @@ -10,6 +10,7 @@ use lime_core::openclaw_install::{ select_best_semver_candidate as core_select_best_semver_candidate, select_preferred_path_candidate as core_select_preferred_path_candidate, shell_command_escape_for as core_shell_command_escape_for, + shell_command_invocation_prefix_for as core_shell_command_invocation_prefix_for, shell_npm_prefix_assignment_for as core_shell_npm_prefix_assignment_for, shell_path_assignment_for as core_shell_path_assignment_for, windows_manual_install_message as core_windows_manual_install_message, @@ -231,7 +232,11 @@ enum ResolvedOpenClawCommand { } impl ResolvedOpenClawCommand { - fn build_command(&self) -> Command { + fn build_command_with_args(&self, args: I) -> Command + where + I: IntoIterator, + S: AsRef, + { let command_path = self.command_path(); let command_path_string = command_path.to_string_lossy().to_string(); let mut command = Command::new(command_path); @@ -241,6 +246,8 @@ impl ResolvedOpenClawCommand { command.arg(cli_path); } + command.args(args); + command } @@ -1106,10 +1113,9 @@ impl OpenClawService { "info", ); } - let mut command = openclaw_command.build_command(); let start_args = gateway_start_args(self.gateway_port, &self.gateway_auth_token); + let mut command = openclaw_command.build_command_with_args(&start_args); command - .args(&start_args) .env(OPENCLAW_CONFIG_ENV, &config_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -1536,14 +1542,11 @@ impl OpenClawService { ); } - let mut command = openclaw_command.build_command(); + let mut command = openclaw_command.build_command_with_args(["update", "--yes", "--json"]); if let Some(root) = update_context.root.as_ref().filter(|root| root.is_dir()) { command.current_dir(root); } let output = command - .arg("update") - .arg("--yes") - .arg("--json") .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1826,16 +1829,18 @@ impl OpenClawService { ); } - let mut command = command_spec.build_command(); + let stop_args = vec![ + "gateway".to_string(), + "stop".to_string(), + "--url".to_string(), + self.gateway_ws_url(), + "--token".to_string(), + self.gateway_auth_token.clone(), + ]; + let mut command = command_spec.build_command_with_args(&stop_args); let output = timeout( Duration::from_secs(8), command - .arg("gateway") - .arg("stop") - .arg("--url") - .arg(self.gateway_ws_url()) - .arg("--token") - .arg(&self.gateway_auth_token) .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1996,14 +2001,16 @@ impl OpenClawService { return Ok(false); }; - let mut command = command_spec.build_command(); + let status_args = vec![ + "gateway".to_string(), + "status".to_string(), + "--url".to_string(), + self.gateway_ws_url(), + "--token".to_string(), + self.gateway_auth_token.clone(), + ]; + let mut command = command_spec.build_command_with_args(&status_args); let output = command - .arg("gateway") - .arg("status") - .arg("--url") - .arg(self.gateway_ws_url()) - .arg("--token") - .arg(&self.gateway_auth_token) .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -2065,15 +2072,17 @@ impl OpenClawService { return None; }; - let mut command = command_spec.build_command(); + let health_args = vec![ + "gateway".to_string(), + "health".to_string(), + "--url".to_string(), + self.gateway_ws_url(), + "--token".to_string(), + self.gateway_auth_token.clone(), + "--json".to_string(), + ]; + let mut command = command_spec.build_command_with_args(&health_args); let output = command - .arg("gateway") - .arg("health") - .arg("--url") - .arg(self.gateway_ws_url()) - .arg("--token") - .arg(&self.gateway_auth_token) - .arg("--json") .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -3269,11 +3278,8 @@ fn parse_openclaw_release_version(value: &str) -> Option { async fn read_openclaw_update_status_payload( command_spec: &ResolvedOpenClawCommand, ) -> Result { - let mut command = command_spec.build_command(); + let mut command = command_spec.build_command_with_args(["update", "status", "--json"]); let output = command - .arg("update") - .arg("status") - .arg("--json") .env(OPENCLAW_CONFIG_ENV, openclaw_lime_config_path()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -3954,6 +3960,10 @@ fn shell_command_escape_for(platform: ShellPlatform, value: &str) -> String { core_shell_command_escape_for(platform, value) } +fn shell_command_invocation_prefix_for(platform: ShellPlatform, binary_path: &str) -> String { + core_shell_command_invocation_prefix_for(platform, binary_path) +} + #[cfg_attr(target_os = "windows", allow(dead_code))] fn shell_command_escape(value: &str) -> String { shell_command_escape_for(current_shell_platform(), value) @@ -3998,8 +4008,9 @@ fn build_openclaw_pnpm_install_command( registry: Option<&str>, ) -> String { let mut command = format!( - "{}{} add -g {}", + "{}{}{} add -g {}", shell_path_assignment_for(platform, pnpm_path), + shell_command_invocation_prefix_for(platform, pnpm_path), shell_command_escape_for(platform, pnpm_path), shell_command_escape_for(platform, package), ); @@ -4592,36 +4603,78 @@ fn resolve_openclaw_cli_entry_from_package_manifest(manifest_path: &Path) -> Opt candidates.into_iter().find(|path| path.is_file()) } -fn resolve_openclaw_command_from_runtime_candidate( +fn prefers_node_cli_for_openclaw_path(platform: ShellPlatform, openclaw_path: &Path) -> bool { + matches!(platform, ShellPlatform::Windows) + && openclaw_path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "cmd" | "bat")) + .unwrap_or(false) +} + +fn resolve_openclaw_command_from_runtime_candidate_for( + platform: ShellPlatform, candidate: &OpenClawRuntimeCandidate, ) -> Option { - if let Some(openclaw_path) = candidate + let openclaw_path = candidate .openclaw_path .as_deref() .map(PathBuf::from) - .filter(|path| path.is_file()) - { - return Some(ResolvedOpenClawCommand::Binary { - binary_path: openclaw_path, - }); + .filter(|path| path.is_file()); + + if let Some(openclaw_path) = openclaw_path.as_ref() { + if !prefers_node_cli_for_openclaw_path(platform, openclaw_path) { + return Some(ResolvedOpenClawCommand::Binary { + binary_path: openclaw_path.clone(), + }); + } } let node_path = PathBuf::from(candidate.node_path.as_str()); - if !node_path.is_file() { - return None; - } - let manifest_path = candidate .openclaw_package_path .as_deref() - .map(PathBuf::from)?; - let cli_path = resolve_openclaw_cli_entry_from_package_manifest(&manifest_path)?; + .map(PathBuf::from); - Some(ResolvedOpenClawCommand::NodeCli { - node_path, - cli_path, - package_version: read_package_version(&manifest_path), - }) + if node_path.is_file() { + if let Some(manifest_path) = manifest_path { + if let Some(cli_path) = resolve_openclaw_cli_entry_from_package_manifest(&manifest_path) + { + return Some(ResolvedOpenClawCommand::NodeCli { + node_path, + cli_path, + package_version: read_package_version(&manifest_path), + }); + } + } + } + + openclaw_path.map(|binary_path| ResolvedOpenClawCommand::Binary { binary_path }) +} + +fn resolve_openclaw_command_from_runtime_candidate( + candidate: &OpenClawRuntimeCandidate, +) -> Option { + resolve_openclaw_command_from_runtime_candidate_for(current_shell_platform(), candidate) +} + +fn runtime_candidate_matches_openclaw_path( + candidate: &OpenClawRuntimeCandidate, + openclaw_path: &Path, +) -> bool { + candidate + .openclaw_path + .as_deref() + .map(Path::new) + .is_some_and(|candidate_path| candidate_path == openclaw_path) + || openclaw_path + .parent() + .is_some_and(|parent| Path::new(&candidate.bin_dir) == parent) + || candidate + .npm_global_prefix + .as_deref() + .map(Path::new) + .is_some_and(|prefix| openclaw_path.starts_with(prefix)) } fn dedupe_openclaw_commands( @@ -4649,17 +4702,36 @@ fn dedupe_paths(candidates: Vec) -> Vec { } async fn resolve_openclaw_command() -> Result, String> { - if let Some(binary) = find_command_in_shell("openclaw").await? { - return Ok(Some(ResolvedOpenClawCommand::Binary { - binary_path: PathBuf::from(binary), - })); - } - + let shell_platform = current_shell_platform(); + let shell_binary = find_command_in_shell("openclaw").await?.map(PathBuf::from); let mut runtime_candidates = list_openclaw_runtime_candidates().await?; runtime_candidates.sort_by(compare_openclaw_runtime_candidates); - Ok(runtime_candidates - .iter() - .find_map(resolve_openclaw_command_from_runtime_candidate)) + + if let Some(shell_binary) = shell_binary.as_ref() { + if !prefers_node_cli_for_openclaw_path(shell_platform, shell_binary) { + return Ok(Some(ResolvedOpenClawCommand::Binary { + binary_path: shell_binary.clone(), + })); + } + + if let Some(command) = runtime_candidates + .iter() + .filter(|candidate| runtime_candidate_matches_openclaw_path(candidate, shell_binary)) + .find_map(|candidate| { + resolve_openclaw_command_from_runtime_candidate_for(shell_platform, candidate) + }) + { + return Ok(Some(command)); + } + } + + if let Some(command) = runtime_candidates.iter().find_map(|candidate| { + resolve_openclaw_command_from_runtime_candidate_for(shell_platform, candidate) + }) { + return Ok(Some(command)); + } + + Ok(shell_binary.map(|binary_path| ResolvedOpenClawCommand::Binary { binary_path })) } async fn read_openclaw_version_from_command( @@ -4670,8 +4742,7 @@ async fn read_openclaw_version_from_command( } let output = command_spec - .build_command() - .arg("--version") + .build_command_with_args(["--version"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() @@ -5345,16 +5416,19 @@ mod tests { infer_openclaw_package_name_from_path, npm_global_command_dirs_for, npm_global_node_modules_dirs_for, package_registry_for_package_spec, parse_semver_from_text, resolve_openclaw_cli_entry_from_package_manifest, - resolve_openclaw_command_from_runtime_candidate, resolve_windows_dependency_install_plan, - runtime_candidate_matches_install_root, sanitize_runtime_config, select_best_git_candidate, - select_best_semver_candidate, select_gateway_start_failure_detail, - select_openclaw_update_failure_detail, select_preferred_path_candidate, - shell_command_escape_for, shell_npm_prefix_assignment_for, shell_path_assignment_for, - trim_trailing_slash, windows_dependency_action_result, windows_dependency_setup_message, - windows_git_install_dir_variants, windows_install_block_result, - windows_manual_install_message, DependencyKind, DependencyStatus, EnvironmentDiagnostics, - OpenClawRuntimeCandidate, ResolvedOpenClawCommand, ShellPlatform, - WindowsDependencyInstallPlan, NPM_MIRROR_CN, OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE, + resolve_openclaw_command_from_runtime_candidate, + resolve_openclaw_command_from_runtime_candidate_for, + resolve_windows_dependency_install_plan, runtime_candidate_matches_install_root, + sanitize_runtime_config, select_best_git_candidate, select_best_semver_candidate, + select_gateway_start_failure_detail, select_openclaw_update_failure_detail, + select_preferred_path_candidate, shell_command_escape_for, + shell_command_invocation_prefix_for, shell_npm_prefix_assignment_for, + shell_path_assignment_for, trim_trailing_slash, windows_dependency_action_result, + windows_dependency_setup_message, windows_git_install_dir_variants, + windows_install_block_result, windows_manual_install_message, DependencyKind, + DependencyStatus, EnvironmentDiagnostics, OpenClawRuntimeCandidate, + ResolvedOpenClawCommand, ShellPlatform, WindowsDependencyInstallPlan, NPM_MIRROR_CN, + OPENCLAW_CN_PACKAGE, OPENCLAW_DEFAULT_PACKAGE, }; use crate::database::dao::api_key_provider::{ApiKeyProvider, ApiProviderType, ProviderGroup}; use chrono::Utc; @@ -5854,6 +5928,22 @@ mod tests { ); } + #[test] + fn windows_cmd_scripts_use_call_invocation_prefix() { + assert_eq!( + shell_command_invocation_prefix_for( + ShellPlatform::Windows, + r"C:\Program Files\nodejs\npm.cmd" + ), + "call " + ); + assert!(shell_command_invocation_prefix_for( + ShellPlatform::Windows, + r"C:\Users\demo\AppData\Local\Microsoft\WindowsApps\winget.exe" + ) + .is_empty()); + } + #[test] fn windows_cleanup_command_uses_cmd_compatible_syntax_without_true_fallback() { let command = build_openclaw_cleanup_command( @@ -5867,7 +5957,7 @@ mod tests { concat!( "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", - "\"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh" + "call \"C:\\Program Files\\nodejs\\npm.cmd\" uninstall -g openclaw @qingchencloud/openclaw-zh" ) ); assert!(!command.contains("|| true")); @@ -5888,7 +5978,7 @@ mod tests { concat!( "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", "set \"NPM_CONFIG_PREFIX=C:\\Users\\demo\\AppData\\Roaming\\npm\" && ", - "\"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ", + "call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g @qingchencloud/openclaw-zh@latest ", "--registry=https://registry.npmmirror.com" ) ); @@ -5908,7 +5998,7 @@ mod tests { command, concat!( "set \"PATH=C:\\Program Files\\nodejs;%PATH%\" && ", - "\"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest" + "call \"C:\\Program Files\\nodejs\\npm.cmd\" install -g openclaw@latest" ) ); assert!(!command.contains("--registry=")); @@ -5927,7 +6017,7 @@ mod tests { command, concat!( "set \"PATH=C:\\Users\\demo\\AppData\\Local\\pnpm;%PATH%\" && ", - "\"C:\\Users\\demo\\AppData\\Local\\pnpm\\pnpm.cmd\" add -g \"@qingchencloud/openclaw-zh@latest\" ", + "call \"C:\\Users\\demo\\AppData\\Local\\pnpm\\pnpm.cmd\" add -g \"@qingchencloud/openclaw-zh@latest\" ", "--registry=\"https://registry.npmmirror.com\"" ) ); @@ -6176,6 +6266,63 @@ mod tests { ); } + #[test] + fn windows_runtime_candidate_prefers_node_cli_over_cmd_shim() { + let temp_dir = build_unique_temp_dir("runtime-candidate-windows-shim"); + let node_bin_dir = temp_dir.join("nodejs"); + let package_dir = temp_dir + .join("node_modules") + .join("@qingchencloud") + .join("openclaw-zh"); + let dist_dir = package_dir.join("dist"); + fs::create_dir_all(&node_bin_dir).unwrap(); + fs::create_dir_all(&dist_dir).unwrap(); + + let node_path = node_bin_dir.join("node.exe"); + let openclaw_cmd = node_bin_dir.join("openclaw.cmd"); + fs::write(&node_path, "").unwrap(); + fs::write(&openclaw_cmd, "@echo off").unwrap(); + fs::write( + package_dir.join("package.json"), + r#"{ + "name":"@qingchencloud/openclaw-zh", + "version":"2026.3.13-zh.1", + "bin":{"openclaw":"openclaw.mjs"} + }"#, + ) + .unwrap(); + fs::write(dist_dir.join("index.js"), "console.log('openclaw');").unwrap(); + + let candidate = OpenClawRuntimeCandidate { + id: temp_dir.display().to_string(), + source: "system".to_string(), + bin_dir: node_bin_dir.display().to_string(), + node_path: node_path.display().to_string(), + node_version: Some("23.4.0".to_string()), + npm_path: Some(node_bin_dir.join("npm.cmd").display().to_string()), + npm_global_prefix: Some(temp_dir.display().to_string()), + openclaw_path: Some(openclaw_cmd.display().to_string()), + openclaw_version: Some("2026.3.13-zh.1".to_string()), + openclaw_package_path: Some(package_dir.join("package.json").display().to_string()), + is_active: true, + is_preferred: true, + }; + + let resolved = + resolve_openclaw_command_from_runtime_candidate_for(ShellPlatform::Windows, &candidate); + + let _ = fs::remove_dir_all(&temp_dir); + + assert_eq!( + resolved, + Some(ResolvedOpenClawCommand::NodeCli { + node_path, + cli_path: package_dir.join("dist").join("index.js"), + package_version: Some("2026.3.13-zh.1".to_string()), + }) + ); + } + #[test] fn windows_node_prefers_winget_when_available() { assert_eq!( diff --git a/src-tauri/src/services/runtime_agents_template_service.rs b/src-tauri/src/services/runtime_agents_template_service.rs new file mode 100644 index 000000000..81d6869a8 --- /dev/null +++ b/src-tauri/src/services/runtime_agents_template_service.rs @@ -0,0 +1,339 @@ +//! 运行时 AGENTS 模板生成服务 +//! +//! 为 Lime 应用运行时会话显式生成 `.lime/AGENTS.md` 模板。 + +use lime_core::app_paths; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeAgentsTemplateTarget { + Global, + Workspace, + WorkspaceLocal, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeAgentsTemplateScaffoldStatus { + Created, + Exists, + Overwritten, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceGitignoreEnsureStatus { + Created, + Added, + Exists, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeAgentsTemplateScaffoldResult { + pub target: RuntimeAgentsTemplateTarget, + pub path: String, + pub status: RuntimeAgentsTemplateScaffoldStatus, + pub created_parent_dir: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceGitignoreEnsureResult { + pub path: String, + pub entry: String, + pub status: WorkspaceGitignoreEnsureStatus, +} + +pub fn scaffold_runtime_agents_template( + target: RuntimeAgentsTemplateTarget, + working_dir: Option<&Path>, + overwrite: bool, +) -> Result { + let path = resolve_runtime_agents_template_path(target, working_dir)?; + scaffold_runtime_agents_template_at_path(target, &path, overwrite) +} + +pub fn ensure_workspace_local_agents_gitignore( + working_dir: &Path, +) -> Result { + let gitignore_path = working_dir.join(".gitignore"); + ensure_gitignore_entry(&gitignore_path, ".lime/AGENTS.local.md") +} + +fn resolve_runtime_agents_template_path( + target: RuntimeAgentsTemplateTarget, + working_dir: Option<&Path>, +) -> Result { + match target { + RuntimeAgentsTemplateTarget::Global => Ok(app_paths::best_effort_user_memory_path()), + RuntimeAgentsTemplateTarget::Workspace => { + let working_dir = + working_dir.ok_or_else(|| "生成 Workspace 模板时缺少 working_dir".to_string())?; + Ok(working_dir.join(".lime").join("AGENTS.md")) + } + RuntimeAgentsTemplateTarget::WorkspaceLocal => { + let working_dir = working_dir + .ok_or_else(|| "生成 Workspace 本机模板时缺少 working_dir".to_string())?; + Ok(working_dir.join(".lime").join("AGENTS.local.md")) + } + } +} + +fn scaffold_runtime_agents_template_at_path( + target: RuntimeAgentsTemplateTarget, + path: &Path, + overwrite: bool, +) -> Result { + let parent = path + .parent() + .ok_or_else(|| format!("无法解析模板目录: {}", path.display()))?; + let created_parent_dir = !parent.exists(); + fs::create_dir_all(parent) + .map_err(|e| format!("创建模板目录失败 {}: {e}", parent.display()))?; + + if path.exists() && path.is_dir() { + return Err(format!("模板路径指向目录而不是文件: {}", path.display())); + } + + let existed = path.exists(); + if existed && !overwrite { + return Ok(RuntimeAgentsTemplateScaffoldResult { + target, + path: path.to_string_lossy().to_string(), + status: RuntimeAgentsTemplateScaffoldStatus::Exists, + created_parent_dir, + }); + } + + fs::write(path, template_content_for_target(target)) + .map_err(|e| format!("写入模板失败 {}: {e}", path.display()))?; + + Ok(RuntimeAgentsTemplateScaffoldResult { + target, + path: path.to_string_lossy().to_string(), + status: if existed { + RuntimeAgentsTemplateScaffoldStatus::Overwritten + } else { + RuntimeAgentsTemplateScaffoldStatus::Created + }, + created_parent_dir, + }) +} + +fn ensure_gitignore_entry( + gitignore_path: &Path, + entry: &str, +) -> Result { + if gitignore_path.exists() && gitignore_path.is_dir() { + return Err(format!( + ".gitignore 路径指向目录而不是文件: {}", + gitignore_path.display() + )); + } + + let entry = entry.trim(); + if entry.is_empty() { + return Err("gitignore 条目不能为空".to_string()); + } + + if !gitignore_path.exists() { + fs::write(gitignore_path, format!("{entry}\n")) + .map_err(|e| format!("创建 .gitignore 失败 {}: {e}", gitignore_path.display()))?; + return Ok(WorkspaceGitignoreEnsureResult { + path: gitignore_path.to_string_lossy().to_string(), + entry: entry.to_string(), + status: WorkspaceGitignoreEnsureStatus::Created, + }); + } + + let existing = fs::read_to_string(gitignore_path) + .map_err(|e| format!("读取 .gitignore 失败 {}: {e}", gitignore_path.display()))?; + if existing.lines().any(|line| line.trim() == entry) { + return Ok(WorkspaceGitignoreEnsureResult { + path: gitignore_path.to_string_lossy().to_string(), + entry: entry.to_string(), + status: WorkspaceGitignoreEnsureStatus::Exists, + }); + } + + let mut next = existing; + if !next.is_empty() && !next.ends_with('\n') { + next.push('\n'); + } + next.push_str(entry); + next.push('\n'); + fs::write(gitignore_path, next) + .map_err(|e| format!("更新 .gitignore 失败 {}: {e}", gitignore_path.display()))?; + + Ok(WorkspaceGitignoreEnsureResult { + path: gitignore_path.to_string_lossy().to_string(), + entry: entry.to_string(), + status: WorkspaceGitignoreEnsureStatus::Added, + }) +} + +fn template_content_for_target(target: RuntimeAgentsTemplateTarget) -> &'static str { + match target { + RuntimeAgentsTemplateTarget::Global => GLOBAL_RUNTIME_AGENTS_TEMPLATE, + RuntimeAgentsTemplateTarget::Workspace => WORKSPACE_RUNTIME_AGENTS_TEMPLATE, + RuntimeAgentsTemplateTarget::WorkspaceLocal => WORKSPACE_LOCAL_RUNTIME_AGENTS_TEMPLATE, + } +} + +const GLOBAL_RUNTIME_AGENTS_TEMPLATE: &str = r#"# 我的全局 Lime 运行时规则 + +## 回复习惯 + +- 默认使用中文简体 +- 先给结论,再给关键步骤 +- 没必要时保持简洁,不要过度展开 + +## 工程偏好 + +- 优先选择 KISS 方案 +- 优先修根因,不做表面补丁 +- 先说明影响范围,再做改动 + +## 代码风格 + +- 尽量沿用现有项目风格 +- 避免无关重构 +- 没有明确收益时,不新增抽象层 +"#; + +const WORKSPACE_RUNTIME_AGENTS_TEMPLATE: &str = r#"# 当前工作区运行时规则 + +## 项目背景 + +- 这里填写当前项目的技术栈与上下文 +- 这里填写默认输出语言或文档语言 + +## 修改原则 + +- 先读后写 +- 只改当前任务直接相关内容 +- 保持现有目录结构和命名习惯 + +## 验证要求 + +- 前端改动后优先跑相关前端测试 +- Rust 改动后优先跑相关单测 +- 若无法完整验证,需要明确说明未验证部分 + +## 禁止事项 + +- 不要提交临时排障脚本 +- 不要修改无关配置 +- 不要默认执行 git commit 或 push +"#; + +const WORKSPACE_LOCAL_RUNTIME_AGENTS_TEMPLATE: &str = r#"# 本机私有补充 + +## 本机偏好 + +- 优先使用本机已安装的工具链 +- 涉及大体量编译时,先跑定向测试 + +## 私有约束 + +- 这里填写只在当前机器生效的补充规则 +- 如不希望提交到仓库,请将本文件加入 .gitignore +"#; + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn should_create_workspace_template_file() { + let tmp = TempDir::new().expect("create temp dir"); + let path = tmp.path().join(".lime").join("AGENTS.md"); + + let result = scaffold_runtime_agents_template_at_path( + RuntimeAgentsTemplateTarget::Workspace, + &path, + false, + ) + .expect("scaffold should succeed"); + + assert_eq!(result.status, RuntimeAgentsTemplateScaffoldStatus::Created); + assert!(path.exists()); + let content = fs::read_to_string(&path).expect("read template"); + assert!(content.contains("当前工作区运行时规则")); + } + + #[test] + fn should_not_overwrite_existing_file_by_default() { + let tmp = TempDir::new().expect("create temp dir"); + let path = tmp.path().join(".lime").join("AGENTS.md"); + fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + fs::write(&path, "custom content").expect("write custom"); + + let result = scaffold_runtime_agents_template_at_path( + RuntimeAgentsTemplateTarget::Workspace, + &path, + false, + ) + .expect("scaffold should succeed"); + + assert_eq!(result.status, RuntimeAgentsTemplateScaffoldStatus::Exists); + let content = fs::read_to_string(&path).expect("read file"); + assert_eq!(content, "custom content"); + } + + #[test] + fn should_overwrite_existing_file_when_requested() { + let tmp = TempDir::new().expect("create temp dir"); + let path = tmp.path().join(".lime").join("AGENTS.local.md"); + fs::create_dir_all(path.parent().expect("parent")).expect("create parent"); + fs::write(&path, "custom content").expect("write custom"); + + let result = scaffold_runtime_agents_template_at_path( + RuntimeAgentsTemplateTarget::WorkspaceLocal, + &path, + true, + ) + .expect("scaffold should succeed"); + + assert_eq!( + result.status, + RuntimeAgentsTemplateScaffoldStatus::Overwritten + ); + let content = fs::read_to_string(&path).expect("read file"); + assert!(content.contains("本机私有补充")); + } + + #[test] + fn should_create_gitignore_when_missing() { + let tmp = TempDir::new().expect("create temp dir"); + + let result = ensure_workspace_local_agents_gitignore(tmp.path()) + .expect("ensure gitignore should succeed"); + + assert_eq!(result.status, WorkspaceGitignoreEnsureStatus::Created); + let content = fs::read_to_string(tmp.path().join(".gitignore")).expect("read gitignore"); + assert_eq!(content, ".lime/AGENTS.local.md\n"); + } + + #[test] + fn should_append_gitignore_entry_once() { + let tmp = TempDir::new().expect("create temp dir"); + let gitignore_path = tmp.path().join(".gitignore"); + fs::write(&gitignore_path, "node_modules\n").expect("write gitignore"); + + let first = ensure_workspace_local_agents_gitignore(tmp.path()) + .expect("first ensure should succeed"); + let second = ensure_workspace_local_agents_gitignore(tmp.path()) + .expect("second ensure should succeed"); + + assert_eq!(first.status, WorkspaceGitignoreEnsureStatus::Added); + assert_eq!(second.status, WorkspaceGitignoreEnsureStatus::Exists); + let content = fs::read_to_string(gitignore_path).expect("read gitignore"); + assert_eq!(content.matches(".lime/AGENTS.local.md").count(), 1); + } +} diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 1fca7746b..e7ac35ea9 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.92.0", + "version": "0.93.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", @@ -31,6 +31,7 @@ }, "bundle": { "active": true, + "createUpdaterArtifacts": true, "targets": "all", "icon": [ "icons/32x32.png", @@ -56,6 +57,9 @@ "shell": { "open": true }, + "updater": { + "pubkey": "lime-dev-placeholder" + }, "deep-link": { "desktop": { "schemes": [ diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9e4bffcec..b1c2c8042 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.92.0", + "version": "0.93.0", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", @@ -48,6 +48,7 @@ }, "bundle": { "active": true, + "createUpdaterArtifacts": true, "targets": "all", "icon": [ "icons/32x32.png", @@ -75,6 +76,9 @@ "shell": { "open": true }, + "updater": { + "pubkey": "lime-dev-placeholder" + }, "deep-link": { "desktop": { "schemes": ["lime"] diff --git a/src/components/agent/chat/AgentChatHomeShell.test.tsx b/src/components/agent/chat/AgentChatHomeShell.test.tsx index 8bb3d4c29..97d942fdd 100644 --- a/src/components/agent/chat/AgentChatHomeShell.test.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.test.tsx @@ -3,19 +3,99 @@ 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"; +import { SettingsTabs } from "@/types/settings"; -const { mockBuildClawAgentParams, mockSaveChatToolPreferences } = vi.hoisted(() => ({ - mockBuildClawAgentParams: vi.fn((overrides?: Record) => ({ - agentEntry: "claw", - ...(overrides || {}), - })), - mockSaveChatToolPreferences: vi.fn(), -})); +const { + mockBuildClawAgentParams, + mockHomeShellExecutionStrategy, + mockHomeShellModel, + mockHomeShellProviderType, + mockSetExecutionStrategy, + mockSetModel, + mockSetProviderType, + mockLoadConfiguredProviders, + mockLoadProviderModels, + mockFilterModelsByTheme, + mockSaveChatToolPreferences, + mockPrepareClawSolution, + mockUseClawSolutions, + mockRecordClawSolutionUsage, + mockClawSolutions, +} = vi.hoisted(() => { + const mockClawSolutions = [ + { + id: "social-post-starter", + title: "社媒主稿生成", + summary: "进入社媒专项工作台并生成一版首稿。", + outputHint: "社媒首稿 + 平台结构", + recommendedCapabilities: ["模型", "社媒主题"], + readiness: "ready", + readinessMessage: "可直接开始", + badge: "社媒方案", + recentUsedAt: null, + isRecent: false, + readinessLabel: "可直接开始", + readinessTone: "emerald", + }, + { + id: "team-breakdown", + title: "多代理拆任务", + summary: "默认启用多代理偏好,按 team runtime 方式展开任务。", + outputHint: "任务拆解 + 分工执行", + recommendedCapabilities: ["模型", "多代理"], + readiness: "ready", + readinessMessage: "可直接开始,进入后会启用多代理偏好", + reasonCode: "team_recommended", + badge: "多代理", + recentUsedAt: null, + isRecent: false, + readinessLabel: "可直接开始", + readinessTone: "emerald", + }, + ]; + + const mockRecordClawSolutionUsage = vi.fn(); + + return { + mockBuildClawAgentParams: vi.fn((overrides?: Record) => ({ + agentEntry: "claw", + ...(overrides || {}), + })), + mockHomeShellProviderType: { current: "mock-provider" }, + mockHomeShellModel: { current: "mock-model" }, + mockHomeShellExecutionStrategy: { current: "react" }, + mockSetProviderType: vi.fn(), + mockSetModel: vi.fn(), + mockSetExecutionStrategy: vi.fn(), + mockLoadConfiguredProviders: vi.fn(async () => []), + mockLoadProviderModels: vi.fn(async () => []), + mockFilterModelsByTheme: vi.fn( + (_theme: string | undefined, models: unknown[]) => ({ + models, + usedFallback: false, + filteredOutCount: 0, + policyName: "mock", + }), + ), + mockSaveChatToolPreferences: vi.fn(), + mockPrepareClawSolution: vi.fn(), + mockUseClawSolutions: vi.fn(() => ({ + solutions: mockClawSolutions, + isLoading: false, + error: null, + refresh: vi.fn(), + recordUsage: mockRecordClawSolutionUsage, + })), + mockRecordClawSolutionUsage, + mockClawSolutions, + }; +}); vi.mock("./components/EmptyState", () => ({ EmptyState: ({ onSend, onRecommendationClick, + supportingSlotOverride, }: { onSend: ( value: string, @@ -23,6 +103,7 @@ vi.mock("./components/EmptyState", () => ({ images?: Array<{ data: string; mediaType: string }>, ) => void; onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; + supportingSlotOverride?: React.ReactNode; }) => ( <> + {supportingSlotOverride} ), })); @@ -72,6 +154,10 @@ vi.mock("./hooks/agentChatStorage", () => ({ })), loadPersisted: vi.fn((_key: string, fallback: unknown) => fallback), loadPersistedString: vi.fn(() => ""), + resolveWorkspaceAgentPreferences: vi.fn(() => ({ + providerType: "mock-provider", + model: "mock-model", + })), savePersisted: vi.fn(), })); @@ -79,6 +165,17 @@ vi.mock("./hooks/agentChatCoreUtils", () => ({ normalizeExecutionStrategy: vi.fn((value: string) => value || "react"), })); +vi.mock("./hooks/useHomeShellAgentPreferences", () => ({ + useHomeShellAgentPreferences: vi.fn(() => ({ + providerType: mockHomeShellProviderType.current, + setProviderType: mockSetProviderType, + model: mockHomeShellModel.current, + setModel: mockSetModel, + executionStrategy: mockHomeShellExecutionStrategy.current, + setExecutionStrategy: mockSetExecutionStrategy, + })), +})); + vi.mock("./utils/chatToolPreferences", () => ({ loadChatToolPreferences: vi.fn(() => ({ webSearch: false, @@ -93,6 +190,49 @@ vi.mock("@/lib/workspace/navigation", () => ({ buildClawAgentParams: mockBuildClawAgentParams, })); +vi.mock("@/lib/api/clawSolutions", () => ({ + prepareClawSolution: mockPrepareClawSolution, +})); + +vi.mock("@/hooks/useConfiguredProviders", () => ({ + loadConfiguredProviders: mockLoadConfiguredProviders, +})); + +vi.mock("@/hooks/useProviderModels", () => ({ + loadProviderModels: mockLoadProviderModels, +})); + +vi.mock("./utils/modelThemePolicy", () => ({ + filterModelsByTheme: mockFilterModelsByTheme, +})); + +vi.mock("./claw-solutions/useClawSolutions", () => ({ + useClawSolutions: mockUseClawSolutions, +})); + +vi.mock("./claw-solutions/ClawHomeSolutionsPanel", () => ({ + ClawHomeSolutionsPanel: ({ + solutions, + onSelect, + }: { + solutions: Array<{ id: string; title: string }>; + onSelect: (solution: { id: string; title: string }) => void; + }) => ( + <> + {solutions.map((solution) => ( + + ))} + + ), +})); + const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; beforeEach(() => { @@ -101,6 +241,26 @@ beforeEach(() => { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; + mockHomeShellProviderType.current = "mock-provider"; + mockHomeShellModel.current = "mock-model"; + mockHomeShellExecutionStrategy.current = "react"; + mockUseClawSolutions.mockImplementation(() => ({ + solutions: mockClawSolutions, + isLoading: false, + error: null, + refresh: vi.fn(), + recordUsage: mockRecordClawSolutionUsage, + })); + mockLoadConfiguredProviders.mockResolvedValue([]); + mockLoadProviderModels.mockResolvedValue([]); + mockFilterModelsByTheme.mockImplementation( + (_theme: string | undefined, models: unknown[]) => ({ + models, + usedFallback: false, + filteredOutCount: 0, + policyName: "mock", + }), + ); }); afterEach(() => { @@ -241,4 +401,304 @@ describe("AgentChatHomeShell", () => { }), ); }); + + it("点击社媒方案时应切换到 social-media 工作区", async () => { + const onNavigate = vi.fn(); + mockLoadConfiguredProviders.mockResolvedValueOnce([ + { + key: "custom-social-provider", + label: "Custom Social Provider", + registryId: "custom-social-provider", + fallbackRegistryId: "openai", + type: "openai", + }, + ]); + mockLoadProviderModels.mockResolvedValueOnce([ + { + id: "social-model-1", + display_name: "Social Model 1", + provider_id: "custom-social-provider", + provider_name: "Custom Social Provider", + family: null, + tier: "pro", + capabilities: { + vision: false, + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning: false, + }, + pricing: null, + limits: { + context_length: null, + max_output_tokens: null, + requests_per_minute: null, + tokens_per_minute: null, + }, + status: "active", + release_date: null, + is_latest: true, + description: "social", + source: "custom", + created_at: 0, + updated_at: 0, + }, + ]); + mockPrepareClawSolution.mockResolvedValueOnce({ + solutionId: "social-post-starter", + actionType: "navigate_theme", + prompt: "请先帮我起草一版社媒内容首稿", + themeTarget: "social-media", + shouldLaunchBrowserAssist: false, + shouldEnableTeamMode: false, + readiness: "ready", + readinessMessage: "可直接开始", + }); + + const { container } = renderShell({ + onNavigate, + }); + + await flushEffects(); + + const socialSolutionButton = container.querySelector( + '[data-testid="home-shell-solution-social-post-starter"]', + ) as HTMLButtonElement | null; + + expect(socialSolutionButton).toBeTruthy(); + + act(() => { + socialSolutionButton?.click(); + }); + + await flushEffects(); + + expect(mockSetProviderType).toHaveBeenCalledWith("custom-social-provider"); + expect(mockSetModel).toHaveBeenCalledWith("social-model-1"); + expect(mockPrepareClawSolution).toHaveBeenCalledWith( + "social-post-starter", + { + projectId: "project-1", + userInput: undefined, + }, + ); + expect(mockBuildClawAgentParams).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + theme: "social-media", + initialUserPrompt: "请先帮我起草一版社媒内容首稿", + }), + ); + expect(onNavigate).toHaveBeenCalledWith( + "agent", + expect.objectContaining({ + agentEntry: "claw", + theme: "social-media", + initialUserPrompt: "请先帮我起草一版社媒内容首稿", + }), + ); + expect(mockSetProviderType.mock.invocationCallOrder[0]).toBeLessThan( + onNavigate.mock.invocationCallOrder[0], + ); + expect(mockSetModel.mock.invocationCallOrder[0]).toBeLessThan( + onNavigate.mock.invocationCallOrder[0], + ); + expect(mockRecordClawSolutionUsage).toHaveBeenCalledWith({ + solutionId: "social-post-starter", + actionType: "navigate_theme", + themeTarget: "social-media", + }); + }); + + it("当前 provider 已可用时应保留 custom provider id 并仅切换模型", async () => { + const onNavigate = vi.fn(); + mockHomeShellProviderType.current = "custom-social-provider"; + mockHomeShellModel.current = "legacy-model"; + mockLoadConfiguredProviders.mockResolvedValueOnce([ + { + key: "custom-social-provider", + label: "Custom Social Provider", + registryId: "custom-social-provider", + fallbackRegistryId: "openai", + type: "openai", + }, + { + key: "other-provider", + label: "Other Provider", + registryId: "other-provider", + type: "openai", + }, + ]); + mockLoadProviderModels.mockResolvedValueOnce([ + { + id: "custom-social-model", + display_name: "Custom Social Model", + provider_id: "custom-social-provider", + provider_name: "Custom Social Provider", + family: null, + tier: "pro", + capabilities: { + vision: false, + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning: false, + }, + pricing: null, + limits: { + context_length: null, + max_output_tokens: null, + requests_per_minute: null, + tokens_per_minute: null, + }, + status: "active", + release_date: null, + is_latest: true, + description: "custom-social-model", + source: "custom", + created_at: 0, + updated_at: 0, + }, + ]); + mockPrepareClawSolution.mockResolvedValueOnce({ + solutionId: "social-post-starter", + actionType: "navigate_theme", + prompt: "请先帮我起草一版社媒内容首稿", + themeTarget: "social-media", + shouldLaunchBrowserAssist: false, + shouldEnableTeamMode: false, + readiness: "ready", + readinessMessage: "可直接开始", + }); + + const { container } = renderShell({ + onNavigate, + }); + + await flushEffects(); + + const socialSolutionButton = container.querySelector( + '[data-testid="home-shell-solution-social-post-starter"]', + ) as HTMLButtonElement | null; + + expect(socialSolutionButton).toBeTruthy(); + + act(() => { + socialSolutionButton?.click(); + }); + + await flushEffects(); + + expect(mockSetProviderType).not.toHaveBeenCalled(); + expect(mockSetModel).toHaveBeenCalledWith("custom-social-model"); + expect(mockLoadProviderModels).toHaveBeenCalledWith( + expect.objectContaining({ + key: "custom-social-provider", + }), + ); + expect(onNavigate).toHaveBeenCalledWith( + "agent", + expect.objectContaining({ + theme: "social-media", + }), + ); + }); + + it("点击多代理方案时应开启多代理偏好并进入工作区", async () => { + const onEnterWorkspace = vi.fn(); + mockPrepareClawSolution.mockResolvedValueOnce({ + solutionId: "team-breakdown", + actionType: "enable_team_mode", + prompt: "请把这个任务按多代理方式拆解", + shouldLaunchBrowserAssist: false, + shouldEnableTeamMode: true, + readiness: "ready", + readinessMessage: "可直接开始,进入后会启用多代理偏好", + }); + + const { container } = renderShell({ + onNavigate: undefined, + onEnterWorkspace, + }); + + await flushEffects(); + + const teamSolutionButton = container.querySelector( + '[data-testid="home-shell-solution-team-breakdown"]', + ) as HTMLButtonElement | null; + + expect(teamSolutionButton).toBeTruthy(); + + act(() => { + teamSolutionButton?.click(); + }); + + await flushEffects(); + + expect(mockSaveChatToolPreferences).toHaveBeenLastCalledWith( + expect.objectContaining({ + webSearch: false, + thinking: false, + task: false, + subagent: true, + }), + "general", + ); + expect(onEnterWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: "project-1", + theme: "general", + initialCreationMode: "guided", + initialUserPrompt: "请把这个任务按多代理方式拆解", + }), + ); + expect(mockRecordClawSolutionUsage).toHaveBeenCalledWith({ + solutionId: "team-breakdown", + actionType: "enable_team_mode", + themeTarget: null, + }); + }); + + it("方案未就绪且缺少模型时应直接跳到供应商设置", async () => { + const onNavigate = vi.fn(); + const onEnterWorkspace = vi.fn(); + mockPrepareClawSolution.mockResolvedValueOnce({ + solutionId: "social-post-starter", + actionType: "navigate_theme", + prompt: "请先帮我起草一版社媒内容首稿", + themeTarget: "social-media", + shouldLaunchBrowserAssist: false, + shouldEnableTeamMode: false, + readiness: "needs_setup", + readinessMessage: "请先配置至少一个可用模型", + reasonCode: "missing_model", + }); + + const { container } = renderShell({ + onNavigate, + onEnterWorkspace, + }); + + await flushEffects(); + + const socialSolutionButton = container.querySelector( + '[data-testid="home-shell-solution-social-post-starter"]', + ) as HTMLButtonElement | null; + + expect(socialSolutionButton).toBeTruthy(); + + act(() => { + socialSolutionButton?.click(); + }); + + await flushEffects(); + + expect(onNavigate).toHaveBeenCalledWith("settings", { + tab: SettingsTabs.Providers, + }); + expect(onEnterWorkspace).not.toHaveBeenCalled(); + expect(mockRecordClawSolutionUsage).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/agent/chat/AgentChatHomeShell.tsx b/src/components/agent/chat/AgentChatHomeShell.tsx index 421fdae22..555273b1c 100644 --- a/src/components/agent/chat/AgentChatHomeShell.tsx +++ b/src/components/agent/chat/AgentChatHomeShell.tsx @@ -1,36 +1,40 @@ 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 { prepareClawSolution } from "@/lib/api/clawSolutions"; 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 { resolveClawWorkspaceProviderSelection } from "./utils/clawWorkspaceProviderSelection"; import { normalizeProjectId } from "./utils/topicProjectResolution"; +import { + LAST_PROJECT_ID_KEY, + usePersistedProjectId, +} from "./hooks/agentProjectStorage"; +import { useHomeShellAgentPreferences } from "./hooks/useHomeShellAgentPreferences"; +import { useHomeShellProjectMemory } from "./hooks/useHomeShellProjectMemory"; +import { useHomeShellSkills } from "./hooks/useHomeShellSkills"; +import { useThemeScopedChatToolPreferences } from "./hooks/useThemeScopedChatToolPreferences"; import { useSelectedTeamPreference } from "./hooks/useSelectedTeamPreference"; +import { + enableSubagentPreference, + resolveClawSolutionLaunch, + resolveClawSolutionSetupTarget, +} from "./claw-solutions/actionDispatcher"; +import { useClawSolutions } from "./claw-solutions/useClawSolutions"; +import { ClawHomeSolutionsPanel } from "./claw-solutions/ClawHomeSolutionsPanel"; +import type { ClawSolutionHomeItem } from "./claw-solutions/types"; +import { + type AgentChatWorkspaceBootstrap, + resolveHomeShellWorkspaceEntry, + type HomeShellEnterWorkspacePayload, +} from "./homeShellEntry"; const SUPPORTED_ENTRY_THEMES: ThemeType[] = [ "general", @@ -44,10 +48,6 @@ const SUPPORTED_ENTRY_THEMES: ThemeType[] = [ "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%; @@ -152,107 +152,17 @@ function normalizeInitialTheme(value?: string): ThemeType { return "general"; } -function scheduleDeferredHomeEnhancement(task: () => void): () => void { - if (typeof window === "undefined") { - return () => undefined; +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; } - - 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); - } - }; + if (typeof error === "string") { + return error; } - - const timeoutId = window.setTimeout(task, HOME_ENHANCEMENT_FALLBACK_DELAY_MS); - return () => { - window.clearTimeout(timeoutId); - }; + 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 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; -} +export type { AgentChatWorkspaceBootstrap } from "./homeShellEntry"; interface AgentChatHomeShellProps { onNavigate?: (page: Page, params?: PageParams) => void; @@ -277,35 +187,35 @@ export function AgentChatHomeShell({ const [creationMode, setCreationMode] = useState( initialCreationMode ?? "guided", ); - 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 { chatToolPreferences, setChatToolPreferences } = + useThemeScopedChatToolPreferences(activeTheme); + const { + projectId: currentProjectId, + setProjectId: setCurrentProjectId, + rememberProjectId, + } = usePersistedProjectId(externalProjectId, LAST_PROJECT_ID_KEY); + const { + providerType, + setProviderType, + model, + setModel, + executionStrategy, + setExecutionStrategy, + } = useHomeShellAgentPreferences(currentProjectId); + const projectMemory = useHomeShellProjectMemory(currentProjectId); + const { skills, skillsLoading, refreshSkills } = useHomeShellSkills(); const [browserAssistLoading, setBrowserAssistLoading] = useState(false); const { selectedTeam, setSelectedTeam: handleSelectTeam, enableSuggestedTeam: handleEnableSuggestedTeam, } = useSelectedTeamPreference(activeTheme); + const { + solutions: clawSolutions, + isLoading: clawSolutionsLoading, + error: clawSolutionsError, + recordUsage: recordClawSolutionUsage, + } = useClawSolutions(activeTheme === "general"); useEffect(() => { setActiveTheme(normalizeInitialTheme(initialTheme)); @@ -319,161 +229,16 @@ export function AgentChatHomeShell({ }, [initialCreationMode]); useEffect(() => { - setCurrentProjectId( - normalizeProjectId(externalProjectId) ?? - loadPersistedProjectId(LAST_PROJECT_ID_KEY), - ); - }, [externalProjectId]); - - useEffect(() => { - if (chatToolPreferencesTheme === activeTheme) { + if (activeTheme !== "general" || !clawSolutionsError) { return; } - setChatToolPreferences(loadChatToolPreferences(activeTheme)); - setChatToolPreferencesTheme(activeTheme); - }, [activeTheme, chatToolPreferencesTheme]); - - useEffect(() => { - if (chatToolPreferencesTheme !== activeTheme) { - return; - } - - saveChatToolPreferences(chatToolPreferences, activeTheme); - }, [activeTheme, chatToolPreferences, chatToolPreferencesTheme]); - - 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], - ); + toast.error(`加载 Claw 方案失败:${clawSolutionsError}`); + }, [activeTheme, clawSolutionsError]); const handleRefreshSkills = useCallback(async () => { - await loadSkills(true); - }, [loadSkills]); + await refreshSkills(true); + }, [refreshSkills]); const handleProjectChange = useCallback( (nextProjectId: string) => { @@ -484,76 +249,143 @@ export function AgentChatHomeShell({ const normalizedProjectId = normalizeProjectId(nextProjectId); setCurrentProjectId(normalizedProjectId); if (normalizedProjectId) { - savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId); + rememberProjectId(normalizedProjectId); } }, - [externalProjectId], + [externalProjectId, rememberProjectId, setCurrentProjectId], ); const handleEnterWorkspace = useCallback( - (payload: { - prompt?: string; - images?: MessageImage[]; - openBrowserAssistOnMount?: boolean; - toolPreferences?: ChatToolPreferences; - }) => { + (payload: HomeShellEnterWorkspacePayload) => { const normalizedProjectId = normalizeProjectId(currentProjectId); - const hasPrompt = Boolean(payload.prompt?.trim()); - const hasImages = Boolean(payload.images?.length); - const effectiveToolPreferences = - payload.toolPreferences ?? chatToolPreferences; + const resolved = resolveHomeShellWorkspaceEntry({ + projectId: normalizedProjectId, + activeTheme, + creationMode, + defaultToolPreferences: chatToolPreferences, + payload, + }); - if (!payload.openBrowserAssistOnMount && !normalizedProjectId) { - toast.error("缺少项目工作区,请先选择项目后再使用 Agent"); - return; - } - - if (!payload.openBrowserAssistOnMount && !hasPrompt && !hasImages) { - return; + if (!resolved.ok) { + if (resolved.reason === "missing_project") { + toast.error("缺少项目工作区,请先选择项目后再使用 Agent"); + } + return false; } if (normalizedProjectId) { - savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId); + rememberProjectId(normalizedProjectId); } - saveChatToolPreferences(effectiveToolPreferences, activeTheme); - const nextNewChatAt = Date.now(); + saveChatToolPreferences(resolved.toolPreferences, resolved.targetTheme); if (onNavigate) { - onNavigate( - "agent", - buildClawAgentParams({ - projectId: normalizedProjectId ?? undefined, - theme: activeTheme, - initialCreationMode: creationMode, - initialUserPrompt: payload.prompt, - initialUserImages: payload.images, - openBrowserAssistOnMount: payload.openBrowserAssistOnMount, - newChatAt: nextNewChatAt, - }), - ); - return; + onNavigate("agent", resolved.navigationParams); + return true; } - onEnterWorkspace({ - projectId: normalizedProjectId ?? undefined, - initialUserPrompt: payload.prompt, - initialUserImages: payload.images, - theme: activeTheme, - initialCreationMode: creationMode, - openBrowserAssistOnMount: payload.openBrowserAssistOnMount, - newChatAt: nextNewChatAt, - }); + onEnterWorkspace(resolved.workspaceBootstrap); + return true; }, [ activeTheme, chatToolPreferences, creationMode, currentProjectId, + rememberProjectId, onEnterWorkspace, onNavigate, ], ); + const handleClawSolutionSelect = useCallback( + async (solution: ClawSolutionHomeItem) => { + try { + const preparation = await prepareClawSolution(solution.id, { + projectId: normalizeProjectId(currentProjectId) ?? undefined, + userInput: input.trim() || undefined, + }); + + if (preparation.readiness !== "ready") { + const setupTab = resolveClawSolutionSetupTarget( + preparation.readiness, + preparation.reasonCode, + ); + if (setupTab && onNavigate) { + onNavigate("settings", { tab: setupTab }); + return; + } + toast.error(preparation.readinessMessage); + return; + } + + const launch = resolveClawSolutionLaunch( + preparation, + chatToolPreferences, + ); + const targetTheme = + launch.enterWorkspacePayload.themeOverride ?? activeTheme; + + try { + const providerSelection = await resolveClawWorkspaceProviderSelection({ + currentProviderType: providerType, + currentModel: model, + theme: targetTheme, + }); + + if (providerSelection) { + if (providerSelection.providerType !== providerType) { + setProviderType(providerSelection.providerType); + } + if (providerSelection.model !== model) { + setModel(providerSelection.model); + } + } + } catch (selectionError) { + console.warn( + "[AgentChatHomeShell] 解析 Claw 工作区默认 provider/model 失败,继续沿用当前选择:", + selectionError, + ); + } + + if (launch.preferencesChanged) { + setChatToolPreferences(launch.nextToolPreferences); + } + + if (launch.shouldStartBrowserAssistLoading) { + setBrowserAssistLoading(true); + } + + const entered = handleEnterWorkspace(launch.enterWorkspacePayload); + + if (!entered) { + if (launch.shouldStartBrowserAssistLoading) { + setBrowserAssistLoading(false); + } + return; + } + + recordClawSolutionUsage(launch.usageRecord); + } catch (error) { + setBrowserAssistLoading(false); + toast.error(`启动方案失败:${getErrorMessage(error)}`); + } + }, + [ + chatToolPreferences, + currentProjectId, + handleEnterWorkspace, + input, + model, + onNavigate, + providerType, + recordClawSolutionUsage, + setChatToolPreferences, + setModel, + setProviderType, + activeTheme, + ], + ); + const handleRecommendationClick = useCallback( (shortLabel: string, fullPrompt: string) => { setInput(fullPrompt); @@ -565,14 +397,10 @@ export function AgentChatHomeShell({ return; } - const nextToolPreferences = chatToolPreferences.subagent - ? chatToolPreferences - : { - ...chatToolPreferences, - subagent: true, - }; + const { nextToolPreferences, changed } = + enableSubagentPreference(chatToolPreferences); - if (!chatToolPreferences.subagent) { + if (changed) { setChatToolPreferences(nextToolPreferences); } saveChatToolPreferences(nextToolPreferences, activeTheme); @@ -581,7 +409,12 @@ export function AgentChatHomeShell({ toolPreferences: nextToolPreferences, }); }, - [activeTheme, chatToolPreferences, handleEnterWorkspace], + [ + activeTheme, + chatToolPreferences, + handleEnterWorkspace, + setChatToolPreferences, + ], ); return ( @@ -658,6 +491,15 @@ export function AgentChatHomeShell({ hasContentId={false} selectedText="" onRecommendationClick={handleRecommendationClick} + supportingSlotOverride={ + activeTheme === "general" ? ( + + ) : undefined + } characters={projectMemory?.characters || []} skills={skills} isSkillsLoading={skillsLoading} diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 5451113bf..e9bf79b0c 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -42,6 +42,7 @@ import { useAgentChatUnified, useArtifactAutoPreviewSync, useCompatSubagentRuntime, + useRuntimeTeamFormation, useTeamWorkspaceRuntime, useThemeContextWorkspace, useTopicBranchBoard, @@ -63,9 +64,11 @@ import { useTrayModelShortcuts } from "./hooks/useTrayModelShortcuts"; import { isTeamWorkspaceTerminalStatus, resolveTeamWorkspaceRuntimeStatusLabel, + summarizeTeamWorkspaceExecution, type TeamWorkspaceControlSummary, type TeamWorkspaceWaitSummary, } from "./teamWorkspaceRuntime"; +import { notifyProjectRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService"; import { ChatNavbar } from "./components/ChatNavbar"; import { ChatSidebar } from "./components/ChatSidebar"; import { ThemeWorkbenchSidebar } from "./components/ThemeWorkbenchSidebar"; @@ -74,6 +77,9 @@ import { AgentRuntimeStrip } from "./components/AgentRuntimeStrip"; import { HarnessStatusPanel } from "./components/HarnessStatusPanel"; import { SocialMediaHarnessCard } from "./components/SocialMediaHarnessCard"; import { TeamWorkspaceDock } from "./components/TeamWorkspaceDock"; +import { TeamWorkspaceBoard } from "./components/TeamWorkspaceBoard"; +import { TeamWorkbenchSummaryPanel } from "./components/TeamWorkbenchSummaryPanel"; +import { ThemeWorkbenchEntryPromptAccessory } from "./components/ThemeWorkbenchEntryPromptAccessory"; import { MessageList } from "./components/MessageList"; import { Inputbar } from "./components/Inputbar"; import { RuntimeStyleControlBar } from "./components/RuntimeStyleControlBar"; @@ -169,7 +175,6 @@ 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 { @@ -181,6 +186,9 @@ import { type ThemeWorkbenchRunTerminalItem, type ThemeWorkbenchRunState as BackendThemeWorkbenchRunState, } from "@/lib/api/executionRun"; +import { + contentWorkflowApi, +} from "@/lib/api/content-workflow"; import { setActiveContentTarget } from "@/lib/activeContentTarget"; import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; import { listMaterials, uploadMaterial } from "@/lib/api/materials"; @@ -216,9 +224,7 @@ import { } from "@/lib/api/skill-execution"; import type { - BrowserPreflightState, BrowserAssistSessionState, - BrowserTaskRequirement, Message, MessageImage, WriteArtifactContext, @@ -244,9 +250,7 @@ import { } from "./utils/styleRuntime"; import { resolveTopicSwitchProject } from "./utils/topicProjectSwitch"; import { - loadChatToolPreferences, saveChatToolPreferences, - type ChatToolPreferences, } from "./utils/chatToolPreferences"; import { buildHarnessRequestMetadata, @@ -273,7 +277,23 @@ import { buildGeneralAgentSystemPrompt, resolveAgentChatMode, } from "./utils/generalAgentPrompt"; +import { loadPersistedProjectId } from "./hooks/agentProjectStorage"; import { useSelectedTeamPreference } from "./hooks/useSelectedTeamPreference"; +import { useThemeScopedChatToolPreferences } from "./hooks/useThemeScopedChatToolPreferences"; +import { useLimeSkills } from "./hooks/useLimeSkills"; +import { useWorkspaceProjectSelection } from "./hooks/useWorkspaceProjectSelection"; +import { + useBootstrapDispatchPreview, +} from "./hooks/useBootstrapDispatchPreview"; +import { useThemeWorkbenchEntryPrompt } from "./hooks/useThemeWorkbenchEntryPrompt"; +import { useThemeWorkbenchEntryPromptActions } from "./hooks/useThemeWorkbenchEntryPromptActions"; +import { + useThemeWorkbenchSendBoundary, +} from "./hooks/useThemeWorkbenchSendBoundary"; +import type { + BrowserTaskPreflight, + HandleSendOptions, +} from "./hooks/handleSendTypes"; import { areBrowserAssistSessionStatesEqual, clearBrowserAssistSessionState, @@ -290,7 +310,6 @@ import { 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 { @@ -652,7 +671,7 @@ function wrapPreviewWithWorkbenchTrigger( return (
{preview} -
+
{stackedWorkbenchTrigger}
@@ -971,38 +990,6 @@ const LayoutTransitionRenderGate = memo( ); 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; @@ -1075,7 +1062,6 @@ function projectTypeToTheme(projectType: ProjectType): ThemeType { 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; @@ -1083,7 +1069,6 @@ 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; @@ -1164,44 +1149,6 @@ function buildBrowserPreflightMessages( ]; } -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("我的选择:") @@ -2236,37 +2183,6 @@ function buildThemeWorkbenchWorkflowSteps( 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); @@ -2293,27 +2209,6 @@ function savePersistedBoolean(key: string, value: boolean) { } } -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; @@ -2547,26 +2442,34 @@ export function AgentChatWorkspace({ 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 [creationMode, setCreationMode] = useState( initialCreationMode ?? "guided", ); + const { chatToolPreferences, setChatToolPreferences } = + useThemeScopedChatToolPreferences(activeTheme); + const { + projectId, + shouldDisableSessionRestore, + hasHandledNewChatRequest, + markNewChatRequestHandled, + rememberProjectId, + getRememberedProjectId, + applyProjectSelection, + resetProjectSelection, + clearProjectSelectionRuntime, + startTopicProjectResolution, + finishTopicProjectResolution, + deferTopicSwitch, + consumePendingTopicSwitch, + } = useWorkspaceProjectSelection({ + externalProjectId, + newChatAt, + }); const [layoutMode, setLayoutMode] = useState( shouldBootstrapCanvasOnEntry ? "canvas" : "chat", ); @@ -2591,43 +2494,7 @@ export function AgentChatWorkspace({ 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(() => { @@ -2796,8 +2663,17 @@ export function AgentChatWorkspace({ ); // 技能列表(用于 @ 引用) - const [skills, setSkills] = useState([]); - const [skillsLoading, setSkillsLoading] = useState(false); + const { + skills, + skillsLoading, + refreshSkills: loadSkills, + } = useLimeSkills({ + autoLoad: "immediate", + logScope: "AgentChatPage", + onError: (error) => { + console.warn("[AgentChatPage] 加载 skills 失败:", error); + }, + }); // Workbench Store(用于主题工作台右侧面板状态同步) const pendingSkillKey = useWorkbenchStore((state) => state.pendingSkillKey); @@ -2810,12 +2686,6 @@ export function AgentChatWorkspace({ // 用于追踪已处理的消息 ID,避免重复处理 const processedMessageIds = useRef>(new Set()); - const pendingTopicSwitchRef = useRef<{ - topicId: string; - targetProjectId: string; - } | null>(null); - const isResolvingTopicProjectRef = useRef(false); - // 文件写入回调 ref(用于传递给统一聊天主链 Hook) const handleWriteFileRef = useRef< @@ -2882,6 +2752,8 @@ export function AgentChatWorkspace({ const browserAssistLaunchRequestIdRef = useRef(0); const browserTaskPreflightLaunchIdRef = useRef(""); const autoCollapsedTopicSidebarRef = useRef(false); + const browserAssistAutoOpenDismissedScopeRef = useRef(null); + const browserAssistScopeTrackerRef = useRef(null); // 当有新的 artifact 时,自动打开画布 useEffect(() => { @@ -2898,6 +2770,13 @@ export function AgentChatWorkspace({ return; } + if ( + !hasNonBrowserAssistArtifact && + browserAssistAutoOpenDismissedScopeRef.current + ) { + return; + } + // 自动打开画布显示 artifact setLayoutMode("chat-canvas"); }, [ @@ -2949,6 +2828,39 @@ export function AgentChatWorkspace({ return "idle" as const; }, [browserAssistLaunching, browserTaskPreflight?.phase]); + const openBrowserAssistCanvas = useCallback( + (artifactId = GENERAL_BROWSER_ASSIST_ARTIFACT_ID) => { + browserAssistAutoOpenDismissedScopeRef.current = null; + setSelectedArtifactId(artifactId); + setLayoutMode("chat-canvas"); + }, + [setSelectedArtifactId], + ); + + const autoOpenBrowserAssistCanvas = useCallback( + (artifactId = GENERAL_BROWSER_ASSIST_ARTIFACT_ID) => { + if ( + activeTheme === "general" && + browserAssistAutoOpenDismissedScopeRef.current + ) { + return false; + } + + setSelectedArtifactId(artifactId); + setLayoutMode("chat-canvas"); + return true; + }, + [activeTheme, setSelectedArtifactId], + ); + + const suppressBrowserAssistCanvasAutoOpen = useCallback(() => { + if (activeTheme !== "general") { + return; + } + + browserAssistAutoOpenDismissedScopeRef.current = "__dismissed__"; + }, [activeTheme]); + useEffect(() => { if (activeTheme === "general") { return; @@ -2961,45 +2873,6 @@ export function AgentChatWorkspace({ _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]); @@ -3326,7 +3199,7 @@ export function AgentChatWorkspace({ } if (project && project.id === normalizedProjectId && !project.isArchived) { - savePersistedProjectId(LAST_PROJECT_ID_KEY, normalizedProjectId); + rememberProjectId(normalizedProjectId); return; } @@ -3335,12 +3208,12 @@ export function AgentChatWorkspace({ if (!resolvedProject || resolvedProject.isArchived) { return; } - savePersistedProjectId(LAST_PROJECT_ID_KEY, resolvedProject.id); + rememberProjectId(resolvedProject.id); }) .catch((error) => { console.warn("[AgentChatPage] 记录最近项目失败:", error); }); - }, [project, projectId]); + }, [project, projectId, rememberProjectId]); const runtimeStylePrompt = useMemo( () => @@ -3468,10 +3341,24 @@ export function AgentChatWorkspace({ } void originalSwitchTopic(parentSessionId); }, [originalSwitchTopic, subagentParentContext?.parent_session_id]); + const hasRealTeamGraph = + childSubagentSessions.length > 0 || Boolean(subagentParentContext); const [teamWaitSummary, setTeamWaitSummary] = useState(null); const [teamControlSummary, setTeamControlSummary] = useState(null); + const { + runtimeTeamState, + clearRuntimeTeamState, + handleRuntimeTeamAfterSend, + } = useRuntimeTeamFormation({ + activeTheme, + projectId, + sessionId, + selectedTeam, + subagentEnabled: chatToolPreferences.subagent, + hasRealTeamGraph, + }); const handleCloseSubagentSession = useCallback( async (subagentSessionId: string) => { try { @@ -3490,18 +3377,18 @@ export function AgentChatWorkspace({ if (summary.affectedSessionIds.length > 1) { toast.success( - `子代理已级联关闭 ${summary.affectedSessionIds.length} 个会话`, + `已级联关闭 ${summary.affectedSessionIds.length} 位协作成员`, ); } else if (summary.affectedSessionIds.length === 1) { - toast.success("子代理已关闭"); + toast.success("协作成员已关闭"); } else { toast.info( - `子代理当前状态为${resolveTeamWorkspaceRuntimeStatusLabel(response.previous_status.kind)},未发生新的关闭变更`, + `当前成员状态为${resolveTeamWorkspaceRuntimeStatusLabel(response.previous_status.kind)},未发生新的关闭变更`, ); } } catch (error) { const message = - error instanceof Error ? error.message : "关闭子代理失败"; + error instanceof Error ? error.message : "关闭协作成员失败"; toast.error(message); throw error; } @@ -3526,18 +3413,18 @@ export function AgentChatWorkspace({ if (summary.affectedSessionIds.length > 1) { toast.success( - `子代理已级联恢复 ${summary.affectedSessionIds.length} 个会话`, + `已级联恢复 ${summary.affectedSessionIds.length} 位协作成员`, ); } else if (summary.affectedSessionIds.length === 1) { - toast.success("子代理已恢复"); + toast.success("协作成员已恢复"); } else { toast.info( - `子代理当前状态为${resolveTeamWorkspaceRuntimeStatusLabel(response.status.kind)},未发生新的恢复变更`, + `当前成员状态为${resolveTeamWorkspaceRuntimeStatusLabel(response.status.kind)},未发生新的恢复变更`, ); } } catch (error) { const message = - error instanceof Error ? error.message : "恢复子代理失败"; + error instanceof Error ? error.message : "恢复协作成员失败"; toast.error(message); throw error; } @@ -3552,17 +3439,17 @@ export function AgentChatWorkspace({ timeout_ms: timeoutMs, }); if (response.timed_out) { - toast.info("等待超时,子代理仍未进入最终状态"); + toast.info("等待超时,该成员仍未进入最终状态"); return; } const status = response.status[subagentSessionId]; toast.success( - `子代理已进入${resolveTeamWorkspaceRuntimeStatusLabel(status?.kind)}状态`, + `该成员已进入${resolveTeamWorkspaceRuntimeStatusLabel(status?.kind)}状态`, ); } catch (error) { const message = - error instanceof Error ? error.message : "等待子代理失败"; + error instanceof Error ? error.message : "等待成员失败"; toast.error(message); throw error; } @@ -3575,7 +3462,7 @@ export function AgentChatWorkspace({ normalizeUniqueSessionIds(subagentSessionIds); if (normalizedSessionIds.length === 0) { - const error = new Error("没有可等待的活跃子代理"); + const error = new Error("没有可等待的活跃成员"); toast.error(error.message); throw error; } @@ -3591,7 +3478,7 @@ export function AgentChatWorkspace({ timedOut: true, updatedAt: Date.now(), }); - toast.info("等待超时,团队内活跃子代理仍未进入最终状态"); + toast.info("等待超时,团队内活跃成员仍未进入最终状态"); return; } @@ -3611,11 +3498,11 @@ export function AgentChatWorkspace({ updatedAt: Date.now(), }); toast.success( - `团队内 agent 已进入${resolveTeamWorkspaceRuntimeStatusLabel(resolvedStatus)}状态`, + `团队成员已进入${resolveTeamWorkspaceRuntimeStatusLabel(resolvedStatus)}状态`, ); } catch (error) { const message = - error instanceof Error ? error.message : "等待团队内子代理失败"; + error instanceof Error ? error.message : "等待团队成员失败"; toast.error(message); throw error; } @@ -3628,7 +3515,7 @@ export function AgentChatWorkspace({ normalizeUniqueSessionIds(subagentSessionIds); if (normalizedSessionIds.length === 0) { - const error = new Error("没有可关闭的已完成子代理"); + const error = new Error("没有可关闭的已完成成员"); toast.error(error.message); throw error; } @@ -3676,7 +3563,7 @@ export function AgentChatWorkspace({ toast.success( affectedSessionIds.length > 0 ? `已级联关闭 ${affectedSessionIds.length} 个会话` - : `已关闭 ${succeededCount} 个已完成 agent`, + : `已关闭 ${succeededCount} 位已完成成员`, ); } @@ -3685,7 +3572,7 @@ export function AgentChatWorkspace({ const message = firstFailure instanceof Error ? firstFailure.message - : "部分已完成 agent 关闭失败"; + : "部分已完成成员关闭失败"; toast.error(message); if (succeededCount === 0) { throw firstFailure instanceof Error @@ -3704,7 +3591,7 @@ export function AgentChatWorkspace({ ) => { const normalizedMessage = message.trim(); if (!normalizedMessage) { - const error = new Error("请输入要发给子代理的内容"); + const error = new Error("请输入要发送给成员的内容"); toast.error(error.message); throw error; } @@ -3717,12 +3604,12 @@ export function AgentChatWorkspace({ }); toast.success( options?.interrupt === true - ? "已中断当前执行并发送新任务" - : "已向子代理发送补充任务", + ? "已中断当前执行并发送新说明" + : "已向成员发送补充说明", ); } catch (error) { const messageText = - error instanceof Error ? error.message : "发送子代理输入失败"; + error instanceof Error ? error.message : "发送成员说明失败"; toast.error(messageText); throw error; } @@ -3735,8 +3622,8 @@ export function AgentChatWorkspace({ ); const showTeamWorkspaceBoard = chatToolPreferences.subagent || - childSubagentSessions.length > 0 || - Boolean(subagentParentContext); + hasRealTeamGraph || + Boolean(runtimeTeamState); const currentSessionRuntimeStatus = useMemo( () => deriveCurrentSessionRuntimeStatus({ @@ -3762,6 +3649,31 @@ export function AgentChatWorkspace({ childSubagentSessions, subagentParentContext, }); + const [teamWorkbenchAutoFocusToken, setTeamWorkbenchAutoFocusToken] = + useState(0); + const previousTeamWorkbenchSessionIdRef = useRef( + sessionId ?? null, + ); + const previousRealTeamGraphRef = useRef(hasRealTeamGraph); + const handleActivateTeamWorkbench = useCallback(() => { + setTeamWorkbenchAutoFocusToken((current) => current + 1); + setLayoutMode((current) => (current === "chat" ? "chat-canvas" : current)); + }, []); + useEffect(() => { + const normalizedSessionId = sessionId ?? null; + + if (previousTeamWorkbenchSessionIdRef.current !== normalizedSessionId) { + previousTeamWorkbenchSessionIdRef.current = normalizedSessionId; + previousRealTeamGraphRef.current = hasRealTeamGraph; + return; + } + + if (hasRealTeamGraph && !previousRealTeamGraphRef.current) { + handleActivateTeamWorkbench(); + } + + previousRealTeamGraphRef.current = hasRealTeamGraph; + }, [handleActivateTeamWorkbench, hasRealTeamGraph, sessionId]); useEffect(() => { logAgentDebug( "AgentChatPage", @@ -3882,6 +3794,28 @@ export function AgentChatWorkspace({ activeTheme === "general" && layoutMode !== "chat" && currentCanvasArtifact?.type === "browser_assist"; + + useEffect(() => { + if (activeTheme !== "general") { + browserAssistScopeTrackerRef.current = null; + browserAssistAutoOpenDismissedScopeRef.current = null; + return; + } + + if (!currentBrowserAssistScopeKey) { + return; + } + + if ( + browserAssistScopeTrackerRef.current && + browserAssistScopeTrackerRef.current !== currentBrowserAssistScopeKey + ) { + browserAssistAutoOpenDismissedScopeRef.current = null; + } + + browserAssistScopeTrackerRef.current = currentBrowserAssistScopeKey; + }, [activeTheme, currentBrowserAssistScopeKey]); + const compatSubagentRuntime = useCompatSubagentRuntime(sessionId); const realSubagentTimelineItems = useMemo( () => @@ -4103,10 +4037,6 @@ export function AgentChatWorkspace({ setHarnessPanelVisible((current) => !current); }, []); - useEffect(() => { - void loadSkills(false); - }, [loadSkills]); - // 主题工作台模式:同步 skills 状态到 store // 注意:不再设置 themeSkillsRailState,避免"操作面板"覆盖默认 Skills Rail // 默认 Skills Rail 已包含完整的技能分类(文字多搜索、视觉生成、音频生成等) @@ -5448,6 +5378,79 @@ export function AgentChatWorkspace({ // 用于追踪是否已触发过 AI 引导 const hasTriggeredGuide = useRef(false); const consumedInitialPromptRef = useRef(null); + const { + initialDispatchKey, + isBootstrapDispatchPending, + bootstrapDispatchPreviewMessages, + } = useBootstrapDispatchPreview({ + initialUserPrompt, + initialUserImages, + messagesCount: messages.length, + isSending, + queuedTurnCount: queuedTurns.length, + consumedInitialPromptKey: consumedInitialPromptRef.current, + shouldUseCompactThemeWorkbench, + }); + const { + themeWorkbenchEntryPrompt, + themeWorkbenchEntryCheckPending, + clearThemeWorkbenchEntryPrompt, + dismissThemeWorkbenchEntryPrompt, + } = useThemeWorkbenchEntryPrompt({ + activeTheme, + contentId, + sessionId, + isThemeWorkbench, + shouldUseCompactThemeWorkbench, + messagesCount: messages.length, + initialDispatchKey, + initialUserPrompt, + initialUserImages, + consumedInitialPromptKey: consumedInitialPromptRef.current, + onHydrateInitialPrompt: useCallback((prompt: string) => { + hasTriggeredGuide.current = true; + setInput((previous) => previous.trim() || prompt); + }, []), + }); + const consumeInitialPrompt = useCallback( + (dispatchKey: string) => { + consumedInitialPromptRef.current = dispatchKey; + onInitialUserPromptConsumed?.(); + }, + [onInitialUserPromptConsumed], + ); + const resetConsumedInitialPrompt = useCallback(() => { + consumedInitialPromptRef.current = null; + }, []); + const prepareBrowserTaskPreflight = useCallback( + (preflight: BrowserTaskPreflight) => { + setInput(""); + setMentionedCharacters([]); + setBrowserTaskPreflight(preflight); + }, + [], + ); + const { + resolveSendBoundary, + isBlockedByBrowserPreflight, + maybeStartBrowserTaskPreflight, + finalizeAfterSendSuccess, + rollbackAfterSendFailure, + } = useThemeWorkbenchSendBoundary({ + isThemeWorkbench, + contentId, + initialDispatchKey, + consumedInitialPromptKey: consumedInitialPromptRef.current, + initialUserImages, + mappedTheme, + socialArticleSkillKey: SOCIAL_ARTICLE_SKILL_KEY, + isBrowserAssistReady, + browserTaskPreflight, + onConsumeInitialPrompt: consumeInitialPrompt, + onResetConsumedInitialPrompt: resetConsumedInitialPrompt, + onClearEntryPrompt: clearThemeWorkbenchEntryPrompt, + onPrepareBrowserTaskPreflight: prepareBrowserTaskPreflight, + }); // 当 sessionMeta 加载完成时,恢复主题和创建模式 useEffect(() => { @@ -5563,6 +5566,7 @@ export function AgentChatWorkspace({ setCanvasState(null); setGeneralCanvasState(DEFAULT_CANVAS_STATE); setTaskFiles([]); + clearRuntimeTeamState(); setBrowserTaskPreflight(null); setSelectedFileId(undefined); processedMessageIds.current.clear(); @@ -5570,7 +5574,7 @@ export function AgentChatWorkspace({ restoredFilesSessionId.current = null; hasTriggeredGuide.current = false; consumedInitialPromptRef.current = null; - }, []); + }, [clearRuntimeTeamState]); const runTopicSwitch = useCallback( async (topicId: string) => { @@ -5605,7 +5609,7 @@ export function AgentChatWorkspace({ const switchTopic = useCallback( async (topicId: string) => { - if (isResolvingTopicProjectRef.current) { + if (!startTopicProjectResolution()) { logAgentDebug( "AgentChatPage", "switchTopic.skipWhileResolving", @@ -5615,7 +5619,6 @@ export function AgentChatWorkspace({ return; } - isResolvingTopicProjectRef.current = true; try { logAgentDebug("AgentChatPage", "switchTopic.start", { currentProjectId: projectId ?? null, @@ -5627,7 +5630,7 @@ export function AgentChatWorkspace({ topicBoundProjectId: loadPersistedProjectId( `${TOPIC_PROJECT_KEY_PREFIX}${topicId}`, ), - lastProjectId: loadPersistedProjectId(LAST_PROJECT_ID_KEY), + lastProjectId: getRememberedProjectId(), loadProjectById: async (candidateProjectId) => { const project = await getProject(candidateProjectId); return project @@ -5670,20 +5673,18 @@ export function AgentChatWorkspace({ toast.info("未找到可用项目,已自动创建默认项目"); } - savePersistedProjectId(LAST_PROJECT_ID_KEY, targetProjectId); - const currentProjectId = normalizeProjectId(projectId); if (currentProjectId !== targetProjectId) { - pendingTopicSwitchRef.current = { topicId, targetProjectId }; + deferTopicSwitch(topicId, targetProjectId); logAgentDebug("AgentChatPage", "switchTopic.deferUntilProjectReady", { currentProjectId, targetProjectId, topicId, }); - setInternalProjectId(targetProjectId); return; } + rememberProjectId(targetProjectId); await runTopicSwitch(topicId); } catch (error) { console.error("[AgentChatPage] 解析任务项目失败:", error); @@ -5699,10 +5700,19 @@ export function AgentChatWorkspace({ ); toast.error("切换任务失败,请稍后重试"); } finally { - isResolvingTopicProjectRef.current = false; + finishTopicProjectResolution(); } }, - [externalProjectId, projectId, runTopicSwitch], + [ + deferTopicSwitch, + externalProjectId, + finishTopicProjectResolution, + getRememberedProjectId, + projectId, + rememberProjectId, + runTopicSwitch, + startTopicProjectResolution, + ], ); useTrayModelShortcuts({ @@ -5715,17 +5725,12 @@ export function AgentChatWorkspace({ }); useEffect(() => { - const pending = pendingTopicSwitchRef.current; + const pending = consumePendingTopicSwitch(projectId); if (!pending) { return; } const currentProjectId = normalizeProjectId(projectId); - if (currentProjectId !== pending.targetProjectId) { - return; - } - - pendingTopicSwitchRef.current = null; logAgentDebug("AgentChatPage", "switchTopic.resumePending", { projectId: currentProjectId, topicId: pending.topicId, @@ -5744,7 +5749,7 @@ export function AgentChatWorkspace({ ); toast.error("加载任务失败,请重试"); }); - }, [projectId, runTopicSwitch]); + }, [consumePendingTopicSwitch, projectId, runTopicSwitch]); /** * 从 AI 响应中提取文档内容 @@ -6010,17 +6015,19 @@ export function AgentChatWorkspace({ ) => { let sourceText = textOverride ?? input; if (!sourceText.trim() && (!images || images.length === 0)) return false; - if (browserTaskPreflight && !sendOptions?.browserPreflightConfirmed) { + const sendBoundary = resolveSendBoundary({ + sourceText, + sendOptions, + }); + sourceText = sendBoundary.sourceText; + if (isBlockedByBrowserPreflight(sendOptions)) { toast.info("请先完成当前浏览器准备后,再继续发送新的任务"); return false; } const effectiveToolPreferences = sendOptions?.toolPreferencesOverride ?? chatToolPreferences; - const browserRequirementMatch = - mappedTheme === "general" && !sendOptions?.purpose - ? detectBrowserTaskRequirement(sourceText) - : null; + const { browserRequirementMatch } = sendBoundary; const requestedWebSearch = webSearch ?? effectiveToolPreferences.webSearch; const effectiveWebSearch = @@ -6037,42 +6044,17 @@ export function AgentChatWorkspace({ } if ( - browserRequirementMatch && - !sendOptions?.browserPreflightConfirmed && - !isBrowserAssistReady - ) { - const preflight: BrowserTaskPreflight = { - requestId: `${BROWSER_PREFLIGHT_REQUEST_PREFIX}${crypto.randomUUID()}`, - createdAt: Date.now(), - sourceText, - images: images || [], + maybeStartBrowserTaskPreflight({ + boundary: sendBoundary, + 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(); + return true; } let text = sourceText; @@ -6304,8 +6286,19 @@ export function AgentChatWorkspace({ ); } + finalizeAfterSendSuccess(sendBoundary); + + handleRuntimeTeamAfterSend({ + input: sourceText, + providerType, + model: effectiveModel, + executionStrategy: sendExecutionStrategy ?? executionStrategy, + purpose: sendOptions?.purpose, + }); + return true; } catch (error) { + rollbackAfterSendFailure(sendBoundary); const errorMessage = error instanceof Error ? error.message : String(error); sendOptions?.observer?.onError?.(errorMessage); @@ -6318,8 +6311,6 @@ export function AgentChatWorkspace({ }, [ chatToolPreferences, - browserTaskPreflight, - isBrowserAssistReady, contextWorkspace, input, creationMode, @@ -6330,6 +6321,7 @@ export function AgentChatWorkspace({ mentionedCharacters, mappedTheme, activeTheme, + executionStrategy, model, projectId, preferredTeamPresetId, @@ -6339,7 +6331,13 @@ export function AgentChatWorkspace({ selectedTeamLabel, selectedTeamSummary, providerType, + finalizeAfterSendSuccess, + handleRuntimeTeamAfterSend, + isBlockedByBrowserPreflight, + maybeStartBrowserTaskPreflight, + resolveSendBoundary, resolveSendProviderContext, + rollbackAfterSendFailure, runtimeStyleMessagePrompt, sendMessage, sessionId, @@ -6402,6 +6400,47 @@ export function AgentChatWorkspace({ webSearchPreferenceRef.current = chatToolPreferences.webSearch; }, [chatToolPreferences.webSearch]); + const { handleContinueThemeWorkbenchEntryPrompt, + handleRestartThemeWorkbenchEntryPrompt } = + useThemeWorkbenchEntryPromptActions({ + themeWorkbenchEntryPrompt, + input, + initialDispatchKey, + onContinuePrompt: async (promptToSend) => { + await handleSendRef.current( + [], + webSearchPreferenceRef.current, + chatToolPreferences.thinking, + promptToSend, + ); + }, + dismissThemeWorkbenchEntryPrompt, + onConsumeInitialPrompt: (dispatchKey) => { + consumedInitialPromptRef.current = dispatchKey; + onInitialUserPromptConsumed?.(); + }, + onInputChange: setInput, + onRequirePrompt: () => { + toast.info("请先补充要继续执行的内容"); + }, + }); + + const themeWorkbenchEntryPromptAccessory = useMemo( + () => + themeWorkbenchEntryPrompt ? ( + + ) : null, + [ + handleContinueThemeWorkbenchEntryPrompt, + handleRestartThemeWorkbenchEntryPrompt, + themeWorkbenchEntryPrompt, + ], + ); + useEffect(() => { if (!browserTaskPreflight) { return; @@ -6527,7 +6566,7 @@ export function AgentChatWorkspace({ }, ); }, - [], + [setChatToolPreferences], ); const handleDocumentAutoContinueRun = useCallback( @@ -6636,9 +6675,8 @@ export function AgentChatWorkspace({ setTaskFiles([]); setSelectedFileId(undefined); processedMessageIds.current.clear(); - pendingTopicSwitchRef.current = null; - isResolvingTopicProjectRef.current = false; - }, [clearMessages, defaultTopicSidebarVisible]); + clearProjectSelectionRuntime(); + }, [clearMessages, clearProjectSelectionRuntime, defaultTopicSidebarVisible]); const handleSwitchBranchVersion = useCallback( (versionId: string) => { @@ -6818,10 +6856,10 @@ export function AgentChatWorkspace({ } const requestKey = String(newChatAt); - if (handledNewChatRequestRef.current === requestKey) { + if (hasHandledNewChatRequest(requestKey)) { return; } - handledNewChatRequestRef.current = requestKey; + markNewChatRequestHandled(requestKey); clearMessages({ showToast: false, @@ -6838,15 +6876,14 @@ export function AgentChatWorkspace({ setSelectedFileId(undefined); setMentionedCharacters([]); processedMessageIds.current.clear(); - pendingTopicSwitchRef.current = null; - isResolvingTopicProjectRef.current = false; + clearProjectSelectionRuntime(); restoredMetaSessionId.current = null; restoredFilesSessionId.current = null; hasTriggeredGuide.current = false; consumedInitialPromptRef.current = null; if (!externalProjectId) { - setInternalProjectId(null); + resetProjectSelection(); setProject(null); setProjectMemory(null); setActiveTheme(normalizeInitialTheme(initialTheme)); @@ -6882,9 +6919,13 @@ export function AgentChatWorkspace({ clearMessages, defaultTopicSidebarVisible, externalProjectId, + hasHandledNewChatRequest, initialTheme, initialCreationMode, + markNewChatRequestHandled, projectId, + clearProjectSelectionRuntime, + resetProjectSelection, ]); const handleBackHome = useCallback(() => { @@ -6900,77 +6941,13 @@ export function AgentChatWorkspace({ setTaskFiles([]); setSelectedFileId(undefined); processedMessageIds.current.clear(); - pendingTopicSwitchRef.current = null; - isResolvingTopicProjectRef.current = false; - setInternalProjectId(null); + resetProjectSelection(); 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]); + }, [clearMessages, _onNavigate, resetProjectSelection]); const displayMessages = useMemo(() => { const collapsedMessages = collapseLegacyQuestionnaireMessages(messages); @@ -7243,6 +7220,9 @@ export function AgentChatWorkspace({ const handleToggleCanvas = useCallback(() => { // General 主题使用专门的画布 if (activeTheme === "general") { + if (layoutMode !== "chat" && isBrowserAssistCanvasVisible) { + suppressBrowserAssistCanvasAutoOpen(); + } setGeneralCanvasState((prev) => ({ ...prev, isOpen: !prev.isOpen, @@ -7270,17 +7250,34 @@ export function AgentChatWorkspace({ } return "chat"; }); - }, [canvasState, mappedTheme, activeTheme]); + }, [ + activeTheme, + canvasState, + isBrowserAssistCanvasVisible, + layoutMode, + mappedTheme, + suppressBrowserAssistCanvasAutoOpen, + ]); // 关闭画布 const handleCloseCanvas = useCallback(() => { + if ( + activeTheme === "general" && + currentCanvasArtifact?.type === "browser_assist" + ) { + suppressBrowserAssistCanvasAutoOpen(); + } setLayoutMode("chat"); setNovelChapterListCollapsed(false); // General 主题关闭画布状态 if (activeTheme === "general") { setGeneralCanvasState((prev) => ({ ...prev, isOpen: false })); } - }, [activeTheme]); + }, [ + activeTheme, + currentCanvasArtifact?.type, + suppressBrowserAssistCanvasAutoOpen, + ]); const resolvedCanvasState = useMemo(() => { if (canvasState) { @@ -7437,8 +7434,7 @@ export function AgentChatWorkspace({ "浏览器协助"; if (currentUrl === url) { - setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - setLayoutMode("chat-canvas"); + openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); return true; } @@ -7495,8 +7491,7 @@ export function AgentChatWorkspace({ updatedAt: Date.now(), }), ); - setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - setLayoutMode("chat-canvas"); + openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); if (!options?.silent) { toast.success(`已切换浏览器页面:${nextTitle}`); @@ -7520,7 +7515,7 @@ export function AgentChatWorkspace({ browserAssistArtifact, browserAssistSessionState, commitBrowserAssistSessionState, - setSelectedArtifactId, + openBrowserAssistCanvas, ], ); @@ -7557,8 +7552,7 @@ export function AgentChatWorkspace({ ); if (hasSessionContext) { - setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - setLayoutMode("chat-canvas"); + openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); if (!targetUrl) { return true; } @@ -7574,8 +7568,7 @@ export function AgentChatWorkspace({ resolveBrowserAssistSessionScopeKey(projectId, sessionId); const launchKey = `${GENERAL_BROWSER_ASSIST_PROFILE_KEY}:${targetUrl}`; if (autoLaunchingBrowserAssistKeyRef.current === launchKey) { - setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - setLayoutMode("chat-canvas"); + openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); return true; } autoLaunchingBrowserAssistKeyRef.current = launchKey; @@ -7587,8 +7580,7 @@ export function AgentChatWorkspace({ title: "浏览器协助", }), ); - setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - setLayoutMode("chat-canvas"); + openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); setBrowserAssistLaunching(true); try { @@ -7619,8 +7611,7 @@ export function AgentChatWorkspace({ updatedAt: Date.now(), }), ); - setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - setLayoutMode("chat-canvas"); + openBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); if (!options?.silent) { toast.success( @@ -7663,9 +7654,9 @@ export function AgentChatWorkspace({ commitBrowserAssistSessionState, navigateBrowserAssistCanvasToUrl, currentBrowserAssistScopeKey, + openBrowserAssistCanvas, projectId, sessionId, - setSelectedArtifactId, upsertGeneralArtifact, ], ); @@ -7849,17 +7840,16 @@ export function AgentChatWorkspace({ }:${browserAssistSessionState.url || currentUrl || "pending"}`; if (autoOpenedBrowserAssistSessionIdRef.current !== autoOpenKey) { autoOpenedBrowserAssistSessionIdRef.current = autoOpenKey; - setSelectedArtifactId(nextArtifact.id); - setLayoutMode("chat-canvas"); + autoOpenBrowserAssistCanvas(nextArtifact.id); } }, [ activeTheme, + autoOpenBrowserAssistCanvas, browserAssistArtifact, currentBrowserAssistScopeKey, browserAssistSessionState, projectId, sessionId, - setSelectedArtifactId, upsertGeneralArtifact, ]); @@ -7904,8 +7894,7 @@ export function AgentChatWorkspace({ title: nextTitle, }), ); - setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - setLayoutMode("chat-canvas"); + autoOpenBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); const launchRequestId = browserAssistLaunchRequestIdRef.current + 1; browserAssistLaunchRequestIdRef.current = launchRequestId; void (async () => { @@ -7941,8 +7930,7 @@ export function AgentChatWorkspace({ updatedAt: Date.now(), }), ); - setSelectedArtifactId(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); - setLayoutMode("chat-canvas"); + autoOpenBrowserAssistCanvas(GENERAL_BROWSER_ASSIST_ARTIFACT_ID); } catch (error) { upsertGeneralArtifact( buildFailedBrowserAssistArtifact({ @@ -7963,12 +7951,12 @@ export function AgentChatWorkspace({ })(); }, [ activeTheme, + autoOpenBrowserAssistCanvas, browserAssistSessionState, commitBrowserAssistSessionState, currentBrowserAssistScopeKey, projectId, sessionId, - setSelectedArtifactId, upsertGeneralArtifact, ]); @@ -8924,7 +8912,14 @@ export function AgentChatWorkspace({ !isSending && canvasEmpty ) { + if (!initialDispatchKey && themeWorkbenchEntryCheckPending) { + return; + } + if (initialDispatchKey) { + if (isThemeWorkbench && pendingInitialImages.length === 0) { + return; + } if (consumedInitialPromptRef.current === initialDispatchKey) { return; } @@ -8951,6 +8946,10 @@ export function AgentChatWorkspace({ return; } + if (themeWorkbenchEntryPrompt?.kind === "resume") { + return; + } + if (defaultGuidePrompt) { hasTriggeredGuide.current = true; setInput((previous) => previous.trim() || defaultGuidePrompt); @@ -8966,12 +8965,11 @@ export function AgentChatWorkspace({ // 同步创建后端工作流(不阻塞触发) void (async () => { try { - const { contentWorkflowApi } = - await import("@/lib/api/content-workflow"); - const themeForApi = - mappedTheme as import("@/lib/api/content-workflow").ThemeType; + const themeForApi = mappedTheme as Parameters< + typeof contentWorkflowApi.create + >[1]; const modeForApi = - (creationMode as import("@/lib/api/content-workflow").CreationMode) ?? + (creationMode as Parameters[2]) ?? "guided"; await contentWorkflowApi.create( contentId!, @@ -9014,6 +9012,8 @@ export function AgentChatWorkspace({ onInitialUserPromptConsumed, shouldUseCompactThemeWorkbench, shouldSkipThemeWorkbenchAutoGuideWithoutPrompt, + themeWorkbenchEntryCheckPending, + themeWorkbenchEntryPrompt, ]); // 通用聊天场景:若带有 initialUserPrompt,则自动新建并发送首条消息 @@ -9075,8 +9075,6 @@ export function AgentChatWorkspace({ void (async () => { try { - const { contentWorkflowApi } = - await import("@/lib/api/content-workflow"); const workflow = await contentWorkflowApi.getByContent(contentId); if (workflow) { const completedCount = workflow.steps.filter( @@ -9384,15 +9382,9 @@ export function AgentChatWorkspace({ const handleProjectChange = useCallback( (newProjectId: string) => { - if (externalProjectId) { - return; - } - pendingTopicSwitchRef.current = null; - isResolvingTopicProjectRef.current = false; - savePersistedProjectId(LAST_PROJECT_ID_KEY, newProjectId); - setInternalProjectId(newProjectId); + applyProjectSelection(newProjectId); }, - [externalProjectId], + [applyProjectSelection], ); const handleSelectWorkspaceDirectory = useCallback(async () => { @@ -9406,7 +9398,15 @@ export function AgentChatWorkspace({ try { await updateProjectById(projectId, { rootPath: newPath }); setWorkspaceHealthError(false); - toast.success("工作区目录已更新"); + notifyProjectRuntimeAgentsGuide( + { + id: projectId, + rootPath: newPath, + }, + { + successMessage: "工作区目录已更新", + }, + ); } catch (err) { toast.error( `更新路径失败: ${err instanceof Error ? err.message : String(err)}`, @@ -9576,6 +9576,7 @@ export function AgentChatWorkspace({ setProviderType={setProviderType} model={model} setModel={setModel} + workspaceId={projectId ?? null} executionStrategy={executionStrategy} setExecutionStrategy={setExecutionStrategy} activeTheme={activeTheme} @@ -9593,38 +9594,44 @@ export function AgentChatWorkspace({ onToggleTaskFiles={handleToggleTaskFiles} onTaskFileClick={handleTaskFileClick} overlayAccessory={ - shouldShowThemeWorkbenchFloatingInputOverlay && - showTeamWorkspaceBoard ? ( - - ) : null + <> + {themeWorkbenchEntryPromptAccessory} + {shouldShowThemeWorkbenchFloatingInputOverlay && + showTeamWorkspaceBoard && + layoutMode === "chat" ? ( + + ) : null} + } characters={projectMemory?.characters || []} skills={skills} @@ -9645,6 +9652,7 @@ export function AgentChatWorkspace({ currentGate, executionStrategy, handleClearMessages, + handleActivateTeamWorkbench, handleManageProviders, handleNavigateToSkillSettings, handleRefreshSkills, @@ -9666,6 +9674,7 @@ export function AgentChatWorkspace({ removeQueuedTurn, setExecutionStrategy, setInput, + setChatToolPreferences, setModel, setProviderType, selectedTeam, @@ -9681,6 +9690,7 @@ export function AgentChatWorkspace({ visibleTaskFiles, taskFilesExpanded, themeWorkbenchRunState, + themeWorkbenchEntryPromptAccessory, themeWorkbenchWorkflowSteps, handleInputbarA2UISubmit, childSubagentSessions, @@ -9705,6 +9715,7 @@ export function AgentChatWorkspace({ teamActivityRefreshVersionBySessionId, teamLiveActivityBySessionId, teamLiveRuntimeBySessionId, + runtimeTeamState, a2uiSubmissionNotice, ], ); @@ -9736,7 +9747,7 @@ export function AgentChatWorkspace({ onRefreshToolInventory={refreshToolInventory} layout="dialog" title="Agent 工作台" - description="集中查看计划、审批、子代理、文件活动与工具产物。" + description="集中查看计划、审批、协作成员、文件活动与工具产物。" toggleLabel="工作台详情" leadContent={ +
-
+
{isInitialContentLoading ? "正在加载文稿内容..." @@ -10124,6 +10135,193 @@ export function AgentChatWorkspace({ ], ); + const renderTeamWorkbenchPreview = useCallback( + (stackedWorkbenchTrigger?: ReactNode) => + wrapPreviewWithWorkbenchTrigger( +
+
+ +
+
, + stackedWorkbenchTrigger, + ), + [ + chatToolPreferences.subagent, + childSubagentSessions, + currentSessionLatestTurnStatus, + currentSessionRuntimeStatus, + currentSessionTitle, + handleCloseCompletedTeamSessions, + handleCloseSubagentSession, + handleOpenSubagentSession, + handleResumeSubagentSession, + handleReturnToParentSession, + handleSendSubagentInput, + handleWaitActiveTeamSessions, + handleWaitSubagentSession, + queuedTurns.length, + sessionId, + subagentParentContext, + teamActivityRefreshVersionBySessionId, + teamControlSummary, + teamLiveActivityBySessionId, + teamLiveRuntimeBySessionId, + teamWaitSummary, + runtimeTeamState, + selectedTeam?.roles, + selectedTeamLabel, + selectedTeamSummary, + ], + ); + + const teamWorkbenchSummaryPanel = useMemo( + () => ( + + ), + [ + childSubagentSessions, + currentSessionLatestTurnStatus, + currentSessionRuntimeStatus, + queuedTurns.length, + selectedTeam?.roles, + selectedTeamLabel, + selectedTeamSummary, + sessionId, + subagentParentContext, + teamControlSummary, + teamLiveActivityBySessionId, + teamLiveRuntimeBySessionId, + teamWaitSummary, + runtimeTeamState, + ], + ); + + const teamWorkbenchExecutionSummary = useMemo( + () => + summarizeTeamWorkspaceExecution({ + currentSessionId: sessionId, + currentSessionRuntimeStatus, + currentSessionLatestTurnStatus, + currentSessionQueuedTurnCount: queuedTurns.length, + childSubagentSessions, + subagentParentContext, + liveRuntimeBySessionId: teamLiveRuntimeBySessionId, + }), + [ + childSubagentSessions, + currentSessionLatestTurnStatus, + currentSessionRuntimeStatus, + queuedTurns.length, + sessionId, + subagentParentContext, + teamLiveRuntimeBySessionId, + ], + ); + + const teamWorkbenchTriggerState = useMemo(() => { + if (!showTeamWorkspaceBoard) { + return null; + } + + if (runtimeTeamState?.status === "failed") { + return { tone: "error" as const, label: "失败" }; + } + + if (runtimeTeamState?.status === "forming") { + return { tone: "active" as const, label: "组建中" }; + } + + if (teamWorkbenchExecutionSummary.runningSessionCount > 0) { + return { + tone: "active" as const, + label: + teamWorkbenchExecutionSummary.runningSessionCount > 1 + ? `${teamWorkbenchExecutionSummary.runningSessionCount} 运行中` + : "运行中", + }; + } + + if (teamWorkbenchExecutionSummary.queuedSessionCount > 0) { + return { + tone: "active" as const, + label: + teamWorkbenchExecutionSummary.queuedSessionCount > 1 + ? `${teamWorkbenchExecutionSummary.queuedSessionCount} 排队` + : "排队中", + }; + } + + if (runtimeTeamState?.status === "formed" && !hasRealTeamGraph) { + return { tone: "active" as const, label: "已就绪" }; + } + + if ( + Object.values(teamLiveActivityBySessionId).some( + (entries) => (entries?.length ?? 0) > 0, + ) || + Boolean(teamWaitSummary) || + Boolean(teamControlSummary) + ) { + return { tone: "active" as const, label: "有更新" }; + } + + return { tone: "idle" as const, label: null }; + }, [ + hasRealTeamGraph, + runtimeTeamState, + showTeamWorkspaceBoard, + teamControlSummary, + teamLiveActivityBySessionId, + teamWaitSummary, + teamWorkbenchExecutionSummary.queuedSessionCount, + teamWorkbenchExecutionSummary.runningSessionCount, + ]); + const renderCanvasWorkbenchPreview = useCallback( ( target: CanvasWorkbenchPreviewTarget, @@ -10144,7 +10342,7 @@ export function AgentChatWorkspace({ return wrapPreviewWithWorkbenchTrigger(
正在准备预览...
, @@ -10154,7 +10352,7 @@ export function AgentChatWorkspace({ return wrapPreviewWithWorkbenchTrigger(
{target.reason}
, @@ -10164,23 +10362,67 @@ export function AgentChatWorkspace({ return wrapPreviewWithWorkbenchTrigger(
暂无可预览内容
, options?.stackedWorkbenchTrigger, ); + case "team-workbench": + return renderTeamWorkbenchPreview(options?.stackedWorkbenchTrigger); default: return null; } }, - [renderArtifactWorkbenchPreview, renderLiveCanvasPreview], + [ + renderArtifactWorkbenchPreview, + renderLiveCanvasPreview, + renderTeamWorkbenchPreview, + ], ); const shouldRenderInlineA2UI = isContentCreationMode; const isWorkspaceCompactChrome = topBarChrome === "workspace-compact"; const shouldRenderBrandedEmptyState = !showChatLayout; const shouldRenderTopBar = !hideTopBar && !shouldRenderBrandedEmptyState; + const teamWorkbenchView = useMemo( + () => + showTeamWorkspaceBoard + ? { + enabled: true, + title: + runtimeTeamState?.label?.trim() || + runtimeTeamState?.blueprint?.label?.trim() || + selectedTeamLabel || + "Team Workbench", + subtitle: hasRealTeamGraph + ? "画布展示多成员实时协作,右侧侧栏展示总览与角色结构。" + : runtimeTeamState?.status === "forming" + ? "正在根据本轮任务准备 Team,随后会在画布中展示成员编队。" + : runtimeTeamState?.status === "formed" + ? "本轮 Team 已就绪,画布展示当前成员与后续真实协作轨道。" + : runtimeTeamState?.status === "failed" + ? runtimeTeamState.errorMessage?.trim() || + "本轮 Team 准备失败,可直接查看失败原因并继续当前对话。" + : "Team 模式已启用,等待系统创建真实团队成员。", + autoFocusToken: teamWorkbenchAutoFocusToken, + triggerState: teamWorkbenchTriggerState, + renderPreview: (options?: { stackedWorkbenchTrigger?: ReactNode }) => + renderTeamWorkbenchPreview(options?.stackedWorkbenchTrigger), + renderPanel: () => teamWorkbenchSummaryPanel, + } + : null, + [ + hasRealTeamGraph, + renderTeamWorkbenchPreview, + runtimeTeamState, + selectedTeamLabel, + showTeamWorkspaceBoard, + teamWorkbenchTriggerState, + teamWorkbenchAutoFocusToken, + teamWorkbenchSummaryPanel, + ], + ); const themeWorkbenchLayoutBottomSpacing = resolveThemeWorkbenchLayoutBottomSpacing({ contextWorkspaceEnabled: contextWorkspace.enabled, @@ -10290,9 +10532,11 @@ export function AgentChatWorkspace({ /> )} {showTeamWorkspaceBoard && - !shouldShowThemeWorkbenchFloatingInputOverlay ? ( + !shouldShowThemeWorkbenchFloatingInputOverlay && + layoutMode === "chat" ? ( ) : null} @@ -10473,6 +10718,7 @@ export function AgentChatWorkspace({ generalCanvasState.content, goToStep, handleA2UISubmit, + handleActivateTeamWorkbench, handleArtifactClick, handleCloseCompletedTeamSessions, handleCloseSubagentSession, @@ -10502,6 +10748,7 @@ export function AgentChatWorkspace({ isContentCreationMode, isThemeWorkbench, isWorkspaceCompactChrome, + layoutMode, lockTheme, displayMessages, model, @@ -10513,6 +10760,7 @@ export function AgentChatWorkspace({ pendingPromotedA2UIActionRequest, setCreationMode, setExecutionStrategy, + setChatToolPreferences, setInput, setModel, setProviderType, @@ -10546,6 +10794,7 @@ export function AgentChatWorkspace({ subagentParentContext, teamControlSummary, teamWaitSummary, + runtimeTeamState, workspaceHealthError, workspacePathMissing, resolvedCanvasState, @@ -10561,11 +10810,14 @@ export function AgentChatWorkspace({ // 画布区域内容 const canvasContent = useMemo(() => { const liveCanvasPreview = renderLiveCanvasPreview(); - if (!liveCanvasPreview) { + if (!liveCanvasPreview && !teamWorkbenchView) { return null; } - if (shouldShowCanvasLoadingState || isBrowserAssistCanvasVisible) { + if ( + !teamWorkbenchView && + (shouldShowCanvasLoadingState || isBrowserAssistCanvasVisible) + ) { return liveCanvasPreview; } @@ -10585,6 +10837,7 @@ export function AgentChatWorkspace({ onRevealPath={handleRevealCanvasWorkbenchPath} renderPreview={renderCanvasWorkbenchPreview} onLayoutModeChange={setCanvasWorkbenchLayoutMode} + teamView={teamWorkbenchView} /> ); }, [ @@ -10602,6 +10855,7 @@ export function AgentChatWorkspace({ isBrowserAssistCanvasVisible, setCanvasWorkbenchLayoutMode, taskFiles, + teamWorkbenchView, workspaceHealthError, workspacePathMissing, ]); diff --git a/src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.test.tsx b/src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.test.tsx new file mode 100644 index 000000000..24eded863 --- /dev/null +++ b/src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.test.tsx @@ -0,0 +1,126 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ClawHomeSolutionsPanel } from "./ClawHomeSolutionsPanel"; +import type { ClawSolutionHomeItem } from "./types"; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +function renderPanel( + props: React.ComponentProps, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + mountedRoots.push({ root, container }); + return container; +} + +describe("ClawHomeSolutionsPanel", () => { + it("应渲染方案信息并透传选择回调", () => { + const solutions: ClawSolutionHomeItem[] = [ + { + id: "social-post-starter", + title: "社媒主稿生成", + summary: "进入社媒专项工作台并生成一版首稿。", + outputHint: "社媒首稿 + 平台结构", + recommendedCapabilities: ["模型", "社媒主题"], + readiness: "ready", + readinessMessage: "可直接开始", + badge: "社媒方案", + recentUsedAt: null, + isRecent: false, + readinessLabel: "可直接开始", + readinessTone: "emerald", + }, + ]; + const onSelect = vi.fn(); + + const container = renderPanel({ + solutions, + onSelect, + }); + + expect(container.textContent).toContain("推荐方案"); + expect(container.textContent).toContain("社媒主稿生成"); + expect(container.textContent).toContain("产出:社媒首稿 + 平台结构"); + expect(container.textContent).toContain("可直接开始"); + expect(container.textContent).toContain("立即开始"); + + const solutionButton = container.querySelector( + '[data-testid="claw-solution-social-post-starter"]', + ) as HTMLButtonElement | null; + + expect(solutionButton).toBeTruthy(); + + act(() => { + solutionButton?.click(); + }); + + expect(onSelect).toHaveBeenCalledWith(solutions[0]); + }); + + it("加载中且无方案时应展示加载状态", () => { + const container = renderPanel({ + solutions: [], + loading: true, + onSelect: vi.fn(), + }); + + expect(container.textContent).toContain("正在加载推荐方案"); + }); + + it("未就绪方案应展示去配置动作文案", () => { + const solutions: ClawSolutionHomeItem[] = [ + { + id: "web-research-brief", + title: "网页研究简报", + summary: "先配置模型后再开始。", + outputHint: "研究提纲 + 结论摘要", + recommendedCapabilities: ["模型"], + readiness: "needs_setup", + readinessMessage: "请先配置模型", + reasonCode: "missing_model", + badge: "Claw 方案", + recentUsedAt: null, + isRecent: false, + readinessLabel: "先配置模型", + readinessTone: "amber", + }, + ]; + + const container = renderPanel({ + solutions, + onSelect: vi.fn(), + }); + + expect(container.textContent).toContain("先配置模型"); + expect(container.textContent).toContain("去配置"); + }); +}); diff --git a/src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.tsx b/src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.tsx new file mode 100644 index 000000000..94997be24 --- /dev/null +++ b/src/components/agent/chat/claw-solutions/ClawHomeSolutionsPanel.tsx @@ -0,0 +1,54 @@ +import { useMemo } from "react"; +import { EmptyStateQuickActions } from "../components/EmptyStateQuickActions"; +import type { ClawSolutionHomeItem } from "./types"; + +interface ClawHomeSolutionsPanelProps { + solutions: ClawSolutionHomeItem[]; + loading?: boolean; + onSelect: (solution: ClawSolutionHomeItem) => void | Promise; +} + +export function ClawHomeSolutionsPanel({ + solutions, + loading = false, + onSelect, +}: ClawHomeSolutionsPanelProps) { + const items = useMemo( + () => + solutions.map((solution) => ({ + key: solution.id, + title: solution.title, + description: solution.summary, + badge: solution.badge, + prompt: "", + actionLabel: solution.readiness === "ready" ? "立即开始" : "去配置", + outputHint: solution.outputHint, + statusLabel: solution.readinessLabel, + statusTone: solution.readinessTone, + statusDescription: solution.readinessMessage, + solutionId: solution.id, + testId: `claw-solution-${solution.id}`, + })), + [solutions], + ); + + return ( + { + const solution = solutions.find( + (candidate) => candidate.id === item.solutionId, + ); + if (solution) { + void onSelect(solution); + } + }} + /> + ); +} + +export default ClawHomeSolutionsPanel; diff --git a/src/components/agent/chat/claw-solutions/actionDispatcher.test.ts b/src/components/agent/chat/claw-solutions/actionDispatcher.test.ts new file mode 100644 index 000000000..f9ac59ca1 --- /dev/null +++ b/src/components/agent/chat/claw-solutions/actionDispatcher.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { + enableSubagentPreference, + resolveClawSolutionLaunch, + resolveClawSolutionSetupTarget, +} from "./actionDispatcher"; +import type { ChatToolPreferences } from "../utils/chatToolPreferences"; +import type { ClawSolutionPreparation } from "./types"; +import { SettingsTabs } from "@/types/settings"; + +const defaultPreferences: ChatToolPreferences = { + webSearch: false, + thinking: false, + task: false, + subagent: false, +}; + +describe("claw solution action dispatcher", () => { + it("应在未开启时启用多代理偏好", () => { + expect(enableSubagentPreference(defaultPreferences)).toEqual({ + nextToolPreferences: { + webSearch: false, + thinking: false, + task: false, + subagent: true, + }, + changed: true, + }); + }); + + it("已开启多代理时应保持原偏好", () => { + const current = { + ...defaultPreferences, + subagent: true, + }; + + expect(enableSubagentPreference(current)).toEqual({ + nextToolPreferences: current, + changed: false, + }); + }); + + it("应把社媒方案转换为工作区进入载荷", () => { + const preparation: ClawSolutionPreparation = { + solutionId: "social-post-starter", + actionType: "navigate_theme", + prompt: "请先帮我起草一版社媒内容首稿", + themeTarget: "social-media", + shouldLaunchBrowserAssist: false, + shouldEnableTeamMode: false, + readiness: "ready", + readinessMessage: "可直接开始", + }; + + expect(resolveClawSolutionLaunch(preparation, defaultPreferences)).toEqual({ + nextToolPreferences: defaultPreferences, + preferencesChanged: false, + shouldStartBrowserAssistLoading: false, + enterWorkspacePayload: { + prompt: "请先帮我起草一版社媒内容首稿", + openBrowserAssistOnMount: false, + toolPreferences: defaultPreferences, + themeOverride: "social-media", + }, + usageRecord: { + solutionId: "social-post-starter", + actionType: "navigate_theme", + themeTarget: "social-media", + }, + }); + }); + + it("应把多代理方案转换为开启 team 模式的载荷", () => { + const preparation: ClawSolutionPreparation = { + solutionId: "team-breakdown", + actionType: "enable_team_mode", + prompt: "请把这个任务按多代理方式拆解", + shouldLaunchBrowserAssist: false, + shouldEnableTeamMode: true, + readiness: "ready", + readinessMessage: "可直接开始,进入后会启用多代理偏好", + }; + + expect(resolveClawSolutionLaunch(preparation, defaultPreferences)).toEqual({ + nextToolPreferences: { + webSearch: false, + thinking: false, + task: false, + subagent: true, + }, + preferencesChanged: true, + shouldStartBrowserAssistLoading: false, + enterWorkspacePayload: { + prompt: "请把这个任务按多代理方式拆解", + openBrowserAssistOnMount: false, + toolPreferences: { + webSearch: false, + thinking: false, + task: false, + subagent: true, + }, + themeOverride: undefined, + }, + usageRecord: { + solutionId: "team-breakdown", + actionType: "enable_team_mode", + themeTarget: null, + }, + }); + }); + + it("应把未就绪方案映射到对应配置入口", () => { + expect( + resolveClawSolutionSetupTarget("needs_setup", "missing_model"), + ).toBe(SettingsTabs.Providers); + expect( + resolveClawSolutionSetupTarget( + "needs_capability", + "missing_skill_dependency", + ), + ).toBe(SettingsTabs.Skills); + expect( + resolveClawSolutionSetupTarget( + "needs_capability", + "missing_browser_capability", + ), + ).toBe(SettingsTabs.ChromeRelay); + }); +}); diff --git a/src/components/agent/chat/claw-solutions/actionDispatcher.ts b/src/components/agent/chat/claw-solutions/actionDispatcher.ts new file mode 100644 index 000000000..43ee7e5d5 --- /dev/null +++ b/src/components/agent/chat/claw-solutions/actionDispatcher.ts @@ -0,0 +1,95 @@ +import { SettingsTabs } from "@/types/settings"; +import type { ChatToolPreferences } from "../utils/chatToolPreferences"; +import type { + ClawSolutionPreparation, + ClawSolutionReadiness, + ClawSolutionReasonCode, + RecordClawSolutionUsageInput, +} from "./types"; + +export interface ClawSolutionEnterWorkspacePayload { + prompt: string; + openBrowserAssistOnMount?: boolean; + toolPreferences: ChatToolPreferences; + themeOverride?: string; +} + +export interface ResolvedClawSolutionLaunch { + nextToolPreferences: ChatToolPreferences; + preferencesChanged: boolean; + shouldStartBrowserAssistLoading: boolean; + enterWorkspacePayload: ClawSolutionEnterWorkspacePayload; + usageRecord: RecordClawSolutionUsageInput; +} + +export function resolveClawSolutionSetupTarget( + readiness: ClawSolutionReadiness, + reasonCode?: ClawSolutionReasonCode, +): SettingsTabs | null { + if (readiness === "needs_setup") { + return SettingsTabs.Providers; + } + + if (readiness !== "needs_capability") { + return null; + } + + if (reasonCode === "missing_skill_dependency") { + return SettingsTabs.Skills; + } + + if (reasonCode === "missing_browser_capability") { + return SettingsTabs.ChromeRelay; + } + + return SettingsTabs.Providers; +} + +export function enableSubagentPreference(preferences: ChatToolPreferences): { + nextToolPreferences: ChatToolPreferences; + changed: boolean; +} { + if (preferences.subagent) { + return { + nextToolPreferences: preferences, + changed: false, + }; + } + + return { + nextToolPreferences: { + ...preferences, + subagent: true, + }, + changed: true, + }; +} + +export function resolveClawSolutionLaunch( + preparation: ClawSolutionPreparation, + currentToolPreferences: ChatToolPreferences, +): ResolvedClawSolutionLaunch { + const { nextToolPreferences, changed } = preparation.shouldEnableTeamMode + ? enableSubagentPreference(currentToolPreferences) + : { + nextToolPreferences: currentToolPreferences, + changed: false, + }; + + return { + nextToolPreferences, + preferencesChanged: changed, + shouldStartBrowserAssistLoading: preparation.shouldLaunchBrowserAssist, + enterWorkspacePayload: { + prompt: preparation.prompt, + openBrowserAssistOnMount: preparation.shouldLaunchBrowserAssist, + toolPreferences: nextToolPreferences, + themeOverride: preparation.themeTarget, + }, + usageRecord: { + solutionId: preparation.solutionId, + actionType: preparation.actionType, + themeTarget: preparation.themeTarget ?? null, + }, + }; +} diff --git a/src/components/agent/chat/claw-solutions/storage.test.ts b/src/components/agent/chat/claw-solutions/storage.test.ts new file mode 100644 index 000000000..8feb63245 --- /dev/null +++ b/src/components/agent/chat/claw-solutions/storage.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + getClawSolutionUsageMap, + listClawSolutionUsage, + recordClawSolutionUsage, +} from "./storage"; + +describe("claw solution storage", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it("应按最近时间记录方案使用", () => { + recordClawSolutionUsage({ + solutionId: "web-research-brief", + usedAt: 100, + actionType: "fill_input", + }); + recordClawSolutionUsage({ + solutionId: "team-breakdown", + usedAt: 300, + actionType: "enable_team_mode", + }); + recordClawSolutionUsage({ + solutionId: "web-research-brief", + usedAt: 500, + actionType: "fill_input", + themeTarget: "general", + }); + + expect(listClawSolutionUsage()).toEqual([ + { + solutionId: "web-research-brief", + usedAt: 500, + actionType: "fill_input", + themeTarget: "general", + }, + { + solutionId: "team-breakdown", + usedAt: 300, + actionType: "enable_team_mode", + themeTarget: null, + }, + ]); + }); + + it("应提供按方案 ID 查询的映射", () => { + recordClawSolutionUsage({ + solutionId: "social-post-starter", + usedAt: 200, + actionType: "navigate_theme", + themeTarget: "social-media", + }); + + const usageMap = getClawSolutionUsageMap(); + expect(usageMap.get("social-post-starter")).toEqual({ + solutionId: "social-post-starter", + usedAt: 200, + actionType: "navigate_theme", + themeTarget: "social-media", + }); + }); +}); diff --git a/src/components/agent/chat/claw-solutions/storage.ts b/src/components/agent/chat/claw-solutions/storage.ts new file mode 100644 index 000000000..f81f451f9 --- /dev/null +++ b/src/components/agent/chat/claw-solutions/storage.ts @@ -0,0 +1,87 @@ +import type { + ClawSolutionUsageRecord, + RecordClawSolutionUsageInput, +} from "./types"; + +const CLAW_SOLUTION_USAGE_STORAGE_KEY = "lime:claw-solution-usage:v1"; +const MAX_CLAW_SOLUTION_USAGE_RECORDS = 12; + +function isValidUsageRecord(value: unknown): value is ClawSolutionUsageRecord { + if (!value || typeof value !== "object") { + return false; + } + + const record = value as Partial; + return ( + typeof record.solutionId === "string" && + record.solutionId.length > 0 && + typeof record.usedAt === "number" && + Number.isFinite(record.usedAt) + ); +} + +export function listClawSolutionUsage(): ClawSolutionUsageRecord[] { + if (typeof window === "undefined") { + return []; + } + + try { + const raw = window.localStorage.getItem(CLAW_SOLUTION_USAGE_STORAGE_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter(isValidUsageRecord) + .sort((left, right) => right.usedAt - left.usedAt) + .slice(0, MAX_CLAW_SOLUTION_USAGE_RECORDS); + } catch { + return []; + } +} + +export function getClawSolutionUsageMap(): Map< + string, + ClawSolutionUsageRecord +> { + return new Map( + listClawSolutionUsage().map((record) => [record.solutionId, record]), + ); +} + +export function recordClawSolutionUsage( + input: RecordClawSolutionUsageInput, +): ClawSolutionUsageRecord[] { + const nextRecord: ClawSolutionUsageRecord = { + solutionId: input.solutionId, + usedAt: input.usedAt ?? Date.now(), + actionType: input.actionType, + themeTarget: input.themeTarget ?? null, + }; + + const nextRecords = [ + nextRecord, + ...listClawSolutionUsage().filter( + (record) => record.solutionId !== nextRecord.solutionId, + ), + ].slice(0, MAX_CLAW_SOLUTION_USAGE_RECORDS); + + if (typeof window === "undefined") { + return nextRecords; + } + + try { + window.localStorage.setItem( + CLAW_SOLUTION_USAGE_STORAGE_KEY, + JSON.stringify(nextRecords), + ); + } catch { + // ignore write errors + } + + return nextRecords; +} diff --git a/src/components/agent/chat/claw-solutions/types.ts b/src/components/agent/chat/claw-solutions/types.ts new file mode 100644 index 000000000..71fbdbb8a --- /dev/null +++ b/src/components/agent/chat/claw-solutions/types.ts @@ -0,0 +1,43 @@ +import type { + ClawSolutionActionType, + ClawSolutionDetail, + ClawSolutionPreparation, + ClawSolutionReadiness, + ClawSolutionReadinessResult, + ClawSolutionReasonCode, + ClawSolutionSummary, +} from "@/lib/api/clawSolutions"; + +export type { + ClawSolutionActionType, + ClawSolutionDetail, + ClawSolutionPreparation, + ClawSolutionReadiness, + ClawSolutionReadinessResult, + ClawSolutionReasonCode, + ClawSolutionSummary, +}; + +export type ClawSolutionTone = "slate" | "sky" | "emerald" | "amber"; + +export interface ClawSolutionHomeItem extends ClawSolutionSummary { + badge: string; + recentUsedAt: number | null; + isRecent: boolean; + readinessLabel: string; + readinessTone: ClawSolutionTone; +} + +export interface ClawSolutionUsageRecord { + solutionId: string; + usedAt: number; + actionType?: ClawSolutionActionType; + themeTarget?: string | null; +} + +export interface RecordClawSolutionUsageInput { + solutionId: string; + usedAt?: number; + actionType?: ClawSolutionActionType; + themeTarget?: string | null; +} diff --git a/src/components/agent/chat/claw-solutions/useClawSolutions.ts b/src/components/agent/chat/claw-solutions/useClawSolutions.ts new file mode 100644 index 000000000..d32d05e73 --- /dev/null +++ b/src/components/agent/chat/claw-solutions/useClawSolutions.ts @@ -0,0 +1,153 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { listClawSolutions } from "@/lib/api/clawSolutions"; +import { getClawSolutionUsageMap, recordClawSolutionUsage } from "./storage"; +import type { + ClawSolutionHomeItem, + ClawSolutionReadiness, + ClawSolutionTone, + ClawSolutionSummary, + RecordClawSolutionUsageInput, +} from "./types"; + +function getReadinessLabel(readiness: ClawSolutionReadiness): string { + if (readiness === "needs_setup") { + return "先配置模型"; + } + if (readiness === "needs_capability") { + return "补齐能力后开始"; + } + return "可直接开始"; +} + +function getReadinessTone(readiness: ClawSolutionReadiness): ClawSolutionTone { + if (readiness === "needs_setup") { + return "amber"; + } + if (readiness === "needs_capability") { + return "sky"; + } + return "emerald"; +} + +function getSolutionBadge( + summary: ClawSolutionSummary, + isRecent: boolean, +): string { + if (isRecent) { + return "最近使用"; + } + if (summary.id === "social-post-starter") { + return "社媒方案"; + } + if (summary.id === "browser-assist-task") { + return "浏览器协助"; + } + if (summary.id === "team-breakdown") { + return "多代理"; + } + return "Claw 方案"; +} + +function buildHomeItems( + summaries: ClawSolutionSummary[], +): ClawSolutionHomeItem[] { + const usageMap = getClawSolutionUsageMap(); + const items: InternalClawSolutionHomeItem[] = summaries.map( + (summary, index) => { + const recent = usageMap.get(summary.id); + const recentUsedAt = recent?.usedAt ?? null; + const isRecent = typeof recentUsedAt === "number"; + + return { + ...summary, + badge: getSolutionBadge(summary, isRecent), + recentUsedAt, + isRecent, + readinessLabel: getReadinessLabel(summary.readiness), + readinessTone: getReadinessTone(summary.readiness), + _sortIndex: index, + }; + }, + ); + + return items + .sort((left, right) => { + if (left.recentUsedAt && right.recentUsedAt) { + if (left.recentUsedAt !== right.recentUsedAt) { + return right.recentUsedAt - left.recentUsedAt; + } + } else if (left.recentUsedAt) { + return -1; + } else if (right.recentUsedAt) { + return 1; + } + + return left._sortIndex - right._sortIndex; + }) + .map(({ _sortIndex, ...item }) => item); +} + +type InternalClawSolutionHomeItem = ClawSolutionHomeItem & { + _sortIndex: number; +}; + +interface UseClawSolutionsResult { + solutions: ClawSolutionHomeItem[]; + isLoading: boolean; + error: string | null; + refresh: () => Promise; + recordUsage: (input: RecordClawSolutionUsageInput) => void; +} + +export function useClawSolutions(enabled = true): UseClawSolutionsResult { + const [summaries, setSummaries] = useState([]); + const [isLoading, setIsLoading] = useState(enabled); + const [error, setError] = useState(null); + const [usageVersion, setUsageVersion] = useState(0); + + const refresh = useCallback(async () => { + if (!enabled) { + setSummaries([]); + setError(null); + setIsLoading(false); + return; + } + + setIsLoading(true); + try { + const nextSummaries = await listClawSolutions(); + setSummaries(nextSummaries); + setError(null); + } catch (reason) { + setSummaries([]); + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setIsLoading(false); + } + }, [enabled]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const recordUsageAndRefresh = useCallback( + (input: RecordClawSolutionUsageInput) => { + recordClawSolutionUsage(input); + setUsageVersion((previous) => previous + 1); + }, + [], + ); + + const solutions = useMemo(() => { + void usageVersion; + return buildHomeItems(summaries); + }, [summaries, usageVersion]); + + return { + solutions, + isLoading, + error, + refresh, + recordUsage: recordUsageAndRefresh, + }; +} diff --git a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx index 06867b365..ec5506870 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.test.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.test.tsx @@ -758,6 +758,9 @@ describe("AgentThreadTimeline", () => { clickTimelineToggle(container); + expect(container.textContent).toContain("图片任务 1"); + expect(container.textContent).not.toContain("Image #1"); + const button = Array.from( container.querySelectorAll("button"), ).find((element) => element.textContent?.includes("打开子会话")); diff --git a/src/components/agent/chat/components/AgentThreadTimeline.tsx b/src/components/agent/chat/components/AgentThreadTimeline.tsx index 6f347364f..eedcf7fd8 100644 --- a/src/components/agent/chat/components/AgentThreadTimeline.tsx +++ b/src/components/agent/chat/components/AgentThreadTimeline.tsx @@ -35,6 +35,7 @@ import { type AgentThreadSummaryChip, } from "../utils/agentThreadGrouping"; import { isActionRequestA2UICompatible } from "../utils/actionRequestA2UI"; +import { resolveInternalImageTaskDisplayName } from "../utils/internalImagePlaceholder"; import { parseAIResponse } from "@/components/content-creator/a2ui/parser"; import type { A2UIResponse } from "@/components/content-creator/a2ui/types"; import { TIMELINE_A2UI_TASK_CARD_PRESET } from "@/components/content-creator/a2ui/taskCardPresets"; @@ -725,11 +726,13 @@ function renderGroupItemDetails( if (item.type === "subagent_activity") { const subagentSessionId = item.session_id?.trim(); + const displayTitle = + resolveInternalImageTaskDisplayName(item.title) || "子代理协作"; return ( {item.status_label} diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx index bf0d8c46e..1392aa399 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx @@ -48,6 +48,7 @@ vi.mock("@/lib/api/fileBrowser", () => ({ interface MountedHarness { container: HTMLDivElement; root: Root; + rerender: (props: React.ComponentProps) => void; } const mountedRoots: MountedHarness[] = []; @@ -95,16 +96,34 @@ function createTaskFile( function mount( props: React.ComponentProps, ): HTMLDivElement { + return mountHarness(props).container; +} + +function mountHarness( + props: React.ComponentProps, +): MountedHarness { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); + let currentProps = props; act(() => { - root.render(); + root.render(); }); - mountedRoots.push({ container, root }); - return container; + const harness: MountedHarness = { + container, + root, + rerender: (nextProps) => { + currentProps = nextProps; + act(() => { + root.render(); + }); + }, + }; + + mountedRoots.push(harness); + return harness; } async function flushEffects(times = 6) { @@ -115,7 +134,7 @@ async function flushEffects(times = 6) { } } -async function resizeWorkbench(width: number) { +async function resizeWorkbench(width: number, height = 720) { resizeObserverState.width = width; await act(async () => { resizeObserverState.observers.forEach((observer) => { @@ -128,7 +147,7 @@ async function resizeWorkbench(width: number) { target: observer.target, contentRect: { width, - height: 720, + height, }, }, ], @@ -445,7 +464,187 @@ describe("CanvasWorkbenchLayout", () => { expect(container.textContent).toContain("该文件为二进制内容"); }); - it("容器变窄时应切换为右侧抽屉布局并保持工作台可展开收起", async () => { + it("启用 teamView 且没有默认预览时应优先展示 Team Workbench", async () => { + const renderPreview = vi.fn((_target: CanvasWorkbenchPreviewTarget) => ( +
fallback
+ )); + const renderTeamPreview = vi.fn( + (_options?: { stackedWorkbenchTrigger?: React.ReactNode }) => ( +
team-preview
+ ), + ); + const renderTeamPanel = vi.fn(() => ( +
team-panel
+ )); + + const container = mount({ + artifacts: [], + canvasState: null, + taskFiles: [], + workspaceRoot: "/workspace", + workspaceUnavailable: false, + defaultPreview: null, + loadFilePreview: vi.fn(async (path: string) => ({ + path, + content: "", + isBinary: false, + size: 0, + error: null, + })), + onOpenPath: vi.fn(async () => undefined), + onRevealPath: vi.fn(async () => undefined), + renderPreview, + teamView: { + enabled: true, + title: "Team Workbench", + subtitle: "多 agent 实时协作", + renderPreview: renderTeamPreview, + renderPanel: renderTeamPanel, + }, + }); + + await flushEffects(); + + expect(container.querySelector('[data-testid="team-preview"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="team-panel"]')).not.toBeNull(); + expect(container.textContent).toContain("多 agent 实时协作"); + expect(renderTeamPreview).toHaveBeenCalled(); + expect(renderTeamPanel).toHaveBeenCalled(); + expect(renderPreview).not.toHaveBeenCalled(); + }); + + it("teamView 的 autoFocusToken 变化时应切到 Team Workbench", async () => { + const renderPreview = vi.fn((target: CanvasWorkbenchPreviewTarget) => ( +
+ fallback:{target.kind} +
+ )); + const renderTeamPreview = vi.fn( + (_options?: { stackedWorkbenchTrigger?: React.ReactNode }) => ( +
team-preview
+ ), + ); + const renderTeamPanel = vi.fn(() => ( +
team-panel
+ )); + + const baseProps: React.ComponentProps = { + artifacts: [ + createArtifact("artifact-1", "draft.md", "标题\n当前内容", 20), + ], + canvasState: null, + taskFiles: [], + workspaceRoot: "/workspace", + workspaceUnavailable: false, + defaultPreview: { + selectionKey: "artifact:artifact-1", + title: "draft.md", + content: "标题\n当前内容", + filePath: "draft.md", + absolutePath: "/workspace/draft.md", + previousContent: null, + }, + loadFilePreview: vi.fn(async (path: string) => ({ + path, + content: "", + isBinary: false, + size: 0, + error: null, + })), + onOpenPath: vi.fn(async () => undefined), + onRevealPath: vi.fn(async () => undefined), + renderPreview, + teamView: { + enabled: true, + title: "Team Workbench", + subtitle: "多成员实时协作", + autoFocusToken: 1, + renderPreview: renderTeamPreview, + renderPanel: renderTeamPanel, + }, + }; + + const harness = mountHarness(baseProps); + await flushEffects(); + + expect(harness.container.querySelector('[data-testid="team-preview"]')).toBeNull(); + expect(harness.container.querySelector('[data-testid="team-panel"]')).toBeNull(); + expect(harness.container.querySelector('[data-testid="fallback-preview"]')).not.toBeNull(); + + harness.rerender({ + ...baseProps, + teamView: { + ...baseProps.teamView!, + autoFocusToken: 2, + }, + }); + await flushEffects(); + + expect(harness.container.querySelector('[data-testid="team-preview"]')).not.toBeNull(); + expect(harness.container.querySelector('[data-testid="team-panel"]')).not.toBeNull(); + expect(harness.container.textContent).toContain("多成员实时协作"); + }); + + it("teamView 存在活动态提示时,应在窄屏悬浮入口显示状态标签", async () => { + const container = mount({ + artifacts: [ + createArtifact("artifact-1", "draft.md", "标题\n当前内容", 20), + ], + canvasState: null, + taskFiles: [], + workspaceRoot: "/workspace", + workspaceUnavailable: false, + defaultPreview: { + selectionKey: "artifact:artifact-1", + title: "draft.md", + content: "标题\n当前内容", + filePath: "draft.md", + absolutePath: "/workspace/draft.md", + previousContent: null, + }, + loadFilePreview: vi.fn(async (path: string) => ({ + path, + content: "", + isBinary: false, + size: 0, + error: null, + })), + onOpenPath: vi.fn(async () => undefined), + onRevealPath: vi.fn(async () => undefined), + renderPreview: (target, options) => ( +
+ {options?.stackedWorkbenchTrigger} + {target.kind}:{target.title} +
+ ), + teamView: { + enabled: true, + title: "Team Workbench", + subtitle: "多成员实时协作", + triggerState: { + tone: "active", + label: "组建中", + }, + renderPreview: () =>
team-preview
, + renderPanel: () =>
team-panel
, + }, + }); + + await flushEffects(); + await resizeWorkbench(820); + await flushEffects(); + + const trigger = container.querySelector( + '[data-testid="canvas-workbench-trigger"]', + ); + + expect(trigger).toBeTruthy(); + expect(trigger?.textContent).toContain("工作台"); + expect(trigger?.textContent).toContain("组建中"); + expect(trigger?.className).toContain("bg-sky-50"); + }); + + it("容器变窄时应切换为底部工作台布局并保持工作台可展开收起", async () => { const container = mount({ artifacts: [ createArtifact("artifact-new", "draft.md", "标题\n产物版本", 20), @@ -506,13 +705,17 @@ describe("CanvasWorkbenchLayout", () => { expect( container.querySelector('button[aria-label="展开画布工作台"]'), ).not.toBeNull(); + expect( + container.querySelector('[data-testid="canvas-workbench-trigger"]') + ?.textContent, + ).toContain("工作台"); clickButtonByLabel(container, "展开画布工作台"); expect( container .querySelector('[data-testid="canvas-workbench-layout"]') ?.getAttribute("data-panel-placement"), - ).toBe("overlay-right"); + ).toBe("overlay-bottom"); expect( container.querySelector('button[aria-label="折叠画布工作台"]'), ).not.toBeNull(); @@ -526,4 +729,110 @@ describe("CanvasWorkbenchLayout", () => { clickButtonByLabel(container, "切换画布标签-预览"); expect(container.textContent).toContain("当前画布正文"); }); + + it("窄屏底部工作台应支持拖拽调整高度", async () => { + const container = mount({ + artifacts: [ + createArtifact("artifact-new", "draft.md", "标题\n产物版本", 20), + ], + canvasState: null, + taskFiles: [], + workspaceRoot: "/workspace", + workspaceUnavailable: false, + defaultPreview: { + selectionKey: "artifact:artifact-new", + title: "draft.md", + content: "标题\n产物版本", + filePath: "draft.md", + absolutePath: "/workspace/draft.md", + previousContent: "标题\n上一版本", + } satisfies CanvasWorkbenchDefaultPreview, + loadFilePreview: vi.fn(async (path: string) => ({ + path, + content: "README 内容", + isBinary: false, + size: 12, + error: null, + })), + onOpenPath: vi.fn(async () => undefined), + onRevealPath: vi.fn(async () => undefined), + renderPreview: (target, options) => ( +
+ {options?.stackedWorkbenchTrigger} + {target.kind}:{target.title} +
+ ), + }); + + await flushEffects(); + await resizeWorkbench(820, 640); + await flushEffects(); + + clickButtonByLabel(container, "展开画布工作台"); + await flushEffects(); + + const layout = container.querySelector( + '[data-testid="canvas-workbench-layout"]', + ); + const resizeHandle = container.querySelector( + '[data-testid="canvas-workbench-resize-handle"]', + ); + + expect(layout).toBeTruthy(); + expect(resizeHandle).toBeTruthy(); + + const initialHeight = Number.parseFloat(layout?.style.height || "0"); + expect(initialHeight).toBeGreaterThan(0); + + await act(async () => { + resizeHandle?.dispatchEvent( + new MouseEvent("mousedown", { + bubbles: true, + clientY: 520, + }), + ); + window.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientY: 420, + }), + ); + window.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + clientY: 420, + }), + ); + await Promise.resolve(); + }); + + const expandedHeight = Number.parseFloat(layout?.style.height || "0"); + expect(expandedHeight).toBeGreaterThan(initialHeight); + + await act(async () => { + resizeHandle?.dispatchEvent( + new MouseEvent("mousedown", { + bubbles: true, + clientY: 420, + }), + ); + window.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientY: 560, + }), + ); + window.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + clientY: 560, + }), + ); + await Promise.resolve(); + }); + + const reducedHeight = Number.parseFloat(layout?.style.height || "0"); + expect(reducedHeight).toBeLessThan(expandedHeight); + expect(reducedHeight).toBeGreaterThanOrEqual(220); + }); }); diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx index d01094713..1a1158ec7 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx @@ -5,6 +5,7 @@ import { useMemo, useRef, useState, + type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; import { @@ -43,7 +44,12 @@ import { type CanvasWorkbenchDiffLine, } from "../utils/canvasWorkbenchDiff"; -type CanvasWorkbenchTab = "artifacts" | "files" | "changes" | "preview"; +type CanvasWorkbenchTab = + | "artifacts" + | "files" + | "changes" + | "preview" + | "team"; export type CanvasWorkbenchLayoutMode = "split" | "stacked"; interface CanvasWorkbenchEntryBase { @@ -126,8 +132,26 @@ export type CanvasWorkbenchPreviewTarget = | { kind: "empty"; title: string; + } + | { + kind: "team-workbench"; + title: string; }; +export interface CanvasWorkbenchTeamView { + enabled: boolean; + title?: string; + subtitle?: string; + autoFocusToken?: string | number | null; + triggerState?: { + tone: "idle" | "active" | "error"; + label?: string | null; + } | null; + renderPreview: (options?: { stackedWorkbenchTrigger?: ReactNode }) => ReactNode; + renderPanel?: () => ReactNode; + renderFooter?: () => ReactNode; +} + interface WorkspaceFileSelection { path: string; title: string; @@ -155,20 +179,14 @@ export interface CanvasWorkbenchLayoutProps { }, ) => ReactNode; onLayoutModeChange?: (mode: CanvasWorkbenchLayoutMode) => void; + teamView?: CanvasWorkbenchTeamView | null; } -const TAB_META: Array<{ key: CanvasWorkbenchTab; label: string }> = [ - { key: "artifacts", label: "产物" }, - { key: "files", label: "全部文件" }, - { key: "changes", label: "变更" }, - { key: "preview", label: "预览" }, -]; - const WORKBENCH_PANEL_CLASSNAME = - "rounded-[24px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.94)_100%)] shadow-sm shadow-slate-950/5"; + "rounded-[24px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5"; const WORKBENCH_MUTED_PANEL_CLASSNAME = - "rounded-[24px] border border-dashed border-slate-200/90 bg-slate-50/82 px-4 py-6 text-sm text-slate-500"; + "rounded-[24px] border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-sm text-slate-500"; const WORKBENCH_BUTTON_CLASSNAME = "border-slate-200/80 bg-white/90 text-slate-600 hover:border-slate-300 hover:bg-white hover:text-slate-900"; @@ -181,6 +199,47 @@ const WORKBENCH_GHOST_BUTTON_CLASSNAME = const STACKED_LAYOUT_BREAKPOINT = 1040; +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function resolveStackedWorkbenchMetrics(shellHeight: number): { + minHeight: number; + maxHeight: number; + defaultHeight: number; +} { + const safeShellHeight = shellHeight > 0 ? shellHeight : 720; + const minHeight = Math.max(220, Math.min(300, Math.round(safeShellHeight * 0.32))); + const maxHeight = Math.max( + minHeight + 80, + Math.min(560, safeShellHeight - 56), + ); + const defaultHeight = clamp( + Math.round(safeShellHeight * 0.48), + minHeight, + maxHeight, + ); + return { + minHeight, + maxHeight, + defaultHeight, + }; +} + +function resolveStackedTriggerClassName( + tone: "idle" | "active" | "error", +): string { + switch (tone) { + case "active": + return "border-sky-200 bg-sky-50 text-sky-700 shadow-sky-950/10 hover:bg-sky-100 hover:text-sky-800"; + case "error": + return "border-rose-200 bg-rose-50 text-rose-700 shadow-rose-950/10 hover:bg-rose-100 hover:text-rose-800"; + case "idle": + default: + return "border-slate-200 bg-white text-slate-600 shadow-slate-950/10 hover:bg-slate-50 hover:text-slate-900"; + } +} + function normalizePath(value: string): string { return value.replace(/\\/g, "/"); } @@ -469,12 +528,20 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ onRevealPath, renderPreview, onLayoutModeChange, + teamView = null, }: CanvasWorkbenchLayoutProps) { const shellRef = useRef(null); - const [activeTab, setActiveTab] = useState("artifacts"); + const stackedResizeCleanupRef = useRef<(() => void) | null>(null); + const [activeTab, setActiveTab] = useState(() => + teamView?.enabled && !defaultPreview ? "team" : "artifacts", + ); const [collapsed, setCollapsed] = useState(false); const [isStackedLayout, setIsStackedLayout] = useState(false); const [stackedWorkbenchOpen, setStackedWorkbenchOpen] = useState(false); + const [shellHeight, setShellHeight] = useState(720); + const [stackedWorkbenchHeight, setStackedWorkbenchHeight] = useState( + null, + ); const [selectedKey, setSelectedKey] = useState(null); const [directoryCache, setDirectoryCache] = useState>( {}, @@ -488,6 +555,16 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ const [workspaceFileSelections, setWorkspaceFileSelections] = useState< Record >({}); + const availableTabs = useMemo>( + () => [ + ...(teamView?.enabled ? [{ key: "team" as const, label: "Team" }] : []), + { key: "artifacts" as const, label: "产物" }, + { key: "files" as const, label: "全部文件" }, + { key: "changes" as const, label: "变更" }, + { key: "preview" as const, label: "预览" }, + ], + [teamView?.enabled], + ); const entries = useMemo( () => buildEntries(artifacts, canvasState, taskFiles, workspaceRoot), @@ -568,27 +645,37 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ return; } - const updateLayout = (width: number) => { + const updateLayout = (width: number, height: number) => { if (width <= 0) { return; } setIsStackedLayout(width < STACKED_LAYOUT_BREAKPOINT); + if (height > 0) { + setShellHeight(height); + } }; const fallbackWidth = node.getBoundingClientRect().width || node.clientWidth || window.innerWidth; - updateLayout(fallbackWidth); + const fallbackHeight = + node.getBoundingClientRect().height || node.clientHeight || window.innerHeight; + updateLayout(fallbackWidth, fallbackHeight); if (typeof ResizeObserver === "undefined") { return; } const observer = new ResizeObserver((entries) => { + const contentRect = entries[0]?.contentRect; const nextWidth = - entries[0]?.contentRect.width || + contentRect?.width || node.getBoundingClientRect().width || node.clientWidth; - updateLayout(nextWidth); + const nextHeight = + contentRect?.height || + node.getBoundingClientRect().height || + node.clientHeight; + updateLayout(nextWidth, nextHeight); }); observer.observe(node); @@ -607,6 +694,57 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ } }, [isStackedLayout]); + const stackedWorkbenchMetrics = useMemo( + () => resolveStackedWorkbenchMetrics(shellHeight), + [shellHeight], + ); + + useEffect(() => { + setStackedWorkbenchHeight((previous) => + previous == null + ? stackedWorkbenchMetrics.defaultHeight + : clamp( + previous, + stackedWorkbenchMetrics.minHeight, + stackedWorkbenchMetrics.maxHeight, + ), + ); + }, [stackedWorkbenchMetrics]); + + useEffect(() => { + return () => { + stackedResizeCleanupRef.current?.(); + stackedResizeCleanupRef.current = null; + }; + }, []); + + useEffect(() => { + if (!availableTabs.some((tab) => tab.key === activeTab)) { + setActiveTab(availableTabs[0]?.key || "artifacts"); + } + }, [activeTab, availableTabs]); + + const teamAutoFocusTokenRef = useRef( + teamView?.autoFocusToken, + ); + + useEffect(() => { + if (!teamView?.enabled || teamView.autoFocusToken == null) { + return; + } + + if (teamAutoFocusTokenRef.current === teamView.autoFocusToken) { + return; + } + + teamAutoFocusTokenRef.current = teamView.autoFocusToken; + setActiveTab("team"); + setCollapsed(false); + if (isStackedLayout) { + setStackedWorkbenchOpen(true); + } + }, [isStackedLayout, teamView?.autoFocusToken, teamView?.enabled]); + const handleToggleDirectory = useCallback( (path: string) => { setExpandedDirectories((previous) => { @@ -687,6 +825,13 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ : null; const currentTarget = useMemo(() => { + if (activeTab === "team" && teamView?.enabled) { + return { + kind: "team-workbench", + title: teamView.title || "Team Workbench", + }; + } + if (selectedWorkspaceFile) { if (selectedWorkspaceFile.status === "loading") { return { @@ -796,7 +941,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ kind: "empty", title: "暂无可预览内容", }; - }, [defaultPreview, selectedEntry, selectedWorkspaceFile]); + }, [activeTab, defaultPreview, selectedEntry, selectedWorkspaceFile, teamView]); const currentContent = useMemo(() => { if (currentTarget.kind === "default-canvas") { @@ -1146,30 +1291,72 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ ); }; + const renderTeamTab = () => { + if (!teamView?.enabled) { + return ( +
+ 当前没有可展示的 Team 工作台。 +
+ ); + } + + return teamView.renderPanel ? ( + teamView.renderPanel() + ) : ( +
+ Team 工作台已启用。 +
+ ); + }; + const renderTabButtons = (stacked: boolean) => ( -
- {TAB_META.map((tab) => ( - - ))} -
+ stacked ? ( +
+ {availableTabs.map((tab) => ( + + ))} +
+ ) : ( +
+ {availableTabs.map((tab) => ( + + ))} +
+ ) ); const renderWorkbenchHeader = ( @@ -1184,33 +1371,62 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ stacked ? "px-3 py-3" : "px-4 py-4", )} > + {stacked ? ( +
+ +
+ ) : null}
- Canvas Workbench + {activeTab === "team" ? "Team Workbench" : "Canvas Workbench"}
-
- {currentTarget.title} +
+ {activeTab === "team" + ? teamView?.title || currentTarget.title + : currentTarget.title}
- {selectionPath ? ( -
+ {activeTab === "team" && teamView?.subtitle ? ( +
+ {teamView.subtitle} +
+ ) : null} + {activeTab !== "team" && selectionPath ? ( +
{selectionPath}
) : null}
- + {activeTab !== "team" ? ( + + ) : null} {stacked || options?.showCollapseButton ? ( ) : undefined; @@ -1331,7 +1625,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ data-testid="canvas-workbench-shell" data-layout-mode={isStackedLayout ? "stacked" : "split"} className={cn( - "relative h-full min-h-0 overflow-hidden rounded-[28px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.94)_100%)] shadow-sm shadow-slate-950/5", + "relative h-full min-h-0 overflow-hidden rounded-[28px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5", isStackedLayout ? "block" : "flex flex-row", )} > @@ -1341,29 +1635,58 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ isStackedLayout ? "h-full" : "flex-1", )} > - {renderPreview(currentTarget, { - stackedWorkbenchTrigger, - })} + {activeTab === "team" && teamView?.enabled + ? teamView.renderPreview({ + stackedWorkbenchTrigger, + }) + : renderPreview(currentTarget, { + stackedWorkbenchTrigger, + })}
{isStackedLayout ? ( stackedWorkbenchOpen ? ( -
- {renderWorkbenchHeader(true)} -
{renderActiveTab()}
- {renderWorkbenchFooter(true)} -
+ <> + - {TAB_META.map((tab) => ( + {availableTabs.map((tab) => ( + ); +}; + +export default TeamModeEntryButton; diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx index ef4734412..8b1c03d02 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelector.tsx @@ -8,6 +8,7 @@ import React, { import { Users } from "lucide-react"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; +import type { WorkspaceSettings } from "@/types/workspace"; import { scheduleIdleModulePreload } from "./scheduleIdleModulePreload"; import type { TeamDefinition } from "../../../utils/teamDefinitions"; @@ -21,8 +22,15 @@ const TeamSelectorPanel = lazy(async () => { interface TeamSelectorProps { activeTheme?: string; input?: string; + workspaceId?: string | null; + providerType?: string; + model?: string; + executionStrategy?: "react" | "code_orchestrated" | "auto"; + autoOpenToken?: number | null; selectedTeam?: TeamDefinition | null; onSelectTeam: (team: TeamDefinition | null) => void; + workspaceSettings?: WorkspaceSettings | null; + onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise; triggerLabel?: string; className?: string; } @@ -30,9 +38,16 @@ interface TeamSelectorProps { export const TeamSelector: React.FC = ({ activeTheme, input, + workspaceId, + providerType, + model, + executionStrategy, + autoOpenToken = null, selectedTeam = null, onSelectTeam, - triggerLabel = "Team", + workspaceSettings, + onPersistCustomTeams, + triggerLabel = "配置 Team", className, }) => { const [open, setOpen] = useState(false); @@ -43,6 +58,13 @@ export const TeamSelector: React.FC = ({ }); }, []); + useEffect(() => { + if (autoOpenToken === null || autoOpenToken === undefined) { + return; + } + setOpen(true); + }, [autoOpenToken]); + const resolvedLabel = useMemo(() => { if (!selectedTeam?.label?.trim()) { return triggerLabel; @@ -90,7 +112,13 @@ export const TeamSelector: React.FC = ({ { onSelectTeam(team); setOpen(false); diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx index 5a157424b..166de8b5c 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.test.tsx @@ -1,5 +1,6 @@ import { act, type ComponentProps } from "react"; import { createRoot, type Root } from "react-dom/client"; +import type { WorkspaceSettings } from "@/types/workspace"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TeamSelectorPanel } from "./TeamSelectorPanel"; import { @@ -93,6 +94,14 @@ describe("TeamSelectorPanel", () => { vi.clearAllMocks(); }); + it("不应再展示模型生成 Team 入口", async () => { + const { container } = renderPanel(); + + await flushEffects(); + + expect(container.textContent).not.toContain("模型生成 Team"); + }); + it("应保存自定义 Team 的 profileId、roleKey 与 skillIds", async () => { const onSelectTeam = vi.fn(); const selectedTeam = createTeamDefinitionFromPreset( @@ -146,6 +155,8 @@ describe("TeamSelectorPanel", () => { saveButton?.click(); }); + await flushEffects(); + const savedTeam = onSelectTeam.mock.calls[0]?.[0] as TeamDefinition | undefined; expect(savedTeam).toBeTruthy(); @@ -158,4 +169,50 @@ describe("TeamSelectorPanel", () => { ]); expect(mockToast.success).toHaveBeenCalled(); }); + + it("项目级自定义 Team 应通过回调持久化", async () => { + const onPersistCustomTeams = vi.fn().mockResolvedValue(undefined); + const workspaceSettings: WorkspaceSettings = { + agentTeam: { + customTeams: [ + { + id: "custom-team-project-1", + label: "项目联调 Team", + description: "面向当前项目的联调与修复。", + roles: [ + { + id: "planner", + label: "分析", + summary: "先定位问题再拆解实施。", + }, + ], + }, + ], + }, + }; + const { container } = renderPanel({ + workspaceSettings, + onPersistCustomTeams, + }); + + await flushEffects(); + + expect(container.textContent).toContain("项目联调 Team"); + expect(container.textContent).toContain("当前项目 Team"); + + const deleteButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("删除这个 Team"), + ); + + expect(deleteButton).toBeTruthy(); + + act(() => { + deleteButton?.click(); + }); + + await flushEffects(); + + expect(onPersistCustomTeams).toHaveBeenCalledWith([]); + expect(mockToast.success).toHaveBeenCalled(); + }); }); diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx index f28b9175e..7af466efc 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx @@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; +import type { WorkspaceSettings } from "@/types/workspace"; import { toast } from "sonner"; import { BUILTIN_TEAM_PROFILE_OPTIONS, @@ -32,13 +33,22 @@ import { type TeamRoleDefinition, } from "../../../utils/teamDefinitions"; import { getTeamSuggestion } from "../../../utils/teamSuggestion"; -import { loadCustomTeams, saveCustomTeams } from "../../../utils/teamStorage"; +import { + resolveCustomTeams, + saveCustomTeams, +} from "../../../utils/teamStorage"; interface TeamSelectorPanelProps { activeTheme?: string; input?: string; + workspaceId?: string | null; + providerType?: string; + model?: string; + executionStrategy?: "react" | "code_orchestrated" | "auto"; selectedTeam?: TeamDefinition | null; onSelectTeam: (team: TeamDefinition | null) => void; + workspaceSettings?: WorkspaceSettings | null; + onPersistCustomTeams?: (teams: TeamDefinition[]) => void | Promise; onClose?: () => void; } @@ -288,18 +298,27 @@ function TeamCard({ export const TeamSelectorPanel: React.FC = ({ activeTheme, input, + workspaceId: _workspaceId, + providerType: _providerType, + model: _model, + executionStrategy: _executionStrategy, selectedTeam = null, onSelectTeam, + workspaceSettings, + onPersistCustomTeams, onClose, }) => { const [query, setQuery] = useState(""); const [customTeams, setCustomTeams] = useState([]); const [draft, setDraft] = useState(null); const [expandedTeamId, setExpandedTeamId] = useState(null); + const isProjectScopedCustomTeam = Boolean( + workspaceSettings && onPersistCustomTeams, + ); useEffect(() => { - setCustomTeams(loadCustomTeams()); - }, []); + setCustomTeams(resolveCustomTeams(workspaceSettings)); + }, [workspaceSettings]); const suggestion = useMemo( () => @@ -370,7 +389,16 @@ export const TeamSelectorPanel: React.FC = ({ setDraft(buildDraftFromTeam(team)); }; - const handleSaveDraft = () => { + const persistCustomTeams = async (nextCustomTeams: TeamDefinition[]) => { + if (onPersistCustomTeams) { + await Promise.resolve(onPersistCustomTeams(nextCustomTeams)); + return; + } + + saveCustomTeams(nextCustomTeams); + }; + + const handleSaveDraft = async () => { const normalized = normalizeTeamDefinition({ id: draft?.id, source: "custom", @@ -400,23 +428,46 @@ export const TeamSelectorPanel: React.FC = ({ 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}」`); + try { + await persistCustomTeams(nextCustomTeams); + setCustomTeams(nextCustomTeams); + setDraft(null); + onSelectTeam(nextTeam); + onClose?.(); + toast.success( + isProjectScopedCustomTeam + ? `已将 Team「${nextTeam.label}」保存到当前项目` + : `已保存 Team「${nextTeam.label}」`, + ); + } catch (error) { + toast.error( + `保存 Team 失败:${error instanceof Error ? error.message : String(error)}`, + ); + } }; - const handleDeleteCustom = (team: TeamDefinition) => { + const handleDeleteCustom = async (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); + + try { + await persistCustomTeams(nextCustomTeams); + setCustomTeams(nextCustomTeams); + setDraft((currentDraft) => + currentDraft?.id === team.id ? null : currentDraft, + ); + if (selectedTeam?.id === team.id) { + onSelectTeam(null); + } + toast.success( + isProjectScopedCustomTeam + ? `已从当前项目删除 Team「${team.label}」` + : `已删除 Team「${team.label}」`, + ); + } catch (error) { + toast.error( + `删除 Team 失败:${error instanceof Error ? error.message : String(error)}`, + ); } - toast.success(`已删除 Team「${team.label}」`); }; const handleClearSelection = () => { @@ -606,7 +657,9 @@ export const TeamSelectorPanel: React.FC = ({ type="button" variant="outline" className="border-rose-200 bg-white text-rose-600 hover:bg-rose-50" - onClick={() => handleDeleteCustom(inspectorTeam)} + onClick={() => { + void handleDeleteCustom(inspectorTeam); + }} > 删除这个 Team @@ -930,7 +983,9 @@ export const TeamSelectorPanel: React.FC = ({ @@ -953,6 +1008,11 @@ export const TeamSelectorPanel: React.FC = ({
只在当前任务适合拆分协作时,为主代理提供团队结构参考。
+
+ {isProjectScopedCustomTeam + ? "自定义 Team 会保存到当前项目,便于项目级复用与评审。" + : "自定义 Team 当前保存在本地设备,用于快速测试与个人偏好。"} +
{selectedTeam ? (
) : (
- 还没有自定义 Team。可以从推荐方案或系统模板复制一份后再改。 + {isProjectScopedCustomTeam + ? "当前项目还没有自定义 Team。可以从推荐方案或系统模板复制一份后再改。" + : "还没有自定义 Team。可以从推荐方案或系统模板复制一份后再改。"}
@@ -1629,13 +1956,13 @@ export function TeamWorkspaceBoard({ ) : ( )} - {detailVisible ? "收起" : "展开详情"} + {detailToggleLabel} )} {isEmptyShellState ? ( - 尚未创建真实 child session + {runtimeFormationMeta?.label || "尚未出现真实团队成员"} ) : Object.entries(statusSummary) @@ -1712,14 +2039,14 @@ export function TeamWorkspaceBoard({ {pendingTeamAction === "wait_any" ? ( ) : null} - {pendingTeamAction === "wait_any" - ? "等待中..." - : "等待任一活跃 agent"} + {pendingTeamAction === "wait_any" + ? "等待中..." + : "等待任一活跃成员"} ) : null} {canWaitAnyActiveTeamSession ? ( - {waitableTeamSessionIds.length} 个活跃 agent 可聚合 wait + {waitableTeamSessionIds.length} 位活跃成员可统一等待 ) : null} {canCloseCompletedTeamSessions ? ( @@ -1734,14 +2061,14 @@ export function TeamWorkspaceBoard({ {pendingTeamAction === "close_completed" ? ( ) : null} - {pendingTeamAction === "close_completed" - ? "关闭中..." - : "关闭已完成 agent"} + {pendingTeamAction === "close_completed" + ? "关闭中..." + : "清理已完成成员"} ) : null} {canCloseCompletedTeamSessions ? ( - {completedTeamSessionIds.length} 个已完成 agent 可释放 slot + {completedTeamSessionIds.length} 位已完成成员可清理 ) : null}
@@ -1959,15 +2286,39 @@ export function TeamWorkspaceBoard({ })}
) : ( -
- 还没有真实 child session。下一次{" "} - spawn_agent{" "} - 成功后,这里会生成可纵向浏览的 team 轨道。 -
+ <> + {hasRuntimeFormation ? ( + <> + {renderRuntimeFormationPanel()} + {renderRuntimeMemberPanel()} + + ) : ( + renderSelectedTeamPlanPanel() + )} +
+ {runtimeTeamState?.status === "forming" ? ( + "模型正在准备本轮 Team,完成后这里会先展示本轮成员;后续真实成员加入时,再切换为可纵向浏览的团队轨道。" + ) : runtimeTeamState?.status === "formed" ? ( + <> + 本轮 Team 已就绪。系统开始分派成员后,这里会从当前编队过渡到真实团队轨道。 + + ) : runtimeTeamState?.status === "failed" ? ( + runtimeTeamState.errorMessage?.trim() || + "Team 准备失败,暂时还没有真实团队成员。" + ) : ( + <> + 还没有真实团队成员。系统开始分派成员后,这里会生成可纵向浏览的团队轨道。 + + )} +
+ )} {detailVisible ? ( -
+
{selectedSession ? ( <>
@@ -2014,10 +2365,10 @@ export function TeamWorkspaceBoard({ {selectedActionPending && pendingSessionAction?.action === "resume" ? "恢复中..." - : "恢复子代理"} + : "恢复成员"} ) : null} - {canCloseSelectedSession ? ( + {canStopSelectedSession ? ( ) : null}
@@ -2125,10 +2481,10 @@ export function TeamWorkspaceBoard({
- Team 控制 + 协作控制 {canWaitSelectedSession ? ( - 可直接 wait_agent + 可直接等待结果 ) : null}
@@ -2150,10 +2506,10 @@ export function TeamWorkspaceBoard({ {selectedActionPending && pendingSessionAction?.action === "wait" ? "等待中..." - : "等待 30 秒"} + : "等待结果 30 秒"} - 仅在主线程确实被该子代理结果阻塞时使用。 + 仅在当前对话确实依赖该成员结果时使用。
) : null} @@ -2166,7 +2522,7 @@ export function TeamWorkspaceBoard({ event.target.value, ) } - placeholder="向该子代理补充新的指令、澄清约束,或要求它继续推进下一步。" + placeholder="给该成员补充新的说明、澄清约束,或要求它继续推进下一步。" className="min-h-[96px] resize-y border-slate-200 bg-white text-sm text-slate-700 placeholder:text-slate-400" data-testid="team-workspace-send-input-textarea" /> @@ -2189,7 +2545,7 @@ export function TeamWorkspaceBoard({ {selectedActionPending && pendingSessionAction?.action === "send" ? "发送中..." - : "发送补充任务"} + : "发送补充说明"}
@@ -2246,11 +2602,11 @@ export function TeamWorkspaceBoard({

) : selectedSessionActivityPreview?.status === "ready" ? (

- 该子代理暂未产出可展示的最近过程。 + 该成员暂未产出可展示的最近过程。

) : (

- 正在同步该子代理的最近过程... + 正在同步该成员的最近过程...

)} @@ -2317,21 +2673,71 @@ export function TeamWorkspaceBoard({ 焦点详情
- 等待真实子代理加入 + {runtimeFormationMeta?.title || "等待团队成员加入"}

- 现在只有 team shell,没有真实 child session。主线程下一步应显式调用{" "} - spawn_agent{" "} - 创建团队成员;创建成功后,详情区会切换为选中 agent 的摘要视图。 + {buildRuntimeFormationEmptyDetail(runtimeTeamState)}

+ {hasRuntimeFormation ? ( +
+ {renderRuntimeFormationPanel()} + {renderRuntimeMemberPanel()} + {runtimeBlueprintRoles.length > 0 ? ( +
+
+ 参考蓝图角色 +
+
+ {runtimeBlueprintRoles.map((role) => ( +
+
+ {role.label} +
+

+ {role.summary} +

+
+ ))} +
+
+ ) : null} +
+ ) : hasSelectedTeamPlan ? ( +
{renderSelectedTeamPlanPanel()}
+ ) : null}
- 推荐链路:spawn_agent → wait_agent → send_input + 推荐流程:分派成员 → 等待结果 → 补充说明
)}
+ ) : railSessions.length > 0 ? ( +
+
+ + 紧凑视图 + {selectedSession ? ( + + 焦点成员 · {selectedSession.name} + + ) : null} +
+

+ 已收起焦点详情、执行配置、协作控制与最近过程,当前仅保留团队轨道与 Team + 轨迹,便于在小屏幕下先浏览整体进度。 +

+
) : null}
diff --git a/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx b/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx index c7317cc1f..53da3f347 100644 --- a/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx @@ -174,8 +174,8 @@ describe("TeamWorkspaceDock", () => { toggleButton?.click(); }); - expect(document.body.textContent).toContain("等待真实子代理"); - expect(document.body.textContent).toContain("spawn_agent"); + expect(document.body.textContent).toContain("等待团队成员加入"); + expect(document.body.textContent).toContain("分派团队成员"); expect(document.body.textContent).toContain("不遮挡画布"); expect(document.body.textContent).toContain("当前 Team:前端联调团队"); expect(document.body.textContent).toContain("分析、实现、验证三段式推进。"); @@ -260,6 +260,95 @@ describe("TeamWorkspaceDock", () => { expect(document.body.textContent).toContain("研究员"); }); + it("仅处于 Team 组建中时,不应自动展开面板,但应显示提醒", async () => { + const { container } = await renderDock({ + runtimeTeamState: { + requestId: "runtime-forming-1", + status: "forming", + label: "修复 Team", + summary: "正在根据任务准备协作成员。", + members: [], + blueprint: { + label: "代码排障团队", + summary: "分析、执行、验证三段式推进。", + roles: [], + }, + updatedAt: Date.now(), + }, + }); + + expect( + document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), + ).toBeNull(); + expect(container.textContent).toContain("查看 Team · 组建中"); + expect( + container.querySelector('[data-testid="team-workspace-dock-signal"]'), + ).toBeTruthy(); + }); + + it("Team 已就绪但真实成员未出现时,不应自动展开面板", async () => { + const { container } = await renderDock({ + runtimeTeamState: { + requestId: "runtime-formed-2", + status: "formed", + label: "修复 Team", + summary: "分析、执行、验证协作闭环。", + members: [ + { + id: "runtime-explorer", + label: "分析", + summary: "负责定位问题边界。", + roleKey: "explorer", + profileId: "code-explorer", + skillIds: ["repo-exploration"], + status: "planned", + }, + ], + blueprint: { + label: "代码排障团队", + summary: "分析、执行、验证三段式推进。", + roles: [], + }, + updatedAt: Date.now(), + }, + }); + + expect( + document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), + ).toBeNull(); + expect(container.textContent).toContain("查看 Team · 1"); + expect( + container.querySelector('[data-testid="team-workspace-dock-signal"]'), + ).toBeTruthy(); + }); + + it("Team 准备失败时,不应自动展开面板,但应保留提醒入口", async () => { + const { container } = await renderDock({ + runtimeTeamState: { + requestId: "runtime-failed-1", + status: "failed", + label: "修复 Team", + summary: "分析、执行、验证协作闭环。", + members: [], + blueprint: { + label: "代码排障团队", + summary: "分析、执行、验证三段式推进。", + roles: [], + }, + errorMessage: "Team 生成失败", + updatedAt: Date.now(), + }, + }); + + expect( + document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), + ).toBeNull(); + expect(container.textContent).toContain("查看 Team · 失败"); + expect( + container.querySelector('[data-testid="team-workspace-dock-signal"]'), + ).toBeTruthy(); + }); + it("真实 team 图谱折叠时,应显示查看入口和动态提示", async () => { const { container } = await renderDock({ childSubagentSessions: [ @@ -332,4 +421,183 @@ describe("TeamWorkspaceDock", () => { expect(getComputedStyle(panelInBody as HTMLElement).position).toBe("fixed"); expect(getComputedStyle(panelInBody as HTMLElement).zIndex).toBe("10010"); }); + + it("用户手动收起后,同一轮不应再次自动展开", async () => { + const { container, render } = await renderDock({ + placement: "inline", + }); + + await render({ + placement: "inline", + childSubagentSessions: [ + { + id: "child-1", + name: "研究员", + created_at: 1_710_000_000, + updated_at: 1_710_000_100, + session_type: "sub_agent", + runtime_status: "running", + task_summary: "整理竞品与数据来源", + role_hint: "explorer", + }, + ], + }); + + const toggleButton = container.querySelector( + '[data-testid="team-workspace-dock-toggle"]', + ); + expect(toggleButton?.textContent).toContain("收起 Team"); + + act(() => { + toggleButton?.click(); + }); + + expect( + document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), + ).toBeNull(); + + await render({ + placement: "inline", + childSubagentSessions: [ + { + id: "child-1", + name: "研究员", + created_at: 1_710_000_000, + updated_at: 1_710_000_100, + session_type: "sub_agent", + runtime_status: "running", + task_summary: "整理竞品与数据来源", + role_hint: "explorer", + }, + { + id: "child-2", + name: "执行者", + created_at: 1_710_000_010, + updated_at: 1_710_000_120, + session_type: "sub_agent", + runtime_status: "running", + task_summary: "提交修复方案", + role_hint: "executor", + }, + ], + }); + + expect( + document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), + ).toBeNull(); + expect(toggleButton?.textContent).toContain("查看 Team · 2"); + }); + + it("切换会话后,新的真实成员出现应再次自动展开", async () => { + const { container, render } = await renderDock({ + placement: "inline", + }); + + await render({ + placement: "inline", + childSubagentSessions: [ + { + id: "child-1", + name: "研究员", + created_at: 1_710_000_000, + updated_at: 1_710_000_100, + session_type: "sub_agent", + runtime_status: "running", + task_summary: "整理竞品与数据来源", + role_hint: "explorer", + }, + ], + }); + + const toggleButton = container.querySelector( + '[data-testid="team-workspace-dock-toggle"]', + ); + + act(() => { + toggleButton?.click(); + }); + + await render({ + placement: "inline", + currentSessionId: "parent-2", + childSubagentSessions: [], + }); + + expect( + document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), + ).toBeNull(); + + await render({ + placement: "inline", + currentSessionId: "parent-2", + childSubagentSessions: [ + { + id: "child-2", + name: "执行者", + created_at: 1_710_000_010, + updated_at: 1_710_000_120, + session_type: "sub_agent", + runtime_status: "running", + task_summary: "提交修复方案", + role_hint: "executor", + }, + ], + }); + + expect( + document.body.querySelector('[data-testid="team-workspace-dock-panel"]'), + ).toBeTruthy(); + expect(container.textContent).toContain("收起 Team"); + }); + + it("本轮 Team 已就绪时,应在空态 Dock 展示成员摘要", async () => { + const { container } = await renderDock({ + runtimeTeamState: { + requestId: "runtime-formed-1", + status: "formed", + label: "修复 Team", + summary: "分析、执行、验证协作闭环。", + members: [ + { + id: "runtime-explorer", + label: "分析", + summary: "负责定位问题边界。", + roleKey: "explorer", + profileId: "code-explorer", + skillIds: ["repo-exploration"], + status: "planned", + }, + ], + blueprint: { + label: "代码排障团队", + summary: "分析、执行、验证三段式推进。", + roles: [], + }, + updatedAt: Date.now(), + }, + }); + + const toggleButton = container.querySelector( + '[data-testid="team-workspace-dock-toggle"]', + ); + expect(toggleButton?.textContent).toContain("查看 Team · 1"); + + act(() => { + toggleButton?.click(); + }); + + const detailToggle = document.body.querySelector( + '[data-testid="team-workspace-selected-team-toggle"]', + ); + expect(detailToggle).toBeTruthy(); + + act(() => { + detailToggle?.click(); + }); + + expect(document.body.textContent).toContain("本轮 Team 已就绪"); + expect(document.body.textContent).toContain("修复 Team"); + expect(document.body.textContent).toContain("分析"); + expect(document.body.textContent).toContain("repo-exploration"); + }); }); diff --git a/src/components/agent/chat/components/TeamWorkspaceDock.tsx b/src/components/agent/chat/components/TeamWorkspaceDock.tsx index 5e2f8627b..27369873c 100644 --- a/src/components/agent/chat/components/TeamWorkspaceDock.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceDock.tsx @@ -10,8 +10,13 @@ import type { TeamWorkspaceActivityEntry, TeamWorkspaceControlSummary, TeamWorkspaceLiveRuntimeState, + TeamWorkspaceRuntimeFormationState, TeamWorkspaceWaitSummary, } from "../teamWorkspaceRuntime"; +import { + resolveRuntimeFormationStatusMeta, + resolveRuntimeMemberStatusMeta, +} from "../teamWorkspaceRuntime"; import { TeamWorkspaceBoard } from "./TeamWorkspaceBoard"; import type { TeamRoleDefinition } from "../utils/teamDefinitions"; @@ -352,6 +357,7 @@ interface TeamWorkspaceDockProps { shellVisible?: boolean; withBottomOverlay?: boolean; placement?: "floating" | "inline"; + onActivateWorkbench?: () => void; currentSessionId?: string | null; currentSessionName?: string | null; currentSessionRuntimeStatus?: AsterSubagentSessionInfo["runtime_status"]; @@ -387,6 +393,7 @@ interface TeamWorkspaceDockProps { selectedTeamLabel?: string | null; selectedTeamSummary?: string | null; selectedTeamRoles?: TeamRoleDefinition[] | null; + runtimeTeamState?: TeamWorkspaceRuntimeFormationState | null; } interface InlinePanelLayout { @@ -400,6 +407,7 @@ export function TeamWorkspaceDock({ shellVisible = false, withBottomOverlay = false, placement = "floating", + onActivateWorkbench, currentSessionId, currentSessionName, currentSessionRuntimeStatus, @@ -423,27 +431,54 @@ export function TeamWorkspaceDock({ selectedTeamLabel, selectedTeamSummary, selectedTeamRoles = [], + runtimeTeamState = null, }: TeamWorkspaceDockProps) { const hasRealTeamGraph = childSubagentSessions.length > 0 || Boolean(subagentParentContext); + const hasRuntimeFormation = Boolean(runtimeTeamState); + const runtimeFormationMeta = runtimeTeamState + ? resolveRuntimeFormationStatusMeta(runtimeTeamState.status) + : null; + const runtimeTeamLabel = + runtimeTeamState?.label?.trim() || + runtimeTeamState?.blueprint?.label?.trim() || + selectedTeamLabel?.trim() || + null; + const runtimeTeamSummary = + runtimeTeamState?.summary?.trim() || + runtimeTeamState?.blueprint?.summary?.trim() || + selectedTeamSummary?.trim() || + null; const [expanded, setExpanded] = useState( () => placement === "inline" && hasRealTeamGraph, ); + const [userDismissedAutoExpand, setUserDismissedAutoExpand] = useState(false); const hasInitializedRef = useRef(false); + const previousSessionIdRef = useRef(currentSessionId ?? null); const previousHasRealGraphRef = useRef(false); const previousPlacementRef = useRef(placement); const toggleRef = useRef(null); const [inlinePanelLayout, setInlinePanelLayout] = useState(null); - const isCompact = !hasRealTeamGraph; - const showAttentionCue = hasRealTeamGraph && !expanded; + const isCompact = !hasRealTeamGraph && !hasRuntimeFormation; + const showAttentionCue = + !expanded && + (hasRealTeamGraph || + runtimeTeamState?.status === "forming" || + runtimeTeamState?.status === "formed" || + runtimeTeamState?.status === "failed"); const dockCount = subagentParentContext ? (subagentParentContext.sibling_subagent_sessions?.length ?? 0) + 1 : childSubagentSessions.length; const shouldPortalPanel = expanded; const [teamDetailExpanded, setTeamDetailExpanded] = useState(false); const hasSelectedTeamDetails = - Boolean(selectedTeamSummary?.trim()) || (selectedTeamRoles?.length ?? 0) > 0; + Boolean(runtimeTeamSummary) || + (runtimeTeamState?.members.length ?? 0) > 0 || + Boolean(runtimeTeamState?.blueprint?.summary?.trim()) || + (runtimeTeamState?.blueprint?.roles.length ?? 0) > 0 || + Boolean(selectedTeamSummary?.trim()) || + (selectedTeamRoles?.length ?? 0) > 0; const toggleLabel = useMemo(() => { if (expanded) { return "收起 Team"; @@ -451,38 +486,70 @@ export function TeamWorkspaceDock({ if (hasRealTeamGraph) { return `查看 Team · ${dockCount}`; } + if (runtimeTeamState?.status === "forming") { + return "查看 Team · 组建中"; + } + if (runtimeTeamState?.status === "formed") { + return `查看 Team · ${runtimeTeamState.members.length}`; + } + if (runtimeTeamState?.status === "failed") { + return "查看 Team · 失败"; + } return "Team"; - }, [dockCount, expanded, hasRealTeamGraph]); + }, [dockCount, expanded, hasRealTeamGraph, runtimeTeamState]); useEffect(() => { + const normalizedSessionId = currentSessionId ?? null; + if (!hasInitializedRef.current) { if (placement === "inline" && hasRealTeamGraph) { setExpanded(true); } + previousSessionIdRef.current = normalizedSessionId; previousHasRealGraphRef.current = hasRealTeamGraph; previousPlacementRef.current = placement; hasInitializedRef.current = true; return; } + if (previousSessionIdRef.current !== normalizedSessionId) { + previousSessionIdRef.current = normalizedSessionId; + previousHasRealGraphRef.current = hasRealTeamGraph; + previousPlacementRef.current = placement; + setUserDismissedAutoExpand(false); + return; + } + + const graphAppeared = + !previousHasRealGraphRef.current && hasRealTeamGraph; + const graphCleared = + previousHasRealGraphRef.current && !hasRealTeamGraph; + if ( previousPlacementRef.current !== placement && placement === "inline" && - hasRealTeamGraph + hasRealTeamGraph && + !userDismissedAutoExpand ) { setExpanded(true); + previousSessionIdRef.current = normalizedSessionId; previousHasRealGraphRef.current = hasRealTeamGraph; previousPlacementRef.current = placement; return; } - if (!previousHasRealGraphRef.current && hasRealTeamGraph) { + if (graphCleared) { + setUserDismissedAutoExpand(false); + } + + if (graphAppeared && !userDismissedAutoExpand) { setExpanded(true); } + previousSessionIdRef.current = normalizedSessionId; previousHasRealGraphRef.current = hasRealTeamGraph; previousPlacementRef.current = placement; - }, [hasRealTeamGraph, placement]); + }, [currentSessionId, hasRealTeamGraph, placement, userDismissedAutoExpand]); const updateInlinePanelLayout = useCallback(() => { if ( @@ -536,6 +603,8 @@ export function TeamWorkspaceDock({ return null; } + const launcherOnly = typeof onActivateWorkbench === "function"; + const panelContent = hasRealTeamGraph ? ( + onOpenSubagentSession={onOpenSubagentSession} + onReturnToParentSession={onReturnToParentSession} + teamWaitSummary={teamWaitSummary} + teamControlSummary={teamControlSummary} + selectedTeamLabel={selectedTeamLabel} + selectedTeamSummary={selectedTeamSummary} + selectedTeamRoles={selectedTeamRoles} + runtimeTeamState={runtimeTeamState} + /> ) : ( @@ -571,13 +644,28 @@ export function TeamWorkspaceDock({ Team 已启用 实时订阅 + {runtimeFormationMeta ? ( + {runtimeFormationMeta.label} + ) : null} - 等待真实子代理 + + {runtimeFormationMeta?.title || "等待团队成员加入"} + - 模型首次成功调用 spawn_agent{" "} - 后,这里会自动切换到真实团队工作台。 + {runtimeTeamState?.status === "forming" + ? "系统正在根据本轮任务准备成员,真实团队成员加入后会自动切换到完整团队工作台。" + : runtimeTeamState?.status === "formed" + ? `已准备 ${runtimeTeamState.members.length} 个成员,后续真实成员会接管实时协作轨道。` + : runtimeTeamState?.status === "failed" + ? runtimeTeamState.errorMessage?.trim() || + "本轮 Team 准备失败,你仍然可以继续在当前对话中执行。" + : ( + <> + 当系统开始真正分派团队成员后,这里会自动切换到真实团队工作台。 + + )} - {selectedTeamLabel && hasSelectedTeamDetails ? ( + {runtimeTeamLabel && hasSelectedTeamDetails ? (
- {selectedTeamLabel} + {runtimeTeamLabel} - 查看当前 Team 配置与角色分工 + {runtimeTeamState + ? "查看本轮成员与参考蓝图" + : "查看当前 Team 配置与角色分工"}
{teamDetailExpanded ? ( @@ -599,30 +689,88 @@ export function TeamWorkspaceDock({
{teamDetailExpanded ? ( - {selectedTeamSummary ? ( + {runtimeTeamSummary ? ( - Team 摘要 - {selectedTeamSummary} + + {runtimeTeamState ? "本轮摘要" : "Team 摘要"} + + {runtimeTeamSummary} ) : null} - {selectedTeamRoles?.map((role) => ( - - {role.label} - {role.summary} + {runtimeTeamState?.members.map((member) => { + const memberStatusMeta = resolveRuntimeMemberStatusMeta( + member.status, + ); + return ( + +
+ {member.label} + + {memberStatusMeta.label} + + {member.roleKey ? ( + + {member.roleKey} + + ) : null} + {member.profileId ? ( + + {member.profileId} + + ) : null} +
+ {member.summary} + {member.skillIds.length > 0 ? ( +
+ {member.skillIds.map((skillId) => ( + + {skillId} + + ))} +
+ ) : null} +
+ ); + })} + {!runtimeTeamState + ? selectedTeamRoles?.map((role) => ( + + {role.label} + {role.summary} + + )) + : null} + {runtimeTeamState?.blueprint?.label ? ( + + + 参考蓝图 · {runtimeTeamState.blueprint.label} + + + {runtimeTeamState.blueprint.summary || + "本轮 Team 参考了当前蓝图 Team 的角色偏好。"} + - ))} + ) : null}
) : null}
) : null} - {selectedTeamLabel ? ( + {runtimeTeamLabel ? ( - 当前 Team:{selectedTeamLabel} + 当前 Team:{runtimeTeamLabel} ) : null} - {selectedTeamSummary ? ( - {selectedTeamSummary} + {runtimeTeamSummary ? ( + {runtimeTeamSummary} + ) : null} + {runtimeTeamState?.members.length ? ( + {runtimeTeamState.members.length} 个成员 ) : null} 不遮挡画布 @@ -656,17 +804,38 @@ export function TeamWorkspaceDock({ $withBottomOverlay={withBottomOverlay} data-testid="team-workspace-dock" > - {expanded && !shouldPortalPanel ? panelNode : null} + {!launcherOnly && expanded && !shouldPortalPanel ? panelNode : null} setExpanded((previous) => !previous)} + onClick={() => { + if (launcherOnly) { + onActivateWorkbench?.(); + return; + } + setExpanded((previous) => { + const nextExpanded = !previous; + if (previous && !nextExpanded) { + setUserDismissedAutoExpand(true); + } + if (!previous && nextExpanded) { + setUserDismissedAutoExpand(false); + } + return nextExpanded; + }); + }} > @@ -681,7 +850,8 @@ export function TeamWorkspaceDock({ {toggleLabel} - {expanded && + {!launcherOnly && + expanded && shouldPortalPanel && inlinePanelLayout && typeof document !== "undefined" diff --git a/src/components/agent/chat/components/ThemeWorkbenchEntryPromptAccessory.test.tsx b/src/components/agent/chat/components/ThemeWorkbenchEntryPromptAccessory.test.tsx new file mode 100644 index 000000000..a681080e1 --- /dev/null +++ b/src/components/agent/chat/components/ThemeWorkbenchEntryPromptAccessory.test.tsx @@ -0,0 +1,118 @@ +import { act, type ComponentProps } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ThemeWorkbenchEntryPromptAccessory } from "./ThemeWorkbenchEntryPromptAccessory"; +import type { ThemeWorkbenchEntryPromptState } from "../hooks/useThemeWorkbenchEntryPrompt"; + +interface MountedHarness { + container: HTMLDivElement; + root: Root; +} + +const mountedRoots: MountedHarness[] = []; + +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 renderAccessory( + props?: Partial>, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const defaultPrompt: ThemeWorkbenchEntryPromptState = { + kind: "initial_prompt", + signature: "dispatch-1", + title: "已恢复待执行创作意图", + description: "进入页面后不会自动开始生成,确认后再继续。", + actionLabel: "继续生成", + prompt: "请先生成主稿", + }; + const defaultProps: ComponentProps = + { + prompt: defaultPrompt, + onRestart: vi.fn(), + onContinue: vi.fn(async () => undefined), + }; + + act(() => { + root.render( + , + ); + }); + + mountedRoots.push({ container, root }); + return { + container, + props: { + ...defaultProps, + ...props, + }, + }; +} + +describe("ThemeWorkbenchEntryPromptAccessory", () => { + it("应渲染提示文案与操作按钮", () => { + const { container } = renderAccessory(); + + expect( + container.querySelector('[data-testid="theme-workbench-entry-prompt"]') + ?.textContent, + ).toContain("已恢复待执行创作意图"); + expect(container.textContent).toContain("进入页面后不会自动开始生成"); + expect(container.textContent).toContain("继续生成"); + expect(container.textContent).toContain("重新开始"); + }); + + it("应分发继续与重启动作", async () => { + const onRestart = vi.fn(); + const onContinue = vi.fn(async () => undefined); + const { container } = renderAccessory({ + onRestart, + onContinue, + }); + + const restartButton = container.querySelector( + '[data-testid="theme-workbench-entry-restart"]', + ); + const continueButton = container.querySelector( + '[data-testid="theme-workbench-entry-continue"]', + ); + + if (!restartButton || !continueButton) { + throw new Error("未找到主题工作台入口提示操作按钮"); + } + + act(() => { + restartButton.click(); + }); + expect(onRestart).toHaveBeenCalledTimes(1); + + await act(async () => { + continueButton.click(); + }); + expect(onContinue).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/agent/chat/components/ThemeWorkbenchEntryPromptAccessory.tsx b/src/components/agent/chat/components/ThemeWorkbenchEntryPromptAccessory.tsx new file mode 100644 index 000000000..957aae0c5 --- /dev/null +++ b/src/components/agent/chat/components/ThemeWorkbenchEntryPromptAccessory.tsx @@ -0,0 +1,140 @@ +import { memo } from "react"; +import { Info } from "lucide-react"; +import styled from "styled-components"; +import type { ThemeWorkbenchEntryPromptState } from "../hooks/useThemeWorkbenchEntryPrompt"; + +interface ThemeWorkbenchEntryPromptAccessoryProps { + prompt: ThemeWorkbenchEntryPromptState; + onRestart: () => void; + onContinue: () => Promise | void; +} + +const ThemeWorkbenchEntryPromptCard = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + min-width: min(360px, calc(100vw - 48px)); + max-width: min(420px, calc(100vw - 48px)); + padding: 12px 14px; + border-radius: 18px; + border: 1px solid rgba(191, 219, 254, 0.92); + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.98) 0%, + rgba(239, 246, 255, 0.96) 100% + ); + color: #0f172a; + box-shadow: 0 18px 34px -28px rgba(15, 23, 42, 0.26); +`; + +const ThemeWorkbenchEntryPromptHeader = styled.div` + display: flex; + align-items: flex-start; + gap: 8px; +`; + +const ThemeWorkbenchEntryPromptTitleWrap = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +`; + +const ThemeWorkbenchEntryPromptTitle = styled.span` + font-size: 13px; + font-weight: 700; + line-height: 1.4; +`; + +const ThemeWorkbenchEntryPromptDescription = styled.span` + font-size: 12px; + line-height: 1.5; + color: #475569; +`; + +const ThemeWorkbenchEntryPromptActions = styled.div` + display: flex; + justify-content: flex-end; + gap: 8px; +`; + +const ThemeWorkbenchEntryPromptButton = styled.button<{ + $variant?: "primary" | "ghost"; +}>` + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 88px; + height: 32px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid + ${({ $variant }) => + $variant === "ghost" + ? "rgba(191, 219, 254, 0.92)" + : "rgba(59, 130, 246, 0.94)"}; + background: ${({ $variant }) => + $variant === "ghost" + ? "rgba(255, 255, 255, 0.92)" + : "linear-gradient(180deg, rgba(59,130,246,0.96) 0%, rgba(37,99,235,0.96) 100%)"}; + color: ${({ $variant }) => ($variant === "ghost" ? "#1e293b" : "#eff6ff")}; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: + transform 0.16s ease, + box-shadow 0.2s ease, + background 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px -18px rgba(37, 99, 235, 0.46); + background: ${({ $variant }) => + $variant === "ghost" + ? "rgba(239, 246, 255, 0.98)" + : "linear-gradient(180deg, rgba(37,99,235,0.98) 0%, rgba(29,78,216,0.98) 100%)"}; + } +`; + +export const ThemeWorkbenchEntryPromptAccessory = memo( + function ThemeWorkbenchEntryPromptAccessory({ + prompt, + onRestart, + onContinue, + }: ThemeWorkbenchEntryPromptAccessoryProps) { + return ( + + + + + + {prompt.title} + + + {prompt.description} + + + + + + 重新开始 + + { + void onContinue(); + }} + > + {prompt.actionLabel} + + + + ); + }, +); diff --git a/src/components/agent/chat/homeShellEntry.test.ts b/src/components/agent/chat/homeShellEntry.test.ts new file mode 100644 index 000000000..d22611ee5 --- /dev/null +++ b/src/components/agent/chat/homeShellEntry.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import type { ChatToolPreferences } from "./utils/chatToolPreferences"; +import { resolveHomeShellWorkspaceEntry } from "./homeShellEntry"; + +const defaultToolPreferences: ChatToolPreferences = { + webSearch: false, + thinking: false, + task: false, + subagent: false, +}; + +describe("homeShellEntry", () => { + it("缺少项目且不是浏览器协助时应拒绝进入", () => { + expect( + resolveHomeShellWorkspaceEntry({ + projectId: null, + activeTheme: "general", + creationMode: "guided", + defaultToolPreferences, + payload: { + prompt: "帮我整理方案", + }, + now: () => 123, + }), + ).toEqual({ + ok: false, + reason: "missing_project", + }); + }); + + it("没有 prompt 和图片时应拒绝空进入", () => { + expect( + resolveHomeShellWorkspaceEntry({ + projectId: "project-1", + activeTheme: "general", + creationMode: "guided", + defaultToolPreferences, + payload: {}, + now: () => 123, + }), + ).toEqual({ + ok: false, + reason: "empty_payload", + }); + }); + + it("应生成导航参数与工作区 bootstrap", () => { + expect( + resolveHomeShellWorkspaceEntry({ + projectId: "project-1", + activeTheme: "general", + creationMode: "guided", + defaultToolPreferences, + payload: { + prompt: "请起草一版首稿", + themeOverride: "social-media", + }, + now: () => 456, + }), + ).toEqual({ + ok: true, + toolPreferences: defaultToolPreferences, + targetTheme: "social-media", + nextNewChatAt: 456, + navigationParams: { + agentEntry: "claw", + immersiveHome: false, + projectId: "project-1", + theme: "social-media", + initialCreationMode: "guided", + initialUserPrompt: "请起草一版首稿", + initialUserImages: undefined, + openBrowserAssistOnMount: undefined, + newChatAt: 456, + lockTheme: false, + }, + workspaceBootstrap: { + projectId: "project-1", + initialUserPrompt: "请起草一版首稿", + initialUserImages: undefined, + theme: "social-media", + initialCreationMode: "guided", + openBrowserAssistOnMount: undefined, + newChatAt: 456, + }, + }); + }); + + it("浏览器协助入口允许无项目直接进入", () => { + expect( + resolveHomeShellWorkspaceEntry({ + projectId: null, + activeTheme: "general", + creationMode: "guided", + defaultToolPreferences, + payload: { + prompt: "", + openBrowserAssistOnMount: true, + }, + now: () => 789, + }), + ).toEqual({ + ok: true, + toolPreferences: defaultToolPreferences, + targetTheme: "general", + nextNewChatAt: 789, + navigationParams: { + agentEntry: "claw", + immersiveHome: false, + projectId: undefined, + theme: "general", + initialCreationMode: "guided", + initialUserPrompt: "", + initialUserImages: undefined, + openBrowserAssistOnMount: true, + newChatAt: 789, + lockTheme: false, + }, + workspaceBootstrap: { + projectId: undefined, + initialUserPrompt: "", + initialUserImages: undefined, + theme: "general", + initialCreationMode: "guided", + openBrowserAssistOnMount: true, + newChatAt: 789, + }, + }); + }); +}); diff --git a/src/components/agent/chat/homeShellEntry.ts b/src/components/agent/chat/homeShellEntry.ts new file mode 100644 index 000000000..73c6104a6 --- /dev/null +++ b/src/components/agent/chat/homeShellEntry.ts @@ -0,0 +1,111 @@ +import { buildClawAgentParams } from "@/lib/workspace/navigation"; +import type { AgentPageParams } from "@/types/page"; +import type { CreationMode } from "./components/types"; +import type { MessageImage } from "./types"; +import type { ChatToolPreferences } from "./utils/chatToolPreferences"; + +export interface AgentChatWorkspaceBootstrap { + projectId?: string; + initialUserPrompt?: string; + initialUserImages?: MessageImage[]; + theme?: string; + initialCreationMode?: CreationMode; + openBrowserAssistOnMount?: boolean; + newChatAt?: number; +} + +export interface HomeShellEnterWorkspacePayload { + prompt?: string; + images?: MessageImage[]; + openBrowserAssistOnMount?: boolean; + toolPreferences?: ChatToolPreferences; + themeOverride?: string; +} + +export interface ResolveHomeShellWorkspaceEntryInput { + projectId?: string | null; + activeTheme: string; + creationMode: CreationMode; + defaultToolPreferences: ChatToolPreferences; + payload: HomeShellEnterWorkspacePayload; + now?: () => number; +} + +export type HomeShellEnterWorkspaceRejectedReason = + | "missing_project" + | "empty_payload"; + +export type ResolvedHomeShellWorkspaceEntry = + | { + ok: false; + reason: HomeShellEnterWorkspaceRejectedReason; + } + | { + ok: true; + toolPreferences: ChatToolPreferences; + targetTheme: string; + nextNewChatAt: number; + navigationParams: AgentPageParams; + workspaceBootstrap: AgentChatWorkspaceBootstrap; + }; + +export function resolveHomeShellWorkspaceEntry( + input: ResolveHomeShellWorkspaceEntryInput, +): ResolvedHomeShellWorkspaceEntry { + const { + projectId, + activeTheme, + creationMode, + defaultToolPreferences, + payload, + now = () => Date.now(), + } = input; + + const hasPrompt = Boolean(payload.prompt?.trim()); + const hasImages = Boolean(payload.images?.length); + const toolPreferences = payload.toolPreferences ?? defaultToolPreferences; + const targetTheme = payload.themeOverride ?? activeTheme; + const openBrowserAssistOnMount = payload.openBrowserAssistOnMount; + + if (!openBrowserAssistOnMount && !projectId) { + return { + ok: false, + reason: "missing_project", + }; + } + + if (!openBrowserAssistOnMount && !hasPrompt && !hasImages) { + return { + ok: false, + reason: "empty_payload", + }; + } + + const nextNewChatAt = now(); + const shared = { + projectId: projectId ?? undefined, + theme: targetTheme, + initialCreationMode: creationMode, + initialUserPrompt: payload.prompt, + initialUserImages: payload.images, + openBrowserAssistOnMount, + newChatAt: nextNewChatAt, + } satisfies AgentPageParams; + + return { + ok: true, + toolPreferences, + targetTheme, + nextNewChatAt, + navigationParams: buildClawAgentParams(shared), + workspaceBootstrap: { + projectId: projectId ?? undefined, + initialUserPrompt: payload.prompt, + initialUserImages: payload.images, + theme: targetTheme, + initialCreationMode: creationMode, + openBrowserAssistOnMount, + newChatAt: nextNewChatAt, + }, + }; +} diff --git a/src/components/agent/chat/hooks/agentChatHistory.test.ts b/src/components/agent/chat/hooks/agentChatHistory.test.ts index ed0a2142b..20ffda8f1 100644 --- a/src/components/agent/chat/hooks/agentChatHistory.test.ts +++ b/src/components/agent/chat/hooks/agentChatHistory.test.ts @@ -8,6 +8,49 @@ import { } from "./agentChatHistory"; describe("agentChatHistory", () => { + it("应清理仅用于内部展示的图片占位文本", () => { + const detail: AsterSessionDetail = { + id: "session-image-placeholder", + created_at: 1, + updated_at: 2, + messages: [ + { + role: "user", + timestamp: 1710000200, + content: [ + { type: "input_text", text: "[Image #1]" }, + { + type: "input_image", + image_url: "data:image/png;base64,aGVsbG8=", + }, + ], + }, + { + role: "assistant", + timestamp: 1710000201, + content: [{ type: "output_text", text: "已收到图片" }], + }, + ], + }; + + const messages = hydrateSessionDetailMessages( + detail, + "session-image-placeholder", + ); + + expect(messages[0]).toMatchObject({ + role: "user", + content: "", + images: [ + { + mediaType: "image/png", + data: "aGVsbG8=", + }, + ], + }); + expect(messages[1]?.content).toBe("已收到图片"); + }); + it("应从历史消息的 thinking 字段恢复完整思考过程", () => { const detail: AsterSessionDetail = { id: "session-1", diff --git a/src/components/agent/chat/hooks/agentChatHistory.ts b/src/components/agent/chat/hooks/agentChatHistory.ts index d690fb5d5..1869095ad 100644 --- a/src/components/agent/chat/hooks/agentChatHistory.ts +++ b/src/components/agent/chat/hooks/agentChatHistory.ts @@ -11,6 +11,10 @@ import { resolveHistoryUserDataText, stringifyToolArguments, } from "./agentChatToolResult"; +import { + sanitizeContentPartsForDisplay, + sanitizeMessageTextForDisplay, +} from "../utils/internalImagePlaceholder"; export const normalizeHistoryPartType = (value: unknown): string => { if (typeof value !== "string") return ""; @@ -569,12 +573,21 @@ export const hydrateSessionDetailMessages = ( contentParts.push({ type: "text", text: resolved }); } - const content = textParts.join("\n").trim(); + const rawContent = textParts.join("\n").trim(); let normalizedRole = msg.role === "tool" ? "assistant" : (msg.role as "user" | "assistant"); + const content = sanitizeMessageTextForDisplay(rawContent, { + role: normalizedRole, + hasImages: images.length > 0, + }); + const sanitizedContentParts = + sanitizeContentPartsForDisplay(contentParts, { + role: normalizedRole, + hasImages: images.length > 0, + }) || []; const hasToolMetadata = toolCalls.length > 0 || - contentParts.some((part) => part.type === "tool_use"); + sanitizedContentParts.some((part) => part.type === "tool_use"); if (normalizedRole === "user" && !content && images.length === 0) { if (hasToolMetadata) { @@ -587,7 +600,7 @@ export const hydrateSessionDetailMessages = ( if ( !content && images.length === 0 && - contentParts.length === 0 && + sanitizedContentParts.length === 0 && toolCalls.length === 0 ) { return []; @@ -599,11 +612,16 @@ export const hydrateSessionDetailMessages = ( role: normalizedRole, content, images: images.length > 0 ? images : undefined, - contentParts: contentParts.length > 0 ? contentParts : undefined, + contentParts: + sanitizedContentParts.length > 0 + ? sanitizedContentParts + : undefined, toolCalls: toolCalls.length > 0 ? toolCalls : undefined, timestamp: messageTimestamp, isThinking: false, - thinkingContent: extractThinkingContentFromParts(contentParts), + thinkingContent: extractThinkingContentFromParts( + sanitizedContentParts, + ), }, ]; }); diff --git a/src/components/agent/chat/hooks/agentChatShared.test.ts b/src/components/agent/chat/hooks/agentChatShared.test.ts index d5e8b961d..ac91d1f49 100644 --- a/src/components/agent/chat/hooks/agentChatShared.test.ts +++ b/src/components/agent/chat/hooks/agentChatShared.test.ts @@ -134,6 +134,40 @@ describe("agentChatShared", () => { ); }); + it("图片占位符不应直接出现在任务摘要里", () => { + const now = new Date("2026-03-19T00:00:00.000Z"); + const messages: Message[] = [ + { + id: "msg-user-image", + role: "user", + content: "[Image #1]", + images: [ + { + mediaType: "image/png", + data: "aGVsbG8=", + }, + ], + timestamp: now, + }, + ]; + + expect(extractTaskPreviewFromMessages(messages)).toBe("已附加图片"); + }); + + it("助手内部图片标签应转换为自然语言摘要", () => { + const now = new Date("2026-03-19T00:00:00.000Z"); + const messages: Message[] = [ + { + id: "msg-assistant-image", + role: "assistant", + content: "[Image #1]", + timestamp: now, + }, + ]; + + expect(extractTaskPreviewFromMessages(messages)).toBe("图片处理中"); + }); + it("应按浏览器预检阶段映射不同状态原因", () => { expect( deriveTaskLiveState({ diff --git a/src/components/agent/chat/hooks/agentChatShared.ts b/src/components/agent/chat/hooks/agentChatShared.ts index 3fb8d8819..f43a55274 100644 --- a/src/components/agent/chat/hooks/agentChatShared.ts +++ b/src/components/agent/chat/hooks/agentChatShared.ts @@ -5,6 +5,7 @@ import type { } from "@/lib/api/agentRuntime"; import type { Message, MessageImage, WriteArtifactContext } from "../types"; import { normalizeExecutionStrategy } from "./agentChatCoreUtils"; +import { sanitizeMessageTextForPreview } from "../utils/internalImagePlaceholder"; export type TaskStatus = "draft" | "running" | "waiting" | "done" | "failed"; export type TaskStatusReason = @@ -120,8 +121,12 @@ function normalizeTaskPreviewText(value: string) { } function extractMessageTextContent(message: Message): string { - if (message.content?.trim()) { - return normalizeTaskPreviewText(message.content); + const sanitizedContent = sanitizeMessageTextForPreview(message.content || "", { + role: message.role, + hasImages: Array.isArray(message.images) && message.images.length > 0, + }); + if (sanitizedContent) { + return normalizeTaskPreviewText(sanitizedContent); } const partText = message.contentParts @@ -129,7 +134,15 @@ function extractMessageTextContent(message: Message): string { (part): part is Extract<(typeof message.contentParts)[number], { type: "text" | "thinking" }> => part.type === "text" || part.type === "thinking", ) - .map((part) => part.text) + .map((part) => + part.type === "text" + ? sanitizeMessageTextForPreview(part.text, { + role: message.role, + hasImages: Array.isArray(message.images) && message.images.length > 0, + }) + : part.text, + ) + .filter(Boolean) .join(" "); return normalizeTaskPreviewText(partText || ""); diff --git a/src/components/agent/chat/hooks/agentProjectStorage.test.tsx b/src/components/agent/chat/hooks/agentProjectStorage.test.tsx new file mode 100644 index 000000000..34ec89a6b --- /dev/null +++ b/src/components/agent/chat/hooks/agentProjectStorage.test.tsx @@ -0,0 +1,100 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + LAST_PROJECT_ID_KEY, + loadPersistedProjectId, + savePersistedProjectId, + usePersistedProjectId, +} from "./agentProjectStorage"; + +interface HookHarness { + getValue: () => ReturnType; + rerender: (externalProjectId?: string | null) => void; + unmount: () => void; +} + +function mountHook(initialExternalProjectId?: string | null): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let hookValue: ReturnType | null = null; + + function TestComponent({ + externalProjectId, + }: { + externalProjectId?: string | null; + }) { + hookValue = usePersistedProjectId(externalProjectId); + return null; + } + + const render = (externalProjectId?: string | null) => { + act(() => { + root.render(); + }); + }; + + render(initialExternalProjectId); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + rerender: render, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +describe("agentProjectStorage", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("应保存并读取最近项目偏好", () => { + savePersistedProjectId(LAST_PROJECT_ID_KEY, "project-a"); + + expect(loadPersistedProjectId(LAST_PROJECT_ID_KEY)).toBe("project-a"); + }); + + it("hook 应优先使用 externalProjectId,否则回退最近项目", () => { + savePersistedProjectId(LAST_PROJECT_ID_KEY, "project-local"); + + const harness = mountHook(); + + try { + expect(harness.getValue().projectId).toBe("project-local"); + + harness.rerender("project-external"); + expect(harness.getValue().projectId).toBe("project-external"); + + act(() => { + harness.getValue().setProjectId("project-updated"); + harness.getValue().rememberProjectId("project-updated"); + }); + + expect(harness.getValue().projectId).toBe("project-updated"); + expect(loadPersistedProjectId(LAST_PROJECT_ID_KEY)).toBe("project-updated"); + } finally { + harness.unmount(); + } + }); +}); diff --git a/src/components/agent/chat/hooks/agentProjectStorage.ts b/src/components/agent/chat/hooks/agentProjectStorage.ts new file mode 100644 index 000000000..27f17db84 --- /dev/null +++ b/src/components/agent/chat/hooks/agentProjectStorage.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useState } from "react"; +import { normalizeProjectId } from "../utils/topicProjectResolution"; + +export const LAST_PROJECT_ID_KEY = "agent_last_project_id"; + +export 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; + } +} + +export function savePersistedProjectId(key: string, projectId: string): void { + const normalized = normalizeProjectId(projectId); + if (!normalized) { + return; + } + + try { + localStorage.setItem(key, JSON.stringify(normalized)); + } catch { + // ignore write errors + } +} + +export function usePersistedProjectId( + externalProjectId?: string | null, + storageKey = LAST_PROJECT_ID_KEY, +) { + const resolveProjectId = useCallback( + () => + normalizeProjectId(externalProjectId) ?? + loadPersistedProjectId(storageKey), + [externalProjectId, storageKey], + ); + const [projectId, setProjectIdState] = useState(() => + resolveProjectId(), + ); + + useEffect(() => { + setProjectIdState(resolveProjectId()); + }, [resolveProjectId]); + + const setProjectId = useCallback((nextProjectId?: string | null) => { + setProjectIdState(normalizeProjectId(nextProjectId)); + }, []); + + const rememberProjectId = useCallback( + (nextProjectId?: string | null) => { + const normalized = normalizeProjectId(nextProjectId); + if (!normalized) { + return; + } + savePersistedProjectId(storageKey, normalized); + }, + [storageKey], + ); + + return { + projectId, + setProjectId, + rememberProjectId, + }; +} diff --git a/src/components/agent/chat/hooks/handleSendTypes.ts b/src/components/agent/chat/hooks/handleSendTypes.ts new file mode 100644 index 000000000..4b7c53320 --- /dev/null +++ b/src/components/agent/chat/hooks/handleSendTypes.ts @@ -0,0 +1,35 @@ +import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime"; +import type { ChatToolPreferences } from "../utils/chatToolPreferences"; +import type { BrowserPreflightState, BrowserTaskRequirement, MessageImage } from "../types"; + +export interface HandleSendObserver { + onComplete?: (content: string) => void; + onError?: (message: string) => void; +} + +export interface HandleSendOptions { + skipThemeSkillPrefix?: boolean; + purpose?: "content_review" | "text_stylize" | "style_rewrite" | "style_audit"; + observer?: HandleSendObserver; + requestMetadata?: Record; + browserPreflightConfirmed?: boolean; + toolPreferencesOverride?: ChatToolPreferences; +} + +export 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; +} diff --git a/src/components/agent/chat/hooks/index.ts b/src/components/agent/chat/hooks/index.ts index dae26a8a6..cc7443024 100644 --- a/src/components/agent/chat/hooks/index.ts +++ b/src/components/agent/chat/hooks/index.ts @@ -32,6 +32,7 @@ export function useAgentChatUnified(options: UseAgentChatUnifiedOptions) { // 旧 useAgentChat 已删除,避免新代码继续沿 compat 路径扩展。 export { useAsterAgentChat } from "./useAsterAgentChat"; +export { useRuntimeTeamFormation } from "./useRuntimeTeamFormation"; export { useTeamWorkspaceRuntime } from "./useTeamWorkspaceRuntime"; export { useCompatSubagentRuntime } from "./useCompatSubagentRuntime"; export { useThemeContextWorkspace } from "./useThemeContextWorkspace"; diff --git a/src/components/agent/chat/hooks/useAgentContext.test.tsx b/src/components/agent/chat/hooks/useAgentContext.test.tsx new file mode 100644 index 000000000..b82d26efe --- /dev/null +++ b/src/components/agent/chat/hooks/useAgentContext.test.tsx @@ -0,0 +1,139 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockNotifyProjectRuntimeAgentsGuide, + mockSetSessionExecutionStrategy, + mockToastError, + mockUpdateProject, +} = vi.hoisted(() => ({ + mockNotifyProjectRuntimeAgentsGuide: vi.fn(), + mockSetSessionExecutionStrategy: vi.fn(async () => undefined), + mockToastError: vi.fn(), + mockUpdateProject: vi.fn(async () => undefined), +})); + +vi.mock("sonner", () => ({ + toast: { + error: mockToastError, + }, +})); + +vi.mock("@/lib/api/project", () => ({ + updateProject: mockUpdateProject, +})); + +vi.mock("@/components/workspace/services/runtimeAgentsGuideService", () => ({ + notifyProjectRuntimeAgentsGuide: mockNotifyProjectRuntimeAgentsGuide, +})); + +import { useAgentContext } from "./useAgentContext"; + +interface HookHarness { + getValue: () => ReturnType; + unmount: () => void; + sendMessage: ReturnType; +} + +function mountHook(workspaceId = "workspace-1"): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const sendMessage = vi.fn(async () => undefined); + let hookValue: ReturnType | null = null; + + function TestComponent() { + hookValue = useAgentContext({ + workspaceId, + sessionIdRef: { current: null }, + topicsUpdaterRef: { current: null }, + sendMessageRef: { current: sendMessage }, + runtime: { + setSessionExecutionStrategy: mockSetSessionExecutionStrategy, + }, + }); + return null; + } + + act(() => { + root.render(); + }); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + sendMessage, + }; +} + +describe("useAgentContext", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + mockNotifyProjectRuntimeAgentsGuide.mockReset(); + mockSetSessionExecutionStrategy.mockClear(); + mockToastError.mockReset(); + mockUpdateProject.mockReset(); + localStorage.clear(); + sessionStorage.clear(); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("修复目录并重试时应触发运行时 AGENTS 引导", async () => { + const harness = mountHook(); + + act(() => { + harness.getValue().setWorkspacePathMissing({ + content: "继续上次对话", + images: [], + }); + }); + + await act(async () => { + await harness + .getValue() + .fixWorkspacePathAndRetry("/tmp/workspace-linked"); + }); + + expect(mockUpdateProject).toHaveBeenCalledWith("workspace-1", { + rootPath: "/tmp/workspace-linked", + }); + expect(mockNotifyProjectRuntimeAgentsGuide).toHaveBeenCalledWith( + { + id: "workspace-1", + rootPath: "/tmp/workspace-linked", + }, + { + successMessage: "工作区目录已重新关联", + showSuccessWhenGuideAlreadySeen: false, + }, + ); + expect(harness.sendMessage).toHaveBeenCalledWith( + "继续上次对话", + [], + false, + false, + true, + ); + + harness.unmount(); + }); +}); diff --git a/src/components/agent/chat/hooks/useAgentContext.ts b/src/components/agent/chat/hooks/useAgentContext.ts index 1eac8b65d..8adfd295f 100644 --- a/src/components/agent/chat/hooks/useAgentContext.ts +++ b/src/components/agent/chat/hooks/useAgentContext.ts @@ -8,6 +8,7 @@ import { import { toast } from "sonner"; import { updateProject } from "@/lib/api/project"; import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime"; +import { notifyProjectRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService"; import type { SendMessageFn, SessionModelPreference, @@ -286,6 +287,16 @@ export function useAgentContext(options: UseAgentContextOptions) { setWorkspacePathMissing(null); try { await updateProject(workspaceId, { rootPath: newPath }); + notifyProjectRuntimeAgentsGuide( + { + id: workspaceId, + rootPath: newPath, + }, + { + successMessage: "工作区目录已重新关联", + showSuccessWhenGuideAlreadySeen: false, + }, + ); await sendMessage(retryContent, retryImages, false, false, true); } catch (err) { toast.error( diff --git a/src/components/agent/chat/hooks/useBootstrapDispatchPreview.test.tsx b/src/components/agent/chat/hooks/useBootstrapDispatchPreview.test.tsx new file mode 100644 index 000000000..321c70afa --- /dev/null +++ b/src/components/agent/chat/hooks/useBootstrapDispatchPreview.test.tsx @@ -0,0 +1,156 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + buildInitialDispatchKey, + useBootstrapDispatchPreview, +} from "./useBootstrapDispatchPreview"; + +interface HookHarness { + getValue: () => ReturnType; + rerender: ( + props?: Partial<{ + initialUserPrompt?: string; + initialUserImages?: Array<{ data: string; mediaType: string }>; + messagesCount: number; + isSending: boolean; + queuedTurnCount: number; + consumedInitialPromptKey?: string | null; + shouldUseCompactThemeWorkbench?: boolean; + }>, + ) => void; + unmount: () => void; +} + +function mountHook( + initialProps?: Partial<{ + initialUserPrompt?: string; + initialUserImages?: Array<{ data: string; mediaType: string }>; + messagesCount: number; + isSending: boolean; + queuedTurnCount: number; + consumedInitialPromptKey?: string | null; + shouldUseCompactThemeWorkbench?: boolean; + }>, +): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let hookValue: ReturnType | null = null; + let currentProps = { + initialUserPrompt: "", + initialUserImages: [], + messagesCount: 0, + isSending: false, + queuedTurnCount: 0, + consumedInitialPromptKey: null, + shouldUseCompactThemeWorkbench: false, + ...initialProps, + }; + + function TestComponent() { + hookValue = useBootstrapDispatchPreview(currentProps); + return null; + } + + const render = ( + nextProps?: Partial<{ + initialUserPrompt?: string; + initialUserImages?: Array<{ data: string; mediaType: string }>; + messagesCount: number; + isSending: boolean; + queuedTurnCount: number; + consumedInitialPromptKey?: string | null; + shouldUseCompactThemeWorkbench?: boolean; + }>, + ) => { + currentProps = { + ...currentProps, + ...nextProps, + }; + act(() => { + root.render(); + }); + }; + + render(); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + rerender: render, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +describe("useBootstrapDispatchPreview", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + // noop + }); + + it("应生成稳定的 initialDispatchKey", () => { + expect( + buildInitialDispatchKey("写一篇文章", [ + { data: "abcdef1234567890", mediaType: "image/png" }, + ]), + ).toContain("写一篇文章"); + }); + + it("发送中且无消息时应展示 bootstrap 预览消息", () => { + const harness = mountHook({ + initialUserPrompt: "请开始处理这个任务", + isSending: true, + }); + + try { + const value = harness.getValue(); + expect(value.initialDispatchKey).toBeTruthy(); + expect(value.shouldShowBootstrapDispatchPreview).toBe(true); + expect(value.bootstrapDispatchPreviewMessages).toHaveLength(2); + expect(value.bootstrapDispatchPreviewMessages[0]?.content).toBe( + "请开始处理这个任务", + ); + } finally { + harness.unmount(); + } + }); + + it("有真实消息后应清空 bootstrap 预览", () => { + const harness = mountHook({ + initialUserPrompt: "请开始处理这个任务", + isSending: true, + }); + + try { + expect(harness.getValue().bootstrapDispatchPreviewMessages).toHaveLength(2); + + harness.rerender({ + messagesCount: 1, + isSending: false, + }); + + expect(harness.getValue().bootstrapDispatchPreviewMessages).toHaveLength(0); + expect(harness.getValue().shouldShowBootstrapDispatchPreview).toBe(false); + } finally { + harness.unmount(); + } + }); +}); diff --git a/src/components/agent/chat/hooks/useBootstrapDispatchPreview.ts b/src/components/agent/chat/hooks/useBootstrapDispatchPreview.ts new file mode 100644 index 000000000..4e7aafcd3 --- /dev/null +++ b/src/components/agent/chat/hooks/useBootstrapDispatchPreview.ts @@ -0,0 +1,159 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Message, MessageImage } from "../types"; + +export interface InitialDispatchPreviewSnapshot { + key: string; + prompt?: string; + images: MessageImage[]; +} + +interface UseBootstrapDispatchPreviewOptions { + initialUserPrompt?: string; + initialUserImages?: MessageImage[]; + messagesCount: number; + isSending: boolean; + queuedTurnCount: number; + consumedInitialPromptKey?: string | null; + shouldUseCompactThemeWorkbench?: boolean; +} + +export 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 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, + }, + ]; +} + +export function useBootstrapDispatchPreview({ + initialUserPrompt, + initialUserImages, + messagesCount, + isSending, + queuedTurnCount, + consumedInitialPromptKey, + shouldUseCompactThemeWorkbench = false, +}: UseBootstrapDispatchPreviewOptions) { + const initialDispatchKey = useMemo( + () => buildInitialDispatchKey(initialUserPrompt, initialUserImages), + [initialUserImages, initialUserPrompt], + ); + const [bootstrapDispatchSnapshot, setBootstrapDispatchSnapshot] = + useState(null); + + useEffect(() => { + if (!initialDispatchKey) { + return; + } + + setBootstrapDispatchSnapshot({ + key: initialDispatchKey, + prompt: initialUserPrompt, + images: initialUserImages || [], + }); + }, [initialDispatchKey, initialUserImages, initialUserPrompt]); + + useEffect(() => { + if (messagesCount > 0) { + setBootstrapDispatchSnapshot(null); + return; + } + + if (!initialDispatchKey && !isSending && queuedTurnCount === 0) { + setBootstrapDispatchSnapshot(null); + } + }, [initialDispatchKey, isSending, messagesCount, queuedTurnCount]); + + 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 && + consumedInitialPromptKey !== activeBootstrapDispatch.key; + const shouldShowBootstrapDispatchPreview = + !shouldUseCompactThemeWorkbench && + Boolean(activeBootstrapDispatch) && + messagesCount === 0 && + (isSending || queuedTurnCount > 0); + const bootstrapDispatchPreviewMessages = useMemo(() => { + if (!shouldShowBootstrapDispatchPreview || !activeBootstrapDispatch) { + return [] as Message[]; + } + + return buildInitialDispatchPreviewMessages( + activeBootstrapDispatch.key, + activeBootstrapDispatch.prompt, + activeBootstrapDispatch.images, + ); + }, [activeBootstrapDispatch, shouldShowBootstrapDispatchPreview]); + + return { + initialDispatchKey, + activeBootstrapDispatch, + isBootstrapDispatchPending, + shouldShowBootstrapDispatchPreview, + bootstrapDispatchPreviewMessages, + }; +} diff --git a/src/components/agent/chat/hooks/useHomeShellAgentPreferences.test.tsx b/src/components/agent/chat/hooks/useHomeShellAgentPreferences.test.tsx new file mode 100644 index 000000000..6dac20b40 --- /dev/null +++ b/src/components/agent/chat/hooks/useHomeShellAgentPreferences.test.tsx @@ -0,0 +1,146 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { useHomeShellAgentPreferences } from "./useHomeShellAgentPreferences"; + +interface HookHarness { + getValue: () => ReturnType; + rerender: (projectId?: string | null) => void; + unmount: () => void; +} + +function mountHook(initialProjectId?: string | null): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let hookValue: ReturnType | null = null; + + function TestComponent({ projectId }: { projectId?: string | null }) { + hookValue = useHomeShellAgentPreferences(projectId); + return null; + } + + const render = (projectId?: string | null) => { + act(() => { + root.render(); + }); + }; + + render(initialProjectId); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + rerender: render, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +describe("useHomeShellAgentPreferences", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("应按项目作用域读取 provider、model 与执行策略", () => { + localStorage.setItem( + "agent_pref_provider_project-home-a", + JSON.stringify("openai"), + ); + localStorage.setItem( + "agent_pref_model_project-home-a", + JSON.stringify("gpt-5-mini"), + ); + localStorage.setItem( + "aster_execution_strategy_project-home-a", + JSON.stringify("auto"), + ); + + const harness = mountHook("project-home-a"); + + try { + expect(harness.getValue().providerType).toBe("openai"); + expect(harness.getValue().model).toBe("gpt-5-mini"); + expect(harness.getValue().executionStrategy).toBe("auto"); + } finally { + harness.unmount(); + } + }); + + it("切换项目时应重载对应偏好,并把变更写回当前作用域", () => { + localStorage.setItem( + "agent_pref_provider_project-home-a", + JSON.stringify("openai"), + ); + localStorage.setItem( + "agent_pref_model_project-home-a", + JSON.stringify("gpt-5-mini"), + ); + localStorage.setItem( + "aster_execution_strategy_project-home-a", + JSON.stringify("auto"), + ); + localStorage.setItem( + "agent_pref_provider_project-home-b", + JSON.stringify("claude"), + ); + localStorage.setItem( + "agent_pref_model_project-home-b", + JSON.stringify("claude-sonnet-4-5"), + ); + + const harness = mountHook("project-home-a"); + + try { + harness.rerender("project-home-b"); + + expect(harness.getValue().providerType).toBe("claude"); + expect(harness.getValue().model).toBe("claude-sonnet-4-5"); + expect(harness.getValue().executionStrategy).toBe("react"); + + act(() => { + harness.getValue().setProviderType("gemini"); + harness.getValue().setModel("gemini-2.5-pro"); + harness.getValue().setExecutionStrategy("code_orchestrated"); + }); + + expect( + JSON.parse( + localStorage.getItem("agent_pref_provider_project-home-b") || "null", + ), + ).toBe("gemini"); + expect( + JSON.parse( + localStorage.getItem("agent_pref_model_project-home-b") || "null", + ), + ).toBe("gemini-2.5-pro"); + expect( + JSON.parse( + localStorage.getItem( + "aster_execution_strategy_project-home-b", + ) || "null", + ), + ).toBe("code_orchestrated"); + } finally { + harness.unmount(); + } + }); +}); diff --git a/src/components/agent/chat/hooks/useHomeShellAgentPreferences.ts b/src/components/agent/chat/hooks/useHomeShellAgentPreferences.ts new file mode 100644 index 000000000..29ae62b34 --- /dev/null +++ b/src/components/agent/chat/hooks/useHomeShellAgentPreferences.ts @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useState } from "react"; +import type { AsterExecutionStrategy } from "@/lib/api/agentRuntime"; +import { normalizeProjectId } from "../utils/topicProjectResolution"; +import { normalizeExecutionStrategy } from "./agentChatCoreUtils"; +import { + getAgentPreferenceKeys, + loadPersisted, + resolveWorkspaceAgentPreferences, + savePersisted, +} from "./agentChatStorage"; + +function resolveExecutionStrategyStorageKey( + projectId?: string | null, +): string | null { + const normalizedProjectId = normalizeProjectId(projectId); + if (!normalizedProjectId) { + return null; + } + + return `aster_execution_strategy_${normalizedProjectId}`; +} + +function resolvePersistedExecutionStrategy( + projectId?: string | null, +): AsterExecutionStrategy { + const storageKey = resolveExecutionStrategyStorageKey(projectId); + if (!storageKey) { + return "react"; + } + + return normalizeExecutionStrategy( + loadPersisted(storageKey, "react"), + ); +} + +export function useHomeShellAgentPreferences(projectId?: string | null) { + const normalizedProjectId = normalizeProjectId(projectId); + const initialPreferences = + resolveWorkspaceAgentPreferences(normalizedProjectId); + const [providerType, setProviderTypeState] = useState( + initialPreferences.providerType, + ); + const [model, setModelState] = useState(initialPreferences.model); + const [executionStrategy, setExecutionStrategyState] = + useState(() => + resolvePersistedExecutionStrategy(normalizedProjectId), + ); + + useEffect(() => { + const scopedPreferences = + resolveWorkspaceAgentPreferences(normalizedProjectId); + setProviderTypeState(scopedPreferences.providerType); + setModelState(scopedPreferences.model); + setExecutionStrategyState( + resolvePersistedExecutionStrategy(normalizedProjectId), + ); + }, [normalizedProjectId]); + + const setProviderType = useCallback( + (nextProviderType: string) => { + setProviderTypeState(nextProviderType); + const { providerKey } = getAgentPreferenceKeys(normalizedProjectId); + savePersisted(providerKey, nextProviderType); + }, + [normalizedProjectId], + ); + + const setModel = useCallback( + (nextModel: string) => { + setModelState(nextModel); + const { modelKey } = getAgentPreferenceKeys(normalizedProjectId); + savePersisted(modelKey, nextModel); + }, + [normalizedProjectId], + ); + + const setExecutionStrategy = useCallback( + (nextExecutionStrategy: AsterExecutionStrategy) => { + const normalized = normalizeExecutionStrategy(nextExecutionStrategy); + setExecutionStrategyState(normalized); + const storageKey = resolveExecutionStrategyStorageKey(normalizedProjectId); + if (!storageKey) { + return; + } + savePersisted(storageKey, normalized); + }, + [normalizedProjectId], + ); + + return { + providerType, + setProviderType, + model, + setModel, + executionStrategy, + setExecutionStrategy, + }; +} diff --git a/src/components/agent/chat/hooks/useHomeShellProjectMemory.test.tsx b/src/components/agent/chat/hooks/useHomeShellProjectMemory.test.tsx new file mode 100644 index 000000000..1b85a6d5d --- /dev/null +++ b/src/components/agent/chat/hooks/useHomeShellProjectMemory.test.tsx @@ -0,0 +1,115 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useHomeShellProjectMemory } from "./useHomeShellProjectMemory"; + +const mockGetProjectMemory = vi.hoisted(() => vi.fn()); +const mockLogAgentDebug = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api/memory", () => ({ + getProjectMemory: mockGetProjectMemory, +})); + +vi.mock("@/lib/agentDebug", () => ({ + logAgentDebug: mockLogAgentDebug, +})); + +interface HookHarness { + getValue: () => ReturnType; + rerender: (projectId?: string | null) => void; + unmount: () => void; +} + +function mountHook(initialProjectId?: string | null): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let hookValue: ReturnType | null = null; + + function TestComponent({ projectId }: { projectId?: string | null }) { + hookValue = useHomeShellProjectMemory(projectId); + return null; + } + + const render = (projectId?: string | null) => { + act(() => { + root.render(); + }); + }; + + render(initialProjectId); + + return { + getValue: () => hookValue, + rerender: render, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +async function flushEffects(times = 4) { + for (let index = 0; index < times; index += 1) { + await act(async () => { + await Promise.resolve(); + }); + } +} + +describe("useHomeShellProjectMemory", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.clearAllMocks(); + mockGetProjectMemory.mockResolvedValue({ + characters: [{ id: "char-1", name: "主角" }], + outline: [], + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("无项目时应直接返回空记忆且不触发加载", async () => { + const harness = mountHook(); + + try { + await flushEffects(); + expect(harness.getValue()).toBeNull(); + expect(mockGetProjectMemory).not.toHaveBeenCalled(); + } finally { + harness.unmount(); + } + }); + + it("切换项目时应加载对应项目记忆", async () => { + const harness = mountHook("project-memory-a"); + + try { + await flushEffects(); + expect(mockGetProjectMemory).toHaveBeenCalledWith("project-memory-a"); + expect(harness.getValue()?.characters[0]?.name).toBe("主角"); + + mockGetProjectMemory.mockResolvedValueOnce({ + characters: [{ id: "char-2", name: "配角" }], + outline: [], + }); + + harness.rerender("project-memory-b"); + await flushEffects(); + + expect(mockGetProjectMemory).toHaveBeenLastCalledWith("project-memory-b"); + expect(harness.getValue()?.characters[0]?.name).toBe("配角"); + } finally { + harness.unmount(); + } + }); +}); diff --git a/src/components/agent/chat/hooks/useHomeShellProjectMemory.ts b/src/components/agent/chat/hooks/useHomeShellProjectMemory.ts new file mode 100644 index 000000000..59aca384b --- /dev/null +++ b/src/components/agent/chat/hooks/useHomeShellProjectMemory.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { getProjectMemory, type ProjectMemory } from "@/lib/api/memory"; +import { logAgentDebug } from "@/lib/agentDebug"; +import { normalizeProjectId } from "../utils/topicProjectResolution"; + +export function useHomeShellProjectMemory(projectId?: string | null) { + const normalizedProjectId = normalizeProjectId(projectId); + const [projectMemory, setProjectMemory] = useState( + null, + ); + + useEffect(() => { + if (!normalizedProjectId) { + setProjectMemory(null); + return; + } + + let cancelled = false; + const startedAt = Date.now(); + logAgentDebug("useHomeShellProjectMemory", "load.start", { + projectId: normalizedProjectId, + }); + + void getProjectMemory(normalizedProjectId) + .then((memory) => { + if (cancelled) { + return; + } + setProjectMemory(memory); + logAgentDebug("useHomeShellProjectMemory", "load.success", { + durationMs: Date.now() - startedAt, + projectId: normalizedProjectId, + charactersCount: memory.characters.length, + }); + }) + .catch((error) => { + if (cancelled) { + return; + } + setProjectMemory(null); + logAgentDebug( + "useHomeShellProjectMemory", + "load.error", + { + durationMs: Date.now() - startedAt, + error, + projectId: normalizedProjectId, + }, + { level: "warn" }, + ); + }); + + return () => { + cancelled = true; + }; + }, [normalizedProjectId]); + + return projectMemory; +} diff --git a/src/components/agent/chat/hooks/useHomeShellSkills.test.tsx b/src/components/agent/chat/hooks/useHomeShellSkills.test.tsx new file mode 100644 index 000000000..63ca6f081 --- /dev/null +++ b/src/components/agent/chat/hooks/useHomeShellSkills.test.tsx @@ -0,0 +1,137 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useHomeShellSkills } from "./useHomeShellSkills"; + +const mockGetLocal = vi.hoisted(() => vi.fn()); +const mockGetAll = vi.hoisted(() => vi.fn()); +const mockLogAgentDebug = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api/skills", () => ({ + skillsApi: { + getLocal: mockGetLocal, + getAll: mockGetAll, + }, +})); + +vi.mock("@/lib/agentDebug", () => ({ + logAgentDebug: mockLogAgentDebug, +})); + +interface HookHarness { + getValue: () => ReturnType; + unmount: () => void; +} + +function mountHook(): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let hookValue: ReturnType | null = null; + + function TestComponent() { + hookValue = useHomeShellSkills(); + return null; + } + + act(() => { + root.render(); + }); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + }); +} + +describe("useHomeShellSkills", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.clearAllMocks(); + vi.useFakeTimers(); + mockGetLocal.mockResolvedValue([ + { + key: "local-skill", + name: "本地技能", + description: "默认加载", + directory: "local-skill", + installed: true, + sourceKind: "builtin", + }, + ]); + mockGetAll.mockResolvedValue([ + { + key: "remote-skill", + name: "远端技能", + description: "刷新加载", + directory: "remote-skill", + installed: true, + sourceKind: "builtin", + }, + ]); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("应在 idle/fallback 阶段加载本地 skills", async () => { + const harness = mountHook(); + + try { + act(() => { + vi.advanceTimersByTime(200); + }); + await flushEffects(); + + expect(mockGetLocal).toHaveBeenCalledWith("lime"); + expect(harness.getValue().skills).toHaveLength(1); + expect(harness.getValue().skills[0]?.key).toBe("local-skill"); + expect(harness.getValue().skillsLoading).toBe(false); + } finally { + harness.unmount(); + } + }); + + it("手动刷新时应走远端聚合入口", async () => { + const harness = mountHook(); + + try { + act(() => { + vi.advanceTimersByTime(200); + }); + await flushEffects(); + + await act(async () => { + await harness.getValue().refreshSkills(true); + }); + + expect(mockGetAll).toHaveBeenCalledWith("lime"); + expect(harness.getValue().skills[0]?.key).toBe("remote-skill"); + } finally { + harness.unmount(); + } + }); +}); diff --git a/src/components/agent/chat/hooks/useHomeShellSkills.ts b/src/components/agent/chat/hooks/useHomeShellSkills.ts new file mode 100644 index 000000000..49ca60692 --- /dev/null +++ b/src/components/agent/chat/hooks/useHomeShellSkills.ts @@ -0,0 +1,8 @@ +import { useLimeSkills } from "./useLimeSkills"; + +export function useHomeShellSkills() { + return useLimeSkills({ + autoLoad: "deferred", + logScope: "useHomeShellSkills", + }); +} diff --git a/src/components/agent/chat/hooks/useLimeSkills.test.tsx b/src/components/agent/chat/hooks/useLimeSkills.test.tsx new file mode 100644 index 000000000..407e25dc4 --- /dev/null +++ b/src/components/agent/chat/hooks/useLimeSkills.test.tsx @@ -0,0 +1,143 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useLimeSkills } from "./useLimeSkills"; + +const mockGetLocal = vi.hoisted(() => vi.fn()); +const mockGetAll = vi.hoisted(() => vi.fn()); +const mockLogAgentDebug = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api/skills", () => ({ + skillsApi: { + getLocal: mockGetLocal, + getAll: mockGetAll, + }, +})); + +vi.mock("@/lib/agentDebug", () => ({ + logAgentDebug: mockLogAgentDebug, +})); + +interface HookHarness { + getValue: () => ReturnType; + unmount: () => void; +} + +function mountHook( + options: Parameters[0] = { + autoLoad: "deferred", + }, +): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let hookValue: ReturnType | null = null; + + function TestComponent() { + hookValue = useLimeSkills(options); + return null; + } + + act(() => { + root.render(); + }); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + }); +} + +describe("useLimeSkills", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.clearAllMocks(); + vi.useFakeTimers(); + mockGetLocal.mockResolvedValue([ + { + key: "local-skill", + name: "本地技能", + description: "默认加载", + directory: "local-skill", + installed: true, + sourceKind: "builtin", + }, + ]); + mockGetAll.mockResolvedValue([ + { + key: "remote-skill", + name: "远端技能", + description: "刷新加载", + directory: "remote-skill", + installed: true, + sourceKind: "builtin", + }, + ]); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("deferred 模式应在 idle/fallback 阶段加载本地 skills", async () => { + const harness = mountHook({ autoLoad: "deferred", logScope: "TestScope" }); + + try { + act(() => { + vi.advanceTimersByTime(200); + }); + await flushEffects(); + + expect(mockGetLocal).toHaveBeenCalledWith("lime"); + expect(harness.getValue().skills[0]?.key).toBe("local-skill"); + expect(mockLogAgentDebug).toHaveBeenCalledWith( + "TestScope", + "loadSkills.start", + { includeRemote: false }, + ); + } finally { + harness.unmount(); + } + }); + + it("immediate 模式应立即加载,并支持手动刷新远端", async () => { + const harness = mountHook({ autoLoad: "immediate" }); + + try { + await flushEffects(); + + expect(mockGetLocal).toHaveBeenCalledWith("lime"); + + await act(async () => { + await harness.getValue().refreshSkills(true); + }); + + expect(mockGetAll).toHaveBeenCalledWith("lime"); + expect(harness.getValue().skills[0]?.key).toBe("remote-skill"); + } finally { + harness.unmount(); + } + }); +}); diff --git a/src/components/agent/chat/hooks/useLimeSkills.ts b/src/components/agent/chat/hooks/useLimeSkills.ts new file mode 100644 index 000000000..df9f039a3 --- /dev/null +++ b/src/components/agent/chat/hooks/useLimeSkills.ts @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { logAgentDebug } from "@/lib/agentDebug"; +import { skillsApi, type Skill } from "@/lib/api/skills"; + +const SKILLS_IDLE_TIMEOUT_MS = 1_500; +const SKILLS_FALLBACK_DELAY_MS = 180; + +function scheduleDeferredSkillsLoad(task: () => void): () => void { + if (typeof window === "undefined") { + task(); + return () => undefined; + } + + if (typeof window.requestIdleCallback === "function") { + const idleId = window.requestIdleCallback(() => task(), { + timeout: SKILLS_IDLE_TIMEOUT_MS, + }); + return () => { + if (typeof window.cancelIdleCallback === "function") { + window.cancelIdleCallback(idleId); + } + }; + } + + const timeoutId = window.setTimeout(task, SKILLS_FALLBACK_DELAY_MS); + return () => { + window.clearTimeout(timeoutId); + }; +} + +interface UseLimeSkillsOptions { + autoLoad?: "immediate" | "deferred" | false; + logScope?: string; + onError?: (error: unknown) => void; +} + +export function useLimeSkills(options: UseLimeSkillsOptions = {}) { + const { + autoLoad = "immediate", + logScope = "useLimeSkills", + onError, + } = options; + const [skills, setSkills] = useState([]); + const [skillsLoading, setSkillsLoading] = useState(false); + const latestRequestIdRef = useRef(0); + + const refreshSkills = useCallback( + async (includeRemote = false): Promise => { + const requestId = latestRequestIdRef.current + 1; + latestRequestIdRef.current = requestId; + + const startedAt = Date.now(); + logAgentDebug(logScope, "loadSkills.start", { + includeRemote, + }); + setSkillsLoading(true); + + try { + const loadedSkills = includeRemote + ? await skillsApi.getAll("lime") + : await skillsApi.getLocal("lime"); + + if (latestRequestIdRef.current !== requestId) { + return loadedSkills; + } + + setSkills(loadedSkills); + logAgentDebug(logScope, "loadSkills.success", { + durationMs: Date.now() - startedAt, + includeRemote, + skillsCount: loadedSkills.length, + }); + return loadedSkills; + } catch (error) { + if (latestRequestIdRef.current !== requestId) { + return []; + } + + setSkills([]); + onError?.(error); + logAgentDebug( + logScope, + "loadSkills.error", + { + durationMs: Date.now() - startedAt, + error, + includeRemote, + }, + { level: "warn" }, + ); + return []; + } finally { + if (latestRequestIdRef.current === requestId) { + setSkillsLoading(false); + } + } + }, + [logScope, onError], + ); + + useEffect(() => { + if (autoLoad === false) { + return; + } + + if (autoLoad === "deferred") { + return scheduleDeferredSkillsLoad(() => { + void refreshSkills(false); + }); + } + + void refreshSkills(false); + return; + }, [autoLoad, refreshSkills]); + + useEffect(() => { + return () => { + latestRequestIdRef.current += 1; + }; + }, []); + + return { + skills, + skillsLoading, + refreshSkills, + }; +} diff --git a/src/components/agent/chat/hooks/useRuntimeTeamFormation.test.tsx b/src/components/agent/chat/hooks/useRuntimeTeamFormation.test.tsx new file mode 100644 index 000000000..a12f1c0ec --- /dev/null +++ b/src/components/agent/chat/hooks/useRuntimeTeamFormation.test.tsx @@ -0,0 +1,252 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TeamDefinition } from "../utils/teamDefinitions"; +import { + shouldGenerateRuntimeTeamAfterSend, + useRuntimeTeamFormation, +} from "./useRuntimeTeamFormation"; + +type HookProps = Parameters[0]; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +function createSelectedTeam(): TeamDefinition { + return { + id: "team-1", + source: "builtin", + label: "研究协作组", + description: "负责拆解调研任务", + roles: [ + { + id: "role-1", + label: "研究员", + summary: "收集资料", + }, + ], + }; +} + +function renderHook(props?: Partial) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const defaultProps: HookProps = { + activeTheme: "general", + projectId: "project-1", + sessionId: "session-1", + selectedTeam: createSelectedTeam(), + subagentEnabled: true, + hasRealTeamGraph: false, + generateRuntimeTeam: vi.fn(async () => ({ + id: "ephemeral-1", + source: "ephemeral", + label: "临时 Team", + description: "自动生成", + roles: [ + { + id: "member-1", + label: "执行者", + summary: "执行当前任务", + }, + ], + })), + createRequestId: () => "request-1", + now: () => 1_710_000_000_000, + }; + + let latestValue: ReturnType | null = null; + + function Probe(currentProps: HookProps) { + latestValue = useRuntimeTeamFormation(currentProps); + return null; + } + + const render = async (nextProps?: Partial) => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + }; + + mountedRoots.push({ root, container }); + + return { + render, + getValue: () => { + if (!latestValue) { + throw new Error("hook 尚未初始化"); + } + return latestValue; + }, + defaultProps: { + ...defaultProps, + ...props, + }, + }; +} + +describe("useRuntimeTeamFormation", () => { + 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(); + }); + + it("发送后满足条件时应进入 forming 并在生成完成后切到 formed", async () => { + const generateRuntimeTeam = vi.fn(async () => ({ + id: "ephemeral-1", + source: "ephemeral" as const, + label: "自动 Team", + description: "自动生成", + roles: [ + { + id: "role-1", + label: "执行者", + summary: "负责执行", + }, + ], + })); + const { render, getValue } = renderHook({ + generateRuntimeTeam, + }); + + await render(); + + act(() => { + getValue().handleRuntimeTeamAfterSend({ + input: "请拆成两个子任务", + providerType: "openai", + model: "gpt-4.1", + executionStrategy: "react", + }); + }); + + expect(getValue().runtimeTeamState?.status).toBe("forming"); + expect(generateRuntimeTeam).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: "project-1", + providerType: "openai", + model: "gpt-4.1", + input: "请拆成两个子任务", + activeTheme: "general", + }), + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(getValue().runtimeTeamState?.status).toBe("formed"); + expect(getValue().runtimeTeamState?.label).toBe("自动 Team"); + }); + + it("subagent 关闭且没有真实 team 图时,应清空 runtime team 状态", async () => { + const { render, getValue } = renderHook(); + await render(); + + act(() => { + getValue().triggerRuntimeTeamFormation({ + input: "请先规划 team", + providerType: "openai", + model: "gpt-4.1", + executionStrategy: "react", + }); + }); + + expect(getValue().runtimeTeamState?.status).toBe("forming"); + + await render({ + subagentEnabled: false, + hasRealTeamGraph: false, + }); + + expect(getValue().runtimeTeamState).toBeNull(); + }); + + it("session 切换时应重置 runtime team 状态", async () => { + const { render, getValue } = renderHook(); + await render(); + + act(() => { + getValue().triggerRuntimeTeamFormation({ + input: "请先规划 team", + providerType: "openai", + model: "gpt-4.1", + executionStrategy: "react", + }); + }); + + expect(getValue().runtimeTeamState?.status).toBe("forming"); + + await render({ + sessionId: "session-2", + }); + + expect(getValue().runtimeTeamState).toBeNull(); + }); +}); + +describe("shouldGenerateRuntimeTeamAfterSend", () => { + it("仅在 subagent 开启、存在项目、非 purpose 且输入非空时返回 true", () => { + expect( + shouldGenerateRuntimeTeamAfterSend({ + subagentEnabled: true, + projectId: "project-1", + input: "请拆解任务", + }), + ).toBe(true); + + expect( + shouldGenerateRuntimeTeamAfterSend({ + subagentEnabled: false, + projectId: "project-1", + input: "请拆解任务", + }), + ).toBe(false); + + expect( + shouldGenerateRuntimeTeamAfterSend({ + subagentEnabled: true, + projectId: "", + input: "请拆解任务", + }), + ).toBe(false); + + expect( + shouldGenerateRuntimeTeamAfterSend({ + subagentEnabled: true, + projectId: "project-1", + input: " ", + }), + ).toBe(false); + + expect( + shouldGenerateRuntimeTeamAfterSend({ + subagentEnabled: true, + projectId: "project-1", + input: "请拆解任务", + purpose: "content_review", + }), + ).toBe(false); + }); +}); diff --git a/src/components/agent/chat/hooks/useRuntimeTeamFormation.ts b/src/components/agent/chat/hooks/useRuntimeTeamFormation.ts new file mode 100644 index 000000000..00e3f56c4 --- /dev/null +++ b/src/components/agent/chat/hooks/useRuntimeTeamFormation.ts @@ -0,0 +1,210 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { HandleSendOptions } from "./handleSendTypes"; +import { + createRuntimeFormationStateFromTeam, + type TeamWorkspaceRuntimeFormationState, +} from "../teamWorkspaceRuntime"; +import { generateEphemeralTeamWithModel } from "../utils/teamAutoGeneration"; +import type { TeamDefinition } from "../utils/teamDefinitions"; + +interface TriggerRuntimeTeamFormationParams { + input: string; + providerType: string; + model: string; + executionStrategy?: "react" | "code_orchestrated" | "auto"; +} + +interface HandleRuntimeTeamAfterSendParams + extends TriggerRuntimeTeamFormationParams { + purpose?: HandleSendOptions["purpose"]; +} + +interface UseRuntimeTeamFormationOptions { + activeTheme: string; + projectId?: string | null; + sessionId?: string | null; + selectedTeam?: TeamDefinition | null; + subagentEnabled: boolean; + hasRealTeamGraph: boolean; + generateRuntimeTeam?: typeof generateEphemeralTeamWithModel; + createRequestId?: () => string; + now?: () => number; +} + +export interface UseRuntimeTeamFormationResult { + runtimeTeamState: TeamWorkspaceRuntimeFormationState | null; + clearRuntimeTeamState: () => void; + triggerRuntimeTeamFormation: ( + params: TriggerRuntimeTeamFormationParams, + ) => void; + handleRuntimeTeamAfterSend: (params: HandleRuntimeTeamAfterSendParams) => void; +} + +function defaultCreateRequestId() { + return crypto.randomUUID(); +} + +export function shouldGenerateRuntimeTeamAfterSend(params: { + subagentEnabled: boolean; + projectId?: string | null; + input: string; + purpose?: HandleSendOptions["purpose"]; +}): boolean { + return ( + params.subagentEnabled && + !params.purpose && + Boolean(params.projectId) && + params.input.trim().length > 0 + ); +} + +export function useRuntimeTeamFormation({ + activeTheme, + projectId, + sessionId, + selectedTeam, + subagentEnabled, + hasRealTeamGraph, + generateRuntimeTeam = generateEphemeralTeamWithModel, + createRequestId = defaultCreateRequestId, + now = () => Date.now(), +}: UseRuntimeTeamFormationOptions): UseRuntimeTeamFormationResult { + const [runtimeTeamState, setRuntimeTeamState] = + useState(null); + const runtimeTeamRequestIdRef = useRef(null); + + const clearRuntimeTeamState = useCallback(() => { + runtimeTeamRequestIdRef.current = null; + setRuntimeTeamState(null); + }, []); + + const triggerRuntimeTeamFormation = useCallback( + ({ + input, + providerType, + model, + executionStrategy, + }: TriggerRuntimeTeamFormationParams) => { + const normalizedInput = input.trim(); + if (!projectId || !normalizedInput) { + return; + } + + const requestId = createRequestId(); + runtimeTeamRequestIdRef.current = requestId; + setRuntimeTeamState( + createRuntimeFormationStateFromTeam({ + requestId, + status: "forming", + blueprintTeam: selectedTeam ?? null, + updatedAt: now(), + }), + ); + + void generateRuntimeTeam({ + workspaceId: projectId, + providerType, + model, + executionStrategy, + activeTheme, + input: normalizedInput, + blueprintTeam: selectedTeam ?? null, + }) + .then((runtimeTeam) => { + if (runtimeTeamRequestIdRef.current !== requestId) { + return; + } + + setRuntimeTeamState( + createRuntimeFormationStateFromTeam({ + requestId, + status: "formed", + runtimeTeam, + blueprintTeam: selectedTeam ?? null, + updatedAt: now(), + }), + ); + }) + .catch((error) => { + if (runtimeTeamRequestIdRef.current !== requestId) { + return; + } + + const errorMessage = + error instanceof Error ? error.message : "Team 生成失败"; + setRuntimeTeamState( + createRuntimeFormationStateFromTeam({ + requestId, + status: "failed", + blueprintTeam: selectedTeam ?? null, + errorMessage, + updatedAt: now(), + }), + ); + }); + }, + [ + activeTheme, + createRequestId, + generateRuntimeTeam, + now, + projectId, + selectedTeam, + ], + ); + + const handleRuntimeTeamAfterSend = useCallback( + ({ + input, + providerType, + model, + executionStrategy, + purpose, + }: HandleRuntimeTeamAfterSendParams) => { + if ( + shouldGenerateRuntimeTeamAfterSend({ + subagentEnabled, + projectId, + input, + purpose, + }) + ) { + triggerRuntimeTeamFormation({ + input, + providerType, + model, + executionStrategy, + }); + return; + } + + if (!subagentEnabled && !hasRealTeamGraph) { + clearRuntimeTeamState(); + } + }, + [ + clearRuntimeTeamState, + hasRealTeamGraph, + projectId, + subagentEnabled, + triggerRuntimeTeamFormation, + ], + ); + + useEffect(() => { + clearRuntimeTeamState(); + }, [clearRuntimeTeamState, sessionId]); + + useEffect(() => { + if (!subagentEnabled && !hasRealTeamGraph) { + clearRuntimeTeamState(); + } + }, [clearRuntimeTeamState, hasRealTeamGraph, subagentEnabled]); + + return { + runtimeTeamState, + clearRuntimeTeamState, + triggerRuntimeTeamFormation, + handleRuntimeTeamAfterSend, + }; +} diff --git a/src/components/agent/chat/hooks/useSelectedTeamPreference.test.tsx b/src/components/agent/chat/hooks/useSelectedTeamPreference.test.tsx index 770381c79..a013ccd1a 100644 --- a/src/components/agent/chat/hooks/useSelectedTeamPreference.test.tsx +++ b/src/components/agent/chat/hooks/useSelectedTeamPreference.test.tsx @@ -1,6 +1,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { WorkspaceSettings } from "@/types/workspace"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TeamDefinition } from "../utils/teamDefinitions"; import { createTeamDefinitionFromPreset } from "../utils/teamDefinitions"; import { useSelectedTeamPreference } from "./useSelectedTeamPreference"; @@ -9,31 +10,42 @@ import { persistSelectedTeam, } from "../utils/teamStorage"; +type HookOptions = Parameters[1]; + interface HookHarness { getValue: () => ReturnType; - rerender: (theme?: string | null) => void; + rerender: (theme?: string | null, options?: HookOptions) => void; unmount: () => void; } -function mountHook(initialTheme?: string | null): HookHarness { +function mountHook( + initialTheme?: string | null, + initialOptions?: HookOptions, +): HookHarness { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); let hookValue: ReturnType | null = null; - function TestComponent({ theme }: { theme?: string | null }) { - hookValue = useSelectedTeamPreference(theme); + function TestComponent({ + theme, + options, + }: { + theme?: string | null; + options?: HookOptions; + }) { + hookValue = useSelectedTeamPreference(theme, options); return null; } - const render = (theme?: string | null) => { + const render = (theme?: string | null, options?: HookOptions) => { act(() => { - root.render(); + root.render(); }); }; - render(initialTheme); + render(initialTheme, initialOptions); return { getValue: () => { @@ -116,4 +128,170 @@ describe("useSelectedTeamPreference", () => { harness.unmount(); } }); + + it("项目级 Team 偏好应优先于 localStorage,并通过回调持久化", async () => { + const engineeringTeam = createTeamDefinitionFromPreset( + "code-triage-team", + ) as TeamDefinition; + const researchTeam = createTeamDefinitionFromPreset( + "research-team", + ) as TeamDefinition; + const projectSettings: WorkspaceSettings = { + agentTeam: { + selectedTeam: { + id: engineeringTeam.id, + source: engineeringTeam.source, + }, + }, + }; + const persistSpy = vi.fn().mockResolvedValue(undefined); + + persistSelectedTeam(researchTeam, "general"); + + const harness = mountHook("general", { + projectSettings, + onPersistSelectedTeam: persistSpy, + }); + + try { + await flushEffects(); + expect(harness.getValue().selectedTeam?.id).toBe("code-triage-team"); + + act(() => { + harness.getValue().setSelectedTeam(null); + }); + await flushEffects(); + + expect(persistSpy).toHaveBeenCalledWith(null); + expect(loadSelectedTeamReference("general")).toEqual({ + id: "research-team", + source: "builtin", + }); + } finally { + harness.unmount(); + } + }); + + it("项目级自定义 Team 应能直接解析为当前选择", async () => { + const harness = mountHook("general", { + projectSettings: { + agentTeam: { + selectedTeam: { + id: "custom-team-project-1", + source: "custom", + }, + customTeams: [ + { + id: "custom-team-project-1", + label: "前端联调团队", + description: "分析、实现、验证三段式推进。", + roles: [ + { + id: "planner", + label: "分析", + summary: "负责拆解问题与确认边界。", + profileId: "code-explorer", + roleKey: "explorer", + skillIds: ["source-grounding"], + }, + ], + }, + ], + }, + }, + }); + + try { + await flushEffects(); + expect(harness.getValue().selectedTeam?.id).toBe("custom-team-project-1"); + expect(harness.getValue().selectedTeam?.label).toBe("前端联调团队"); + expect(harness.getValue().selectedTeam?.source).toBe("custom"); + expect(harness.getValue().selectedTeam?.roles[0]?.profileId).toBe( + "code-explorer", + ); + } finally { + harness.unmount(); + } + }); + + it("项目级持久化失败时应回滚到当前项目 Team", async () => { + const engineeringTeam = createTeamDefinitionFromPreset( + "code-triage-team", + ) as TeamDefinition; + const researchTeam = createTeamDefinitionFromPreset( + "research-team", + ) as TeamDefinition; + const persistSpy = vi.fn().mockRejectedValue(new Error("save failed")); + + const harness = mountHook("general", { + projectSettings: { + agentTeam: { + selectedTeam: { + id: engineeringTeam.id, + source: engineeringTeam.source, + }, + }, + }, + onPersistSelectedTeam: persistSpy, + }); + + try { + await flushEffects(); + expect(harness.getValue().selectedTeam?.id).toBe("code-triage-team"); + + act(() => { + harness.getValue().setSelectedTeam(researchTeam); + }); + await flushEffects(); + + expect(persistSpy).toHaveBeenCalledWith(researchTeam); + expect(harness.getValue().selectedTeam?.id).toBe("code-triage-team"); + } finally { + harness.unmount(); + } + }); + + it("ephemeral Team 不应写入项目级或本地持久化", async () => { + const persistSpy = vi.fn().mockResolvedValue(undefined); + const ephemeralTeam: TeamDefinition = { + id: "ephemeral-team-generated", + source: "ephemeral", + label: "本轮 Team", + description: "仅本轮会话使用", + roles: [ + { + id: "planner", + label: "分析", + summary: "负责拆解任务。", + }, + ], + }; + const harness = mountHook("general", { + projectSettings: { + agentTeam: { + selectedTeam: { + id: "code-triage-team", + source: "builtin", + }, + }, + }, + onPersistSelectedTeam: persistSpy, + }); + + try { + await flushEffects(); + act(() => { + harness.getValue().setSelectedTeam(ephemeralTeam); + }); + await flushEffects(); + + expect(harness.getValue().selectedTeam?.id).toBe( + "ephemeral-team-generated", + ); + expect(persistSpy).not.toHaveBeenCalled(); + expect(loadSelectedTeamReference("general")).toBeNull(); + } finally { + harness.unmount(); + } + }); }); diff --git a/src/components/agent/chat/hooks/useSelectedTeamPreference.ts b/src/components/agent/chat/hooks/useSelectedTeamPreference.ts index 4b5b4e80d..29bff875f 100644 --- a/src/components/agent/chat/hooks/useSelectedTeamPreference.ts +++ b/src/components/agent/chat/hooks/useSelectedTeamPreference.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import type { WorkspaceSettings } from "@/types/workspace"; import type { TeamDefinition } from "../utils/teamDefinitions"; import { buildTeamDefinitionLabel, @@ -7,24 +8,55 @@ import { } from "../utils/teamDefinitions"; import { persistSelectedTeam, - resolvePersistedSelectedTeam, + resolveSelectedTeamPreference, } from "../utils/teamStorage"; -export function useSelectedTeamPreference(theme?: string | null) { +interface UseSelectedTeamPreferenceOptions { + projectSettings?: WorkspaceSettings | null; + onPersistSelectedTeam?: (team: TeamDefinition | null) => void | Promise; +} + +export function useSelectedTeamPreference( + theme?: string | null, + options: UseSelectedTeamPreferenceOptions = {}, +) { + const { projectSettings, onPersistSelectedTeam } = options; + const resolveCurrentSelection = useCallback( + () => + resolveSelectedTeamPreference({ + theme, + workspaceSettings: projectSettings, + }), + [projectSettings, theme], + ); const [selectedTeam, setSelectedTeamState] = useState( - () => resolvePersistedSelectedTeam(theme), + () => resolveCurrentSelection(), ); useEffect(() => { - setSelectedTeamState(resolvePersistedSelectedTeam(theme)); - }, [theme]); + setSelectedTeamState(resolveCurrentSelection()); + }, [resolveCurrentSelection]); const setSelectedTeam = useCallback( (team: TeamDefinition | null) => { - persistSelectedTeam(team, theme); setSelectedTeamState(team); + + if (onPersistSelectedTeam && team?.source !== "ephemeral") { + const fallbackTeam = resolveCurrentSelection(); + void Promise.resolve(onPersistSelectedTeam(team)).catch((error) => { + console.warn("[Team] 持久化项目级 Team 偏好失败:", error); + setSelectedTeamState(fallbackTeam); + }); + return; + } + + if (onPersistSelectedTeam && team?.source === "ephemeral") { + return; + } + + persistSelectedTeam(team, theme); }, - [theme], + [onPersistSelectedTeam, resolveCurrentSelection, theme], ); const enableSuggestedTeam = useCallback( diff --git a/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.test.tsx b/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.test.tsx index 6ae841ec1..264c9d530 100644 --- a/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.test.tsx +++ b/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.test.tsx @@ -244,6 +244,148 @@ describe("useTeamWorkspaceRuntime", () => { ).toBe(1); }); + it("收到 turn_completed 后,应立即把 live runtime 从 running 回落到 completed", async () => { + const listeners = new Map< + string, + (event: { payload: unknown }) => void + >(); + mockSafeListen.mockImplementation( + async (eventName: string, handler: (event: { payload: unknown }) => void) => { + listeners.set(eventName, handler); + return () => { + listeners.delete(eventName); + }; + }, + ); + + await renderHookProbe({ + childSubagentSessions: [ + { + id: "child-1", + name: "研究员", + created_at: 1_710_000_000, + updated_at: 1_710_000_100, + session_type: "sub_agent", + runtime_status: "running", + latest_turn_status: "running", + task_summary: "整理竞品与数据来源", + role_hint: "explorer", + }, + ], + }); + + await act(async () => { + listeners.get("agent_subagent_stream:child-1")?.({ + payload: { + type: "turn_completed", + turn: { + id: "turn-1", + }, + }, + }); + await Promise.resolve(); + }); + + expect(latestValue?.liveRuntimeBySessionId["child-1"]?.runtimeStatus).toBe( + "completed", + ); + expect( + latestValue?.liveRuntimeBySessionId["child-1"]?.latestTurnStatus, + ).toBe("completed"); + }); + + it("收到 final_done 且没有后续状态事件时,也应结束 running 状态", async () => { + const listeners = new Map< + string, + (event: { payload: unknown }) => void + >(); + mockSafeListen.mockImplementation( + async (eventName: string, handler: (event: { payload: unknown }) => void) => { + listeners.set(eventName, handler); + return () => { + listeners.delete(eventName); + }; + }, + ); + + await renderHookProbe({ + childSubagentSessions: [ + { + id: "child-1", + name: "研究员", + created_at: 1_710_000_000, + updated_at: 1_710_000_100, + session_type: "sub_agent", + runtime_status: "running", + latest_turn_status: "running", + task_summary: "整理竞品与数据来源", + role_hint: "explorer", + }, + ], + }); + + await act(async () => { + listeners.get("agent_subagent_stream:child-1")?.({ + payload: { + type: "final_done", + }, + }); + await Promise.resolve(); + }); + + expect(latestValue?.liveRuntimeBySessionId["child-1"]?.runtimeStatus).toBe( + "completed", + ); + }); + + it("收到 error 后,应立即把 live runtime 从 running 回落到 failed", async () => { + const listeners = new Map< + string, + (event: { payload: unknown }) => void + >(); + mockSafeListen.mockImplementation( + async (eventName: string, handler: (event: { payload: unknown }) => void) => { + listeners.set(eventName, handler); + return () => { + listeners.delete(eventName); + }; + }, + ); + + await renderHookProbe({ + childSubagentSessions: [ + { + id: "child-1", + name: "研究员", + created_at: 1_710_000_000, + updated_at: 1_710_000_100, + session_type: "sub_agent", + runtime_status: "running", + latest_turn_status: "running", + task_summary: "整理竞品与数据来源", + role_hint: "explorer", + }, + ], + }); + + await act(async () => { + listeners.get("agent_subagent_stream:child-1")?.({ + payload: { + type: "error", + message: "工具调用失败", + }, + }); + await Promise.resolve(); + }); + + expect(latestValue?.liveRuntimeBySessionId["child-1"]?.runtimeStatus).toBe( + "failed", + ); + expect( + latestValue?.liveRuntimeBySessionId["child-1"]?.latestTurnStatus, + ).toBe("failed"); + }); + it("base snapshot 追平或 session 移除后,应自动清理过期 live 状态", async () => { const listeners = new Map< string, diff --git a/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.ts b/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.ts index 398bfb41a..6bfef6c67 100644 --- a/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.ts +++ b/src/components/agent/chat/hooks/useTeamWorkspaceRuntime.ts @@ -50,6 +50,7 @@ interface TeamWorkspaceRuntimeStreamProjection { forgetToolId?: string; runtimeStatus?: TeamWorkspaceRuntimeStatus; latestTurnStatus?: TeamWorkspaceRuntimeStatus; + queuedTurnCount?: number; refreshPreview?: boolean; } @@ -272,7 +273,12 @@ function buildLiveRuntimeState( session: TeamWorkspaceRuntimeSessionSnapshot, baseFingerprint: string, current: TeamWorkspaceLiveRuntimeState | undefined, - patch: Pick, + patch: Partial< + Pick< + TeamWorkspaceLiveRuntimeState, + "runtimeStatus" | "latestTurnStatus" | "queuedTurnCount" + > + >, ): TeamWorkspaceLiveRuntimeState { return { runtimeStatus: @@ -285,17 +291,71 @@ function buildLiveRuntimeState( current?.latestTurnStatus ?? session.latestTurnStatus ?? session.runtimeStatus, + queuedTurnCount: + patch.queuedTurnCount ?? + current?.queuedTurnCount ?? + session.queuedTurnCount, baseFingerprint, }; } +function getQueuedTurnCount( + session: TeamWorkspaceRuntimeSessionSnapshot, + current?: TeamWorkspaceLiveRuntimeState, +): number { + return current?.queuedTurnCount ?? session.queuedTurnCount ?? 0; +} + +function resolveQueueDrainedRuntimeStatus( + session: TeamWorkspaceRuntimeSessionSnapshot, + current?: TeamWorkspaceLiveRuntimeState, +): TeamWorkspaceRuntimeStatus { + const candidate = [ + current?.latestTurnStatus, + session.latestTurnStatus, + current?.runtimeStatus, + session.runtimeStatus, + ].find((status) => status && status !== "queued" && status !== "running"); + + return candidate ?? "idle"; +} + +function resolveFinalRuntimeStatus(params: { + session: TeamWorkspaceRuntimeSessionSnapshot; + current?: TeamWorkspaceLiveRuntimeState; + terminalStatus?: Extract; + queuedTurnCount?: number; +}): TeamWorkspaceRuntimeStatus { + const queuedTurnCount = + params.queuedTurnCount ?? getQueuedTurnCount(params.session, params.current); + if (queuedTurnCount > 0) { + return "queued"; + } + + if (params.terminalStatus) { + return params.terminalStatus; + } + + const candidate = [ + params.current?.latestTurnStatus, + params.session.latestTurnStatus, + params.current?.runtimeStatus, + params.session.runtimeStatus, + ].find((status) => status && status !== "queued" && status !== "running"); + + return candidate ?? "completed"; +} + function projectRuntimeStreamEvent(params: { sessionId: string; + session: TeamWorkspaceRuntimeSessionSnapshot; event: StreamEvent; + currentRuntime?: TeamWorkspaceLiveRuntimeState; streamState?: SessionLiveStreamState; toolNameById?: Record; }): TeamWorkspaceRuntimeStreamProjection | null { - const { sessionId, event, streamState, toolNameById } = params; + const { sessionId, session, event, currentRuntime, streamState, toolNameById } = + params; switch (event.type) { case "item_started": @@ -385,6 +445,7 @@ function projectRuntimeStreamEvent(params: { }), runtimeStatus: "queued", latestTurnStatus: "queued", + queuedTurnCount: getQueuedTurnCount(session, currentRuntime) + 1, }; case "queue_started": return { @@ -398,10 +459,29 @@ function projectRuntimeStreamEvent(params: { }), runtimeStatus: "running", latestTurnStatus: "running", + queuedTurnCount: Math.max(getQueuedTurnCount(session, currentRuntime) - 1, 0), }; - case "queue_removed": + case "queue_removed": { + const queuedTurnCount = Math.max( + getQueuedTurnCount(session, currentRuntime) - 1, + 0, + ); + return { + queuedTurnCount, + runtimeStatus: + queuedTurnCount === 0 && currentRuntime?.runtimeStatus === "queued" + ? resolveQueueDrainedRuntimeStatus(session, currentRuntime) + : undefined, + refreshPreview: true, + }; + } case "queue_cleared": return { + queuedTurnCount: 0, + runtimeStatus: + currentRuntime?.runtimeStatus === "queued" + ? resolveQueueDrainedRuntimeStatus(session, currentRuntime) + : undefined, refreshPreview: true, }; case "turn_started": @@ -418,26 +498,37 @@ function projectRuntimeStreamEvent(params: { latestTurnStatus: "running", }; case "turn_completed": - return { - entry: buildLifecycleActivityEntry({ - sessionId, - key: "turn", - title: "回合完成", - detail: "当前回合已完成,正在等待快照同步。", - statusLabel: "完成", - badgeClassName: COMPLETED_BADGE_CLASS_NAME, - }), - latestTurnStatus: "completed", - clearEntryIds: [ - `stream-text:${sessionId}`, - `stream-thinking:${sessionId}`, - `runtime-status:${sessionId}`, - ], - clearTextDraft: true, - clearThinkingDraft: true, - refreshPreview: true, - }; - case "turn_failed": + { + const queuedTurnCount = getQueuedTurnCount(session, currentRuntime); + return { + entry: buildLifecycleActivityEntry({ + sessionId, + key: "turn", + title: "回合完成", + detail: "当前回合已完成,正在等待快照同步。", + statusLabel: "完成", + badgeClassName: COMPLETED_BADGE_CLASS_NAME, + }), + runtimeStatus: resolveFinalRuntimeStatus({ + session, + current: currentRuntime, + terminalStatus: "completed", + queuedTurnCount, + }), + latestTurnStatus: "completed", + queuedTurnCount, + clearEntryIds: [ + `stream-text:${sessionId}`, + `stream-thinking:${sessionId}`, + `runtime-status:${sessionId}`, + ], + clearTextDraft: true, + clearThinkingDraft: true, + refreshPreview: true, + }; + } + case "turn_failed": { + const queuedTurnCount = getQueuedTurnCount(session, currentRuntime); return { entry: buildLifecycleActivityEntry({ sessionId, @@ -448,7 +539,14 @@ function projectRuntimeStreamEvent(params: { statusLabel: "失败", badgeClassName: FAILED_BADGE_CLASS_NAME, }), + runtimeStatus: resolveFinalRuntimeStatus({ + session, + current: currentRuntime, + terminalStatus: "failed", + queuedTurnCount, + }), latestTurnStatus: "failed", + queuedTurnCount, clearEntryIds: [ `stream-text:${sessionId}`, `stream-thinking:${sessionId}`, @@ -458,15 +556,24 @@ function projectRuntimeStreamEvent(params: { clearThinkingDraft: true, refreshPreview: true, }; + } case "warning": return { entry: buildWarningActivityEntry(sessionId, event.message, event.code), refreshPreview: true, }; - case "error": + case "error": { + const queuedTurnCount = getQueuedTurnCount(session, currentRuntime); return { entry: buildErrorActivityEntry(sessionId, event.message), + runtimeStatus: resolveFinalRuntimeStatus({ + session, + current: currentRuntime, + terminalStatus: "failed", + queuedTurnCount, + }), latestTurnStatus: "failed", + queuedTurnCount, clearEntryIds: [ `stream-text:${sessionId}`, `stream-thinking:${sessionId}`, @@ -476,10 +583,18 @@ function projectRuntimeStreamEvent(params: { clearThinkingDraft: true, refreshPreview: true, }; + } case "done": return null; - case "final_done": + case "final_done": { + const queuedTurnCount = getQueuedTurnCount(session, currentRuntime); return { + runtimeStatus: resolveFinalRuntimeStatus({ + session, + current: currentRuntime, + queuedTurnCount, + }), + queuedTurnCount, clearEntryIds: [ `stream-text:${sessionId}`, `stream-thinking:${sessionId}`, @@ -489,6 +604,7 @@ function projectRuntimeStreamEvent(params: { clearThinkingDraft: true, refreshPreview: true, }; + } default: return null; } @@ -551,6 +667,9 @@ export function useTeamWorkspaceRuntime( const [liveRuntimeBySessionId, setLiveRuntimeBySessionId] = useState< Record >({}); + const liveRuntimeBySessionIdRef = useRef< + Record + >({}); const [liveActivityBySessionId, setLiveActivityBySessionId] = useState< Record >({}); @@ -614,6 +733,10 @@ export function useTeamWorkspaceRuntime( baseFingerprintByIdRef.current = baseFingerprintById; }, [activeSnapshotById, baseFingerprintById]); + useEffect(() => { + liveRuntimeBySessionIdRef.current = liveRuntimeBySessionId; + }, [liveRuntimeBySessionId]); + const scheduleActivityRefresh = useCallback((sessionId: string) => { if (refreshTimersRef.current[sessionId] !== undefined) { return; @@ -742,9 +865,18 @@ export function useTeamWorkspaceRuntime( setLiveRuntimeBySessionId((previous) => { const current = previous[data.session_id]; + const nextQueuedTurnCount = + normalizedStatus === "completed" || + normalizedStatus === "failed" || + normalizedStatus === "aborted" || + normalizedStatus === "closed" || + normalizedStatus === "idle" + ? 0 + : current?.queuedTurnCount ?? matchingSession.queuedTurnCount; if ( current?.runtimeStatus === normalizedStatus && current.latestTurnStatus === normalizedStatus && + current.queuedTurnCount === nextQueuedTurnCount && current.baseFingerprint === baseFingerprint ) { return previous; @@ -752,11 +884,16 @@ export function useTeamWorkspaceRuntime( return { ...previous, - [data.session_id]: { - runtimeStatus: normalizedStatus, - latestTurnStatus: normalizedStatus, + [data.session_id]: buildLiveRuntimeState( + matchingSession, baseFingerprint, - }, + current, + { + runtimeStatus: normalizedStatus, + latestTurnStatus: normalizedStatus, + queuedTurnCount: nextQueuedTurnCount, + }, + ), }; }); @@ -830,7 +967,9 @@ export function useTeamWorkspaceRuntime( const projection = projectRuntimeStreamEvent({ sessionId, + session: matchingSession, event: data, + currentRuntime: liveRuntimeBySessionIdRef.current[sessionId], streamState: liveStreamStateBySessionIdRef.current[sessionId], toolNameById: toolNameBySessionIdRef.current[sessionId], }); @@ -891,7 +1030,8 @@ export function useTeamWorkspaceRuntime( if ( projection.runtimeStatus !== undefined || - projection.latestTurnStatus !== undefined + projection.latestTurnStatus !== undefined || + projection.queuedTurnCount !== undefined ) { setLiveRuntimeBySessionId((previous) => { const current = previous[sessionId]; @@ -902,6 +1042,7 @@ export function useTeamWorkspaceRuntime( { runtimeStatus: projection.runtimeStatus, latestTurnStatus: projection.latestTurnStatus, + queuedTurnCount: projection.queuedTurnCount, }, ); @@ -909,6 +1050,7 @@ export function useTeamWorkspaceRuntime( current && current.runtimeStatus === nextState.runtimeStatus && current.latestTurnStatus === nextState.latestTurnStatus && + current.queuedTurnCount === nextState.queuedTurnCount && current.baseFingerprint === nextState.baseFingerprint ) { return previous; diff --git a/src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.test.tsx b/src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.test.tsx new file mode 100644 index 000000000..b4f92bc63 --- /dev/null +++ b/src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.test.tsx @@ -0,0 +1,130 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { saveChatToolPreferences } from "../utils/chatToolPreferences"; +import { useThemeScopedChatToolPreferences } from "./useThemeScopedChatToolPreferences"; + +interface HookHarness { + getValue: () => ReturnType; + rerender: (activeTheme: string) => void; + unmount: () => void; +} + +function mountHook(initialTheme: string): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let hookValue: ReturnType | null = + null; + + function TestComponent({ activeTheme }: { activeTheme: string }) { + hookValue = useThemeScopedChatToolPreferences(activeTheme); + return null; + } + + const render = (activeTheme: string) => { + act(() => { + root.render(); + }); + }; + + render(initialTheme); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + rerender: render, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +describe("useThemeScopedChatToolPreferences", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("应按当前主题读取工具偏好", () => { + saveChatToolPreferences( + { webSearch: true, thinking: false, task: true, subagent: false }, + "general", + ); + + const harness = mountHook("general"); + + try { + expect(harness.getValue().chatToolPreferences).toEqual({ + webSearch: true, + thinking: false, + task: true, + subagent: false, + }); + } finally { + harness.unmount(); + } + }); + + it("切换主题时应切换作用域,并在更新后持久化当前主题", () => { + saveChatToolPreferences( + { webSearch: true, thinking: false, task: false, subagent: false }, + "general", + ); + saveChatToolPreferences( + { webSearch: false, thinking: true, task: true, subagent: true }, + "social-media", + ); + + const harness = mountHook("general"); + + try { + harness.rerender("social-media"); + expect(harness.getValue().chatToolPreferences).toEqual({ + webSearch: false, + thinking: true, + task: true, + subagent: true, + }); + + act(() => { + harness.getValue().setChatToolPreferences({ + webSearch: true, + thinking: true, + task: false, + subagent: true, + }); + }); + + expect( + JSON.parse( + localStorage.getItem("lime.chat.tool_preferences.social-media.v3") || + "null", + ), + ).toEqual({ + webSearch: true, + thinking: true, + task: false, + subagent: true, + }); + } finally { + harness.unmount(); + } + }); +}); diff --git a/src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.ts b/src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.ts new file mode 100644 index 000000000..9964995f3 --- /dev/null +++ b/src/components/agent/chat/hooks/useThemeScopedChatToolPreferences.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; +import { + loadChatToolPreferences, + saveChatToolPreferences, + type ChatToolPreferences, +} from "../utils/chatToolPreferences"; + +export function useThemeScopedChatToolPreferences(activeTheme: string) { + const [chatToolPreferences, setChatToolPreferences] = + useState(() => loadChatToolPreferences(activeTheme)); + const [chatToolPreferencesTheme, setChatToolPreferencesTheme] = + useState(activeTheme); + + useEffect(() => { + if (chatToolPreferencesTheme === activeTheme) { + return; + } + + setChatToolPreferences(loadChatToolPreferences(activeTheme)); + setChatToolPreferencesTheme(activeTheme); + }, [activeTheme, chatToolPreferencesTheme]); + + useEffect(() => { + if (chatToolPreferencesTheme !== activeTheme) { + return; + } + + saveChatToolPreferences(chatToolPreferences, activeTheme); + }, [activeTheme, chatToolPreferences, chatToolPreferencesTheme]); + + return { + chatToolPreferences, + setChatToolPreferences, + }; +} diff --git a/src/components/agent/chat/hooks/useThemeWorkbenchEntryPrompt.test.tsx b/src/components/agent/chat/hooks/useThemeWorkbenchEntryPrompt.test.tsx new file mode 100644 index 000000000..fad96bbe2 --- /dev/null +++ b/src/components/agent/chat/hooks/useThemeWorkbenchEntryPrompt.test.tsx @@ -0,0 +1,200 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useThemeWorkbenchEntryPrompt } from "./useThemeWorkbenchEntryPrompt"; + +interface HookHarness { + getValue: () => ReturnType; + rerender: (props?: Partial) => void; + unmount: () => void; + onHydrateInitialPrompt: ReturnType; +} + +interface HookProps { + activeTheme: string; + contentId?: string; + sessionId?: string; + isThemeWorkbench: boolean; + shouldUseCompactThemeWorkbench: boolean; + messagesCount: number; + initialDispatchKey: string | null; + initialUserPrompt?: string; + initialUserImages?: Array<{ data: string; mediaType: string }>; + consumedInitialPromptKey?: string | null; +} + +function mountHook(initialProps?: Partial): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const onHydrateInitialPrompt = vi.fn(); + + const loadWorkflow = vi.fn(async () => null); + const loadRunState = vi.fn(async () => null); + + let hookValue: ReturnType | null = null; + let currentProps: HookProps = { + activeTheme: "social-media", + contentId: "content-1", + sessionId: "session-1", + isThemeWorkbench: true, + shouldUseCompactThemeWorkbench: false, + messagesCount: 0, + initialDispatchKey: null, + initialUserPrompt: "", + initialUserImages: [], + consumedInitialPromptKey: null, + ...initialProps, + }; + + function TestComponent() { + hookValue = useThemeWorkbenchEntryPrompt({ + ...currentProps, + onHydrateInitialPrompt, + loadWorkflow, + loadRunState, + }); + return null; + } + + const render = (nextProps?: Partial) => { + currentProps = { + ...currentProps, + ...nextProps, + }; + act(() => { + root.render(); + }); + }; + + render(); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + rerender: render, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + onHydrateInitialPrompt, + }; +} + +async function flushEffects(times = 4) { + for (let index = 0; index < times; index += 1) { + await act(async () => { + await Promise.resolve(); + }); + } +} + +describe("useThemeWorkbenchEntryPrompt", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("主题工作台初始意图应先进入预填提示态", async () => { + const harness = mountHook({ + initialDispatchKey: "initial-dispatch", + initialUserPrompt: "请先生成社媒主稿", + }); + + try { + await flushEffects(); + expect(harness.onHydrateInitialPrompt).toHaveBeenCalledWith( + "请先生成社媒主稿", + "initial-dispatch", + ); + expect(harness.getValue().themeWorkbenchEntryPrompt).toMatchObject({ + kind: "initial_prompt", + prompt: "请先生成社媒主稿", + }); + } finally { + harness.unmount(); + } + }); + + it("无初始意图时应查询 resume prompt", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const onHydrateInitialPrompt = vi.fn(); + const loadWorkflow = vi.fn(async () => ({ + id: "wf-1", + content_id: "content-1", + theme: "social-media", + mode: "guided", + current_step_index: 1, + created_at: Date.now(), + updated_at: Date.now(), + steps: [ + { + id: "step-1", + title: "撰写主稿", + status: "completed", + }, + { + id: "step-2", + title: "润色结尾", + status: "pending", + }, + ], + })); + const loadRunState = vi.fn(async () => null); + let hookValue: ReturnType | null = null; + + function TestComponent() { + hookValue = useThemeWorkbenchEntryPrompt({ + activeTheme: "social-media", + contentId: "content-1", + sessionId: "session-1", + isThemeWorkbench: true, + shouldUseCompactThemeWorkbench: false, + messagesCount: 0, + initialDispatchKey: null, + initialUserPrompt: "", + initialUserImages: [], + consumedInitialPromptKey: null, + onHydrateInitialPrompt, + loadWorkflow, + loadRunState, + }); + return null; + } + + act(() => { + root.render(); + }); + + try { + await flushEffects(); + expect(loadWorkflow).toHaveBeenCalledWith("content-1"); + expect(hookValue?.themeWorkbenchEntryPrompt).toMatchObject({ + kind: "resume", + title: "发现上次未完成任务", + }); + expect(hookValue?.themeWorkbenchEntryCheckPending).toBe(false); + } finally { + act(() => { + root.unmount(); + }); + container.remove(); + } + }); +}); diff --git a/src/components/agent/chat/hooks/useThemeWorkbenchEntryPrompt.ts b/src/components/agent/chat/hooks/useThemeWorkbenchEntryPrompt.ts new file mode 100644 index 000000000..4bc578417 --- /dev/null +++ b/src/components/agent/chat/hooks/useThemeWorkbenchEntryPrompt.ts @@ -0,0 +1,348 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + contentWorkflowApi, + type WorkflowState as ContentWorkflowState, +} from "@/lib/api/content-workflow"; +import { + executionRunGetThemeWorkbenchState, + type ThemeWorkbenchRunState as BackendThemeWorkbenchRunState, + type ThemeWorkbenchRunTerminalItem, + type ThemeWorkbenchRunTodoItem, +} from "@/lib/api/executionRun"; +import type { MessageImage } from "../types"; + +export interface ThemeWorkbenchEntryPromptState { + kind: "initial_prompt" | "resume"; + signature: string; + title: string; + description: string; + actionLabel: string; + prompt: string; +} + +interface UseThemeWorkbenchEntryPromptOptions { + activeTheme: string; + contentId?: string; + sessionId?: string; + isThemeWorkbench: boolean; + shouldUseCompactThemeWorkbench: boolean; + messagesCount: number; + initialDispatchKey: string | null; + initialUserPrompt?: string; + initialUserImages?: MessageImage[]; + consumedInitialPromptKey?: string | null; + onHydrateInitialPrompt: (prompt: string, dispatchKey: string) => void; + loadWorkflow?: (contentId: string) => Promise; + loadRunState?: ( + sessionId: string, + ) => Promise; +} + +const defaultLoadThemeWorkbenchWorkflow = (contentId: string) => + contentWorkflowApi.getByContent(contentId); + +const defaultLoadThemeWorkbenchRunState = (sessionId: string) => + executionRunGetThemeWorkbenchState(sessionId, 3); + +function resolveThemeWorkbenchGateLabel( + gateKey?: ThemeWorkbenchRunTodoItem["gate_key"], +): string | null { + switch (gateKey) { + case "topic_select": + return "选题确认"; + case "write_mode": + return "写作推进"; + case "publish_confirm": + return "发布确认"; + case "idle": + case null: + case undefined: + default: + return null; + } +} + +function hasWorkflowMeaningfulProgress( + workflow: ContentWorkflowState | null, +): boolean { + if (!workflow) { + return false; + } + + if (workflow.current_step_index > 0) { + return true; + } + + return workflow.steps.some( + (step) => + step.status === "completed" || + step.status === "skipped" || + step.status === "error" || + Boolean(step.result), + ); +} + +export function buildThemeWorkbenchResumePromptFromWorkflow( + workflow: ContentWorkflowState | null, +): ThemeWorkbenchEntryPromptState | null { + if (!workflow || !hasWorkflowMeaningfulProgress(workflow)) { + return null; + } + + const hasPendingStep = workflow.steps.some( + (step) => step.status !== "completed" && step.status !== "skipped", + ); + if (!hasPendingStep) { + return null; + } + + const activeStep = + workflow.steps.find( + (step) => + step.status === "active" || + step.status === "pending" || + step.status === "error", + ) || workflow.steps[workflow.current_step_index]; + const stepTitle = activeStep?.title?.trim() || "当前创作阶段"; + + return { + kind: "resume", + signature: `workflow:${workflow.id}:${workflow.updated_at}:${activeStep?.id || ""}`, + title: "发现上次未完成任务", + description: `检测到当前文稿上次停留在“${stepTitle}”,可以直接衔接已有进度继续。`, + actionLabel: "继续上次任务", + prompt: `请基于当前文稿与已有上下文,继续推进上次未完成的任务。优先继续“${stepTitle}”阶段,不要从头重复已经完成的内容。先简要确认当前进度,再继续执行。`, + }; +} + +function resolveThemeWorkbenchPendingRunCandidate( + state: BackendThemeWorkbenchRunState | null, +): ThemeWorkbenchRunTodoItem | ThemeWorkbenchRunTerminalItem | null { + if (!state) { + return null; + } + + const activeQueueItem = (state.queue_items || []).find((item) => + ["queued", "running", "error", "timeout"].includes(item.status), + ); + if (activeQueueItem) { + return activeQueueItem; + } + + if ( + state.latest_terminal && + ["queued", "running", "error", "timeout"].includes( + state.latest_terminal.status, + ) + ) { + return state.latest_terminal; + } + + return null; +} + +export function buildThemeWorkbenchResumePromptFromRunState( + state: BackendThemeWorkbenchRunState | null, +): ThemeWorkbenchEntryPromptState | null { + const pendingRun = resolveThemeWorkbenchPendingRunCandidate(state); + if (!pendingRun) { + return null; + } + + const runTitle = pendingRun.title?.trim() || "最近一次创作任务"; + const gateLabel = resolveThemeWorkbenchGateLabel(pendingRun.gate_key); + const stageSuffix = gateLabel ? `,当前停留在“${gateLabel}”附近` : ""; + + return { + kind: "resume", + signature: `run:${pendingRun.run_id}:${pendingRun.status}:${pendingRun.started_at}:${"finished_at" in pendingRun ? pendingRun.finished_at || "" : ""}`, + title: "发现上次未完成任务", + description: `最近一次任务“${runTitle}”尚未完成${stageSuffix}。`, + actionLabel: "继续上次任务", + prompt: `请基于当前文稿与最近一次未完成的运行继续推进。任务标题:${runTitle}。${gateLabel ? `优先衔接“${gateLabel}”阶段。` : ""}不要从头开始,先概括已有进度,再继续执行。`, + }; +} + +export function useThemeWorkbenchEntryPrompt({ + activeTheme, + contentId, + sessionId, + isThemeWorkbench, + shouldUseCompactThemeWorkbench, + messagesCount, + initialDispatchKey, + initialUserPrompt, + initialUserImages, + consumedInitialPromptKey, + onHydrateInitialPrompt, + loadWorkflow = defaultLoadThemeWorkbenchWorkflow, + loadRunState = defaultLoadThemeWorkbenchRunState, +}: UseThemeWorkbenchEntryPromptOptions) { + const [themeWorkbenchEntryPrompt, setThemeWorkbenchEntryPrompt] = + useState(null); + const [themeWorkbenchEntryCheckPending, setThemeWorkbenchEntryCheckPending] = + useState(false); + const hydratedPromptSignatureRef = useRef(null); + const dismissedPromptSignatureRef = useRef(null); + + useEffect(() => { + hydratedPromptSignatureRef.current = null; + dismissedPromptSignatureRef.current = null; + setThemeWorkbenchEntryPrompt(null); + setThemeWorkbenchEntryCheckPending(false); + }, [activeTheme, contentId, initialDispatchKey]); + + useEffect(() => { + if (shouldUseCompactThemeWorkbench) { + return; + } + + const pendingInitialPrompt = (initialUserPrompt || "").trim(); + const pendingInitialImages = initialUserImages || []; + if ( + !isThemeWorkbench || + !contentId || + !initialDispatchKey || + !pendingInitialPrompt || + pendingInitialImages.length > 0 || + messagesCount > 0 + ) { + return; + } + + if ( + consumedInitialPromptKey === initialDispatchKey || + hydratedPromptSignatureRef.current === initialDispatchKey + ) { + return; + } + + hydratedPromptSignatureRef.current = initialDispatchKey; + onHydrateInitialPrompt(pendingInitialPrompt, initialDispatchKey); + setThemeWorkbenchEntryPrompt({ + kind: "initial_prompt", + signature: initialDispatchKey, + title: "已恢复待执行创作意图", + description: "进入页面后不会自动开始生成,确认后再继续。", + actionLabel: "继续生成", + prompt: pendingInitialPrompt, + }); + }, [ + consumedInitialPromptKey, + contentId, + initialDispatchKey, + initialUserImages, + initialUserPrompt, + isThemeWorkbench, + messagesCount, + onHydrateInitialPrompt, + shouldUseCompactThemeWorkbench, + ]); + + useEffect(() => { + if (shouldUseCompactThemeWorkbench) { + setThemeWorkbenchEntryCheckPending(false); + return; + } + + if ( + !isThemeWorkbench || + !contentId || + !sessionId || + messagesCount > 0 || + Boolean(initialDispatchKey) + ) { + setThemeWorkbenchEntryCheckPending(false); + return; + } + + let disposed = false; + setThemeWorkbenchEntryCheckPending(true); + + void (async () => { + try { + const [workflow, backendState] = await Promise.all([ + loadWorkflow(contentId).catch(() => null), + loadRunState(sessionId).catch(() => null), + ]); + + if (disposed) { + return; + } + + const nextPrompt = + buildThemeWorkbenchResumePromptFromWorkflow(workflow) ?? + buildThemeWorkbenchResumePromptFromRunState(backendState); + + if (!nextPrompt) { + setThemeWorkbenchEntryPrompt((current) => + current?.kind === "resume" ? null : current, + ); + return; + } + + if (dismissedPromptSignatureRef.current === nextPrompt.signature) { + return; + } + + setThemeWorkbenchEntryPrompt((current) => + current?.kind === "initial_prompt" ? current : nextPrompt, + ); + } finally { + if (!disposed) { + setThemeWorkbenchEntryCheckPending(false); + } + } + })(); + + return () => { + disposed = true; + }; + }, [ + contentId, + initialDispatchKey, + isThemeWorkbench, + loadRunState, + loadWorkflow, + messagesCount, + sessionId, + shouldUseCompactThemeWorkbench, + ]); + + const clearThemeWorkbenchEntryPrompt = useCallback(() => { + setThemeWorkbenchEntryPrompt(null); + }, []); + + const dismissThemeWorkbenchEntryPrompt = useCallback( + (options?: { + consumeInitialPrompt?: boolean; + onConsumeInitialPrompt?: () => void; + }) => { + setThemeWorkbenchEntryPrompt((current) => { + if (!current) { + return current; + } + + if ( + current.kind === "initial_prompt" && + options?.consumeInitialPrompt && + initialDispatchKey + ) { + options.onConsumeInitialPrompt?.(); + } else { + dismissedPromptSignatureRef.current = current.signature; + } + + return null; + }); + }, + [initialDispatchKey], + ); + + return { + themeWorkbenchEntryPrompt, + themeWorkbenchEntryCheckPending, + clearThemeWorkbenchEntryPrompt, + dismissThemeWorkbenchEntryPrompt, + }; +} diff --git a/src/components/agent/chat/hooks/useThemeWorkbenchEntryPromptActions.test.tsx b/src/components/agent/chat/hooks/useThemeWorkbenchEntryPromptActions.test.tsx new file mode 100644 index 000000000..a7d3ecd4c --- /dev/null +++ b/src/components/agent/chat/hooks/useThemeWorkbenchEntryPromptActions.test.tsx @@ -0,0 +1,199 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useThemeWorkbenchEntryPromptActions } from "./useThemeWorkbenchEntryPromptActions"; +import type { ThemeWorkbenchEntryPromptState } from "./useThemeWorkbenchEntryPrompt"; + +interface HookProps { + themeWorkbenchEntryPrompt: ThemeWorkbenchEntryPromptState | null; + input: string; + initialDispatchKey: string | null; +} + +interface HookHarness { + getValue: () => ReturnType; + rerender: (props?: Partial) => void; + unmount: () => void; + onContinuePrompt: ReturnType; + dismissThemeWorkbenchEntryPrompt: ReturnType; + onConsumeInitialPrompt: ReturnType; + onInputChange: ReturnType; + onRequirePrompt: ReturnType; +} + +function mountHook(initialProps?: Partial): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const onContinuePrompt = vi.fn(async () => undefined); + const dismissThemeWorkbenchEntryPrompt = vi.fn(); + const onConsumeInitialPrompt = vi.fn(); + const onInputChange = vi.fn(); + const onRequirePrompt = vi.fn(); + + let hookValue: ReturnType | null = + null; + let currentProps: HookProps = { + themeWorkbenchEntryPrompt: null, + input: "", + initialDispatchKey: null, + ...initialProps, + }; + + function TestComponent() { + hookValue = useThemeWorkbenchEntryPromptActions({ + ...currentProps, + onContinuePrompt, + dismissThemeWorkbenchEntryPrompt, + onConsumeInitialPrompt, + onInputChange, + onRequirePrompt, + }); + return null; + } + + const render = (nextProps?: Partial) => { + currentProps = { + ...currentProps, + ...nextProps, + }; + act(() => { + root.render(); + }); + }; + + render(); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + rerender: render, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + onContinuePrompt, + dismissThemeWorkbenchEntryPrompt, + onConsumeInitialPrompt, + onInputChange, + onRequirePrompt, + }; +} + +describe("useThemeWorkbenchEntryPromptActions", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("继续时应优先发送当前输入,没有输入时回退到提示文案", async () => { + const harness = mountHook({ + themeWorkbenchEntryPrompt: { + kind: "initial_prompt", + signature: "dispatch-1", + title: "已恢复待执行创作意图", + description: "desc", + actionLabel: "继续生成", + prompt: "请先生成主稿", + }, + input: "", + initialDispatchKey: "dispatch-1", + }); + + try { + await act(async () => { + await harness + .getValue() + .handleContinueThemeWorkbenchEntryPrompt(); + }); + expect(harness.onContinuePrompt).toHaveBeenCalledWith("请先生成主稿"); + + harness.rerender({ + input: "我已经补充了额外要求", + }); + + await act(async () => { + await harness + .getValue() + .handleContinueThemeWorkbenchEntryPrompt(); + }); + expect(harness.onContinuePrompt).toHaveBeenLastCalledWith( + "我已经补充了额外要求", + ); + } finally { + harness.unmount(); + } + }); + + it("继续时没有任何可发送内容应提示补充", async () => { + const harness = mountHook({ + themeWorkbenchEntryPrompt: { + kind: "resume", + signature: "resume-1", + title: "发现上次未完成任务", + description: "desc", + actionLabel: "继续任务", + prompt: " ", + }, + input: " ", + }); + + try { + await act(async () => { + await harness + .getValue() + .handleContinueThemeWorkbenchEntryPrompt(); + }); + expect(harness.onRequirePrompt).toHaveBeenCalledTimes(1); + expect(harness.onContinuePrompt).not.toHaveBeenCalled(); + } finally { + harness.unmount(); + } + }); + + it("重新开始初始提示时应消费意图并清空输入", () => { + const harness = mountHook({ + themeWorkbenchEntryPrompt: { + kind: "initial_prompt", + signature: "dispatch-1", + title: "已恢复待执行创作意图", + description: "desc", + actionLabel: "继续生成", + prompt: "请先生成主稿", + }, + initialDispatchKey: "dispatch-1", + input: "已有内容", + }); + + try { + act(() => { + harness.getValue().handleRestartThemeWorkbenchEntryPrompt(); + }); + + expect(harness.dismissThemeWorkbenchEntryPrompt).toHaveBeenCalledTimes(1); + const options = + harness.dismissThemeWorkbenchEntryPrompt.mock.calls[0]?.[0]; + expect(options?.consumeInitialPrompt).toBe(true); + options?.onConsumeInitialPrompt?.(); + expect(harness.onConsumeInitialPrompt).toHaveBeenCalledWith("dispatch-1"); + expect(harness.onInputChange).toHaveBeenCalledWith(""); + } finally { + harness.unmount(); + } + }); +}); diff --git a/src/components/agent/chat/hooks/useThemeWorkbenchEntryPromptActions.ts b/src/components/agent/chat/hooks/useThemeWorkbenchEntryPromptActions.ts new file mode 100644 index 000000000..64159a80d --- /dev/null +++ b/src/components/agent/chat/hooks/useThemeWorkbenchEntryPromptActions.ts @@ -0,0 +1,71 @@ +import { useCallback } from "react"; +import type { ThemeWorkbenchEntryPromptState } from "./useThemeWorkbenchEntryPrompt"; + +interface DismissThemeWorkbenchEntryPromptOptions { + consumeInitialPrompt?: boolean; + onConsumeInitialPrompt?: () => void; +} + +interface UseThemeWorkbenchEntryPromptActionsOptions { + themeWorkbenchEntryPrompt: ThemeWorkbenchEntryPromptState | null; + input: string; + initialDispatchKey: string | null; + onContinuePrompt: (prompt: string) => Promise | void; + dismissThemeWorkbenchEntryPrompt: ( + options?: DismissThemeWorkbenchEntryPromptOptions, + ) => void; + onConsumeInitialPrompt?: (dispatchKey: string | null) => void; + onInputChange: (value: string) => void; + onRequirePrompt?: () => void; +} + +export function useThemeWorkbenchEntryPromptActions({ + themeWorkbenchEntryPrompt, + input, + initialDispatchKey, + onContinuePrompt, + dismissThemeWorkbenchEntryPrompt, + onConsumeInitialPrompt, + onInputChange, + onRequirePrompt, +}: UseThemeWorkbenchEntryPromptActionsOptions) { + const handleContinueThemeWorkbenchEntryPrompt = useCallback(async () => { + if (!themeWorkbenchEntryPrompt) { + return; + } + + const promptToSend = + input.trim() || themeWorkbenchEntryPrompt.prompt.trim(); + if (!promptToSend) { + onRequirePrompt?.(); + return; + } + + await onContinuePrompt(promptToSend); + }, [input, onContinuePrompt, onRequirePrompt, themeWorkbenchEntryPrompt]); + + const handleRestartThemeWorkbenchEntryPrompt = useCallback(() => { + if (!themeWorkbenchEntryPrompt) { + return; + } + + dismissThemeWorkbenchEntryPrompt({ + consumeInitialPrompt: themeWorkbenchEntryPrompt.kind === "initial_prompt", + onConsumeInitialPrompt: () => { + onConsumeInitialPrompt?.(initialDispatchKey); + }, + }); + onInputChange(""); + }, [ + dismissThemeWorkbenchEntryPrompt, + initialDispatchKey, + onConsumeInitialPrompt, + onInputChange, + themeWorkbenchEntryPrompt, + ]); + + return { + handleContinueThemeWorkbenchEntryPrompt, + handleRestartThemeWorkbenchEntryPrompt, + }; +} diff --git a/src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.test.tsx b/src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.test.tsx new file mode 100644 index 000000000..ffd339cb9 --- /dev/null +++ b/src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.test.tsx @@ -0,0 +1,216 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + useThemeWorkbenchSendBoundary, +} from "./useThemeWorkbenchSendBoundary"; +import type { BrowserTaskPreflight } from "./handleSendTypes"; + +interface HookHarnessProps { + isThemeWorkbench: boolean; + contentId?: string; + initialDispatchKey: string | null; + consumedInitialPromptKey: string | null; + mappedTheme: string; + browserTaskPreflight: BrowserTaskPreflight | null; + isBrowserAssistReady: boolean; +} + +interface HookHarness { + getValue: () => ReturnType; + rerender: (props?: Partial) => void; + unmount: () => void; + onConsumeInitialPrompt: ReturnType; + onResetConsumedInitialPrompt: ReturnType; + onClearEntryPrompt: ReturnType; + onPrepareBrowserTaskPreflight: ReturnType; +} + +function createPreflight(): BrowserTaskPreflight { + return { + requestId: "browser-preflight:existing", + createdAt: 123, + sourceText: "旧任务", + images: [], + requirement: "required_with_user_step", + reason: "需要浏览器", + phase: "awaiting_user", + launchUrl: "https://mp.weixin.qq.com/", + }; +} + +function mountHook(initialProps?: Partial): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const onConsumeInitialPrompt = vi.fn(); + const onResetConsumedInitialPrompt = vi.fn(); + const onClearEntryPrompt = vi.fn(); + const onPrepareBrowserTaskPreflight = vi.fn(); + + let hookValue: ReturnType | null = null; + let currentProps: HookHarnessProps = { + isThemeWorkbench: true, + contentId: "content-1", + initialDispatchKey: "dispatch-1", + consumedInitialPromptKey: null, + mappedTheme: "social-media", + browserTaskPreflight: null, + isBrowserAssistReady: true, + ...initialProps, + }; + + function TestComponent() { + hookValue = useThemeWorkbenchSendBoundary({ + ...currentProps, + initialUserImages: [], + socialArticleSkillKey: "social_post_with_cover", + onConsumeInitialPrompt, + onResetConsumedInitialPrompt, + onClearEntryPrompt, + onPrepareBrowserTaskPreflight, + }); + return null; + } + + const render = (nextProps?: Partial) => { + currentProps = { + ...currentProps, + ...nextProps, + }; + + act(() => { + root.render(); + }); + }; + + render(); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + rerender: render, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + onConsumeInitialPrompt, + onResetConsumedInitialPrompt, + onClearEntryPrompt, + onPrepareBrowserTaskPreflight, + }; +} + +describe("useThemeWorkbenchSendBoundary", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("应识别社媒主题工作台的首条意图消费与技能前缀", () => { + const harness = mountHook(); + + try { + const boundary = harness.getValue().resolveSendBoundary({ + sourceText: "请生成今天的社媒主稿", + }); + + expect(boundary.sourceText).toBe( + "/social_post_with_cover 请生成今天的社媒主稿", + ); + expect(boundary.shouldConsumePendingThemeWorkbenchInitialPrompt).toBe( + true, + ); + expect(boundary.shouldDismissThemeWorkbenchEntryPrompt).toBe(true); + expect(boundary.browserRequirementMatch).toBeNull(); + } finally { + harness.unmount(); + } + }); + + it("已有浏览器前置引导时应阻止未确认发送", () => { + const harness = mountHook({ + mappedTheme: "general", + browserTaskPreflight: createPreflight(), + }); + + try { + expect(harness.getValue().isBlockedByBrowserPreflight()).toBe(true); + expect( + harness.getValue().isBlockedByBrowserPreflight({ + browserPreflightConfirmed: true, + }), + ).toBe(false); + } finally { + harness.unmount(); + } + }); + + it("需要真实浏览器时应创建前置引导并同步消费初始意图", () => { + const harness = mountHook({ + mappedTheme: "general", + isBrowserAssistReady: false, + }); + + try { + const boundary = harness.getValue().resolveSendBoundary({ + sourceText: "帮我把这篇文章发布到微信公众号后台", + }); + + const started = harness.getValue().maybeStartBrowserTaskPreflight({ + boundary, + images: [], + webSearch: false, + thinking: false, + sendExecutionStrategy: "auto", + }); + + expect(started).toBe(true); + expect(harness.onPrepareBrowserTaskPreflight).toHaveBeenCalledWith( + expect.objectContaining({ + sourceText: "帮我把这篇文章发布到微信公众号后台", + requirement: "required_with_user_step", + launchUrl: "https://mp.weixin.qq.com/", + phase: "launching", + }), + ); + expect(harness.onConsumeInitialPrompt).toHaveBeenCalledWith("dispatch-1"); + expect(harness.onClearEntryPrompt).toHaveBeenCalledTimes(1); + } finally { + harness.unmount(); + } + }); + + it("发送失败时应回滚已消费的首条意图", () => { + const harness = mountHook(); + + try { + const boundary = harness.getValue().resolveSendBoundary({ + sourceText: "请生成今天的社媒主稿", + }); + + act(() => { + harness.getValue().rollbackAfterSendFailure(boundary); + }); + + expect(harness.onResetConsumedInitialPrompt).toHaveBeenCalledTimes(1); + } finally { + harness.unmount(); + } + }); +}); diff --git a/src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.ts b/src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.ts new file mode 100644 index 000000000..2d0c02272 --- /dev/null +++ b/src/components/agent/chat/hooks/useThemeWorkbenchSendBoundary.ts @@ -0,0 +1,277 @@ +import { useCallback } from "react"; +import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime"; +import type { BrowserTaskRequirementMatch } from "../utils/browserTaskRequirement"; +import { detectBrowserTaskRequirement } from "../utils/browserTaskRequirement"; +import type { MessageImage } from "../types"; +import type { BrowserTaskPreflight, HandleSendOptions } from "./handleSendTypes"; + +interface BuildThemeWorkbenchSendBoundaryStateOptions { + isThemeWorkbench: boolean; + contentId?: string; + initialDispatchKey: string | null; + consumedInitialPromptKey: string | null; + initialUserImages?: MessageImage[]; + mappedTheme: string; + socialArticleSkillKey: string; + sourceText: string; + sendOptions?: HandleSendOptions; +} + +export interface ThemeWorkbenchSendBoundaryState { + sourceText: string; + browserRequirementMatch: BrowserTaskRequirementMatch | null; + shouldConsumePendingThemeWorkbenchInitialPrompt: boolean; + shouldDismissThemeWorkbenchEntryPrompt: boolean; +} + +interface CreateBrowserTaskPreflightOptions { + sourceText: string; + images?: MessageImage[]; + webSearch?: boolean; + thinking?: boolean; + sendExecutionStrategy?: "react" | "code_orchestrated" | "auto"; + autoContinuePayload?: AutoContinueRequestPayload; + sendOptions?: HandleSendOptions; + browserRequirementMatch: BrowserTaskRequirementMatch; + createRequestId?: () => string; + now?: () => number; +} + +interface UseThemeWorkbenchSendBoundaryOptions { + isThemeWorkbench: boolean; + contentId?: string; + initialDispatchKey: string | null; + consumedInitialPromptKey: string | null; + initialUserImages?: MessageImage[]; + mappedTheme: string; + socialArticleSkillKey: string; + isBrowserAssistReady: boolean; + browserTaskPreflight: BrowserTaskPreflight | null; + onConsumeInitialPrompt: (dispatchKey: string) => void; + onResetConsumedInitialPrompt: () => void; + onClearEntryPrompt: () => void; + onPrepareBrowserTaskPreflight: (preflight: BrowserTaskPreflight) => void; +} + +interface StartBrowserTaskPreflightOptions { + boundary: ThemeWorkbenchSendBoundaryState; + images?: MessageImage[]; + webSearch?: boolean; + thinking?: boolean; + sendExecutionStrategy?: "react" | "code_orchestrated" | "auto"; + autoContinuePayload?: AutoContinueRequestPayload; + sendOptions?: HandleSendOptions; +} + +function defaultCreateBrowserTaskPreflightRequestId() { + return `browser-preflight:${crypto.randomUUID()}`; +} + +export function buildThemeWorkbenchSendBoundaryState({ + isThemeWorkbench, + contentId, + initialDispatchKey, + consumedInitialPromptKey, + initialUserImages, + mappedTheme, + socialArticleSkillKey, + sourceText, + sendOptions, +}: BuildThemeWorkbenchSendBoundaryStateOptions): ThemeWorkbenchSendBoundaryState { + const shouldConsumePendingThemeWorkbenchInitialPrompt = + isThemeWorkbench && + Boolean(contentId) && + Boolean(initialDispatchKey) && + consumedInitialPromptKey !== initialDispatchKey && + (initialUserImages || []).length === 0 && + !sendOptions?.purpose; + const shouldDismissThemeWorkbenchEntryPrompt = + isThemeWorkbench && !sendOptions?.purpose; + + let nextSourceText = sourceText; + + if ( + isThemeWorkbench && + mappedTheme === "social-media" && + nextSourceText.trim() && + !nextSourceText.trimStart().startsWith("/") && + !sendOptions?.skipThemeSkillPrefix + ) { + nextSourceText = `/${socialArticleSkillKey} ${nextSourceText}`.trim(); + } + + const browserRequirementMatch = + mappedTheme === "general" && !sendOptions?.purpose + ? detectBrowserTaskRequirement(nextSourceText) + : null; + + return { + sourceText: nextSourceText, + browserRequirementMatch, + shouldConsumePendingThemeWorkbenchInitialPrompt, + shouldDismissThemeWorkbenchEntryPrompt, + }; +} + +export function createBrowserTaskPreflight({ + sourceText, + images, + webSearch, + thinking, + sendExecutionStrategy, + autoContinuePayload, + sendOptions, + browserRequirementMatch, + createRequestId = defaultCreateBrowserTaskPreflightRequestId, + now = () => Date.now(), +}: CreateBrowserTaskPreflightOptions): BrowserTaskPreflight { + return { + requestId: createRequestId(), + createdAt: now(), + sourceText, + images: images || [], + webSearch, + thinking, + sendExecutionStrategy, + autoContinuePayload, + sendOptions, + requirement: browserRequirementMatch.requirement, + reason: browserRequirementMatch.reason, + phase: "launching", + launchUrl: browserRequirementMatch.launchUrl, + platformLabel: browserRequirementMatch.platformLabel, + detail: "正在尝试建立浏览器会话,请稍候...", + }; +} + +export function useThemeWorkbenchSendBoundary({ + isThemeWorkbench, + contentId, + initialDispatchKey, + consumedInitialPromptKey, + initialUserImages, + mappedTheme, + socialArticleSkillKey, + isBrowserAssistReady, + browserTaskPreflight, + onConsumeInitialPrompt, + onResetConsumedInitialPrompt, + onClearEntryPrompt, + onPrepareBrowserTaskPreflight, +}: UseThemeWorkbenchSendBoundaryOptions) { + const resolveSendBoundary = useCallback( + ({ sourceText, sendOptions }: { sourceText: string; sendOptions?: HandleSendOptions }) => + buildThemeWorkbenchSendBoundaryState({ + isThemeWorkbench, + contentId, + initialDispatchKey, + consumedInitialPromptKey, + initialUserImages, + mappedTheme, + socialArticleSkillKey, + sourceText, + sendOptions, + }), + [ + consumedInitialPromptKey, + contentId, + initialDispatchKey, + initialUserImages, + isThemeWorkbench, + mappedTheme, + socialArticleSkillKey, + ], + ); + + const isBlockedByBrowserPreflight = useCallback( + (sendOptions?: HandleSendOptions) => + Boolean(browserTaskPreflight) && !sendOptions?.browserPreflightConfirmed, + [browserTaskPreflight], + ); + + const maybeStartBrowserTaskPreflight = useCallback( + ({ + boundary, + images, + webSearch, + thinking, + sendExecutionStrategy, + autoContinuePayload, + sendOptions, + }: StartBrowserTaskPreflightOptions) => { + if ( + !boundary.browserRequirementMatch || + sendOptions?.browserPreflightConfirmed || + isBrowserAssistReady + ) { + return false; + } + + const preflight = createBrowserTaskPreflight({ + sourceText: boundary.sourceText, + images, + webSearch, + thinking, + sendExecutionStrategy, + autoContinuePayload, + sendOptions, + browserRequirementMatch: boundary.browserRequirementMatch, + }); + + onPrepareBrowserTaskPreflight(preflight); + + if ( + boundary.shouldConsumePendingThemeWorkbenchInitialPrompt && + initialDispatchKey + ) { + onConsumeInitialPrompt(initialDispatchKey); + } + + if (boundary.shouldDismissThemeWorkbenchEntryPrompt) { + onClearEntryPrompt(); + } + + return true; + }, + [ + initialDispatchKey, + isBrowserAssistReady, + onClearEntryPrompt, + onConsumeInitialPrompt, + onPrepareBrowserTaskPreflight, + ], + ); + + const finalizeAfterSendSuccess = useCallback( + (boundary: ThemeWorkbenchSendBoundaryState) => { + if ( + boundary.shouldConsumePendingThemeWorkbenchInitialPrompt && + initialDispatchKey + ) { + onConsumeInitialPrompt(initialDispatchKey); + } + + if (boundary.shouldDismissThemeWorkbenchEntryPrompt) { + onClearEntryPrompt(); + } + }, + [initialDispatchKey, onClearEntryPrompt, onConsumeInitialPrompt], + ); + + const rollbackAfterSendFailure = useCallback( + (boundary: ThemeWorkbenchSendBoundaryState) => { + if (boundary.shouldConsumePendingThemeWorkbenchInitialPrompt) { + onResetConsumedInitialPrompt(); + } + }, + [onResetConsumedInitialPrompt], + ); + + return { + resolveSendBoundary, + isBlockedByBrowserPreflight, + maybeStartBrowserTaskPreflight, + finalizeAfterSendSuccess, + rollbackAfterSendFailure, + }; +} diff --git a/src/components/agent/chat/hooks/useTrayModelShortcuts.test.ts b/src/components/agent/chat/hooks/useTrayModelShortcuts.test.ts index aa46f75ee..fed6f26e0 100644 --- a/src/components/agent/chat/hooks/useTrayModelShortcuts.test.ts +++ b/src/components/agent/chat/hooks/useTrayModelShortcuts.test.ts @@ -1,29 +1,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { - getOverview, - getProviders, + loadConfiguredProviders, getModelRegistry, getAllAliasConfigs, syncTrayModelShortcuts, } = vi.hoisted(() => ({ - getOverview: vi.fn(), - getProviders: vi.fn(), + loadConfiguredProviders: vi.fn(), getModelRegistry: vi.fn(), getAllAliasConfigs: vi.fn(), syncTrayModelShortcuts: vi.fn(), })); -vi.mock("@/lib/api/providerPool", () => ({ - providerPoolApi: { - getOverview, - }, -})); - -vi.mock("@/lib/api/apiKeyProvider", () => ({ - apiKeyProviderApi: { - getProviders, - }, +vi.mock("@/hooks/useConfiguredProviders", () => ({ + loadConfiguredProviders, })); vi.mock("@/lib/api/modelRegistry", () => ({ @@ -68,20 +58,12 @@ describe("buildTrayPayload", () => { vi.clearAllMocks(); invalidateTrayPayloadCache(); - getOverview.mockResolvedValue([ + loadConfiguredProviders.mockResolvedValue([ { - provider_type: "deepseek", - credentials: [{ credential_type: "deepseek" }], - }, - ]); - getProviders.mockResolvedValue([ - { - id: "deepseek", - name: "DeepSeek", + key: "deepseek", + label: "DeepSeek", + registryId: "deepseek", type: "deepseek", - enabled: true, - api_key_count: 1, - custom_models: [], }, ]); getModelRegistry.mockResolvedValue([ @@ -107,8 +89,7 @@ describe("buildTrayPayload", () => { ); expect(second).toEqual(first); - expect(getOverview).toHaveBeenCalledTimes(1); - expect(getProviders).toHaveBeenCalledTimes(1); + expect(loadConfiguredProviders).toHaveBeenCalledTimes(1); expect(getModelRegistry).toHaveBeenCalledTimes(1); expect(getAllAliasConfigs).toHaveBeenCalledTimes(1); }); @@ -119,8 +100,7 @@ describe("buildTrayPayload", () => { forceRefresh: true, }); - expect(getOverview).toHaveBeenCalledTimes(2); - expect(getProviders).toHaveBeenCalledTimes(2); + expect(loadConfiguredProviders).toHaveBeenCalledTimes(2); expect(getModelRegistry).toHaveBeenCalledTimes(2); expect(getAllAliasConfigs).toHaveBeenCalledTimes(2); }); @@ -132,6 +112,49 @@ describe("buildTrayPayload", () => { expect(syncTrayModelShortcuts).toHaveBeenCalledTimes(1); }); + it("当 provider 仅命中 fallbackRegistryId 时仍应保留托盘候选模型", async () => { + loadConfiguredProviders.mockResolvedValueOnce([ + { + key: "custom-openai", + label: "Custom OpenAI", + registryId: "custom-openai", + fallbackRegistryId: "openai", + type: "openai", + }, + ]); + getModelRegistry.mockResolvedValueOnce([ + { + id: "gpt-4.1", + display_name: "GPT-4.1", + provider_id: "openai", + provider_name: "OpenAI", + is_latest: true, + release_date: "2026-02-01", + }, + ]); + + const payload = await buildTrayPayload( + "custom-openai", + "gpt-4.1", + "general", + { forceRefresh: true }, + ); + + expect(payload.quick_model_groups).toEqual([ + { + provider_type: "custom-openai", + provider_label: "Custom OpenAI", + models: [ + { + provider_type: "custom-openai", + provider_label: "Custom OpenAI", + model: "gpt-4.1", + }, + ], + }, + ]); + }); + it("首次同步失败时不应缓存成功指纹,后续重试仍应继续同步", async () => { syncTrayModelShortcuts .mockRejectedValueOnce(new Error("tray unavailable")) diff --git a/src/components/agent/chat/hooks/useTrayModelShortcuts.ts b/src/components/agent/chat/hooks/useTrayModelShortcuts.ts index 4fa312be5..d1d74a8e9 100644 --- a/src/components/agent/chat/hooks/useTrayModelShortcuts.ts +++ b/src/components/agent/chat/hooks/useTrayModelShortcuts.ts @@ -6,26 +6,21 @@ import { type TrayModelSelectedPayload, type TrayQuickModelGroup, } from "@/lib/api/tray"; -import { - apiKeyProviderApi, - type ProviderWithKeysDisplay, -} from "@/lib/api/apiKeyProvider"; -import { - providerPoolApi, - type ProviderPoolOverview, -} from "@/lib/api/providerPool"; import { modelRegistryApi } from "@/lib/api/modelRegistry"; import { trayApi } from "@/lib/api/tray"; import { getAliasConfigKey, getProviderLabel, - getRegistryIdFromType, - isAliasProvider, } from "@/lib/constants/providerMappings"; import type { EnhancedModelMetadata, ProviderAliasConfig, } from "@/lib/types/modelRegistry"; +import { + loadConfiguredProviders, + type ConfiguredProvider, +} from "@/hooks/useConfiguredProviders"; +import { buildProviderModelsFromRegistry } from "@/hooks/useProviderModels"; import { filterModelsByTheme } from "../utils/modelThemePolicy"; import { getProviderModelCompatibilityIssue } from "../utils/providerModelCompatibility"; @@ -38,16 +33,6 @@ interface UseTrayModelShortcutsOptions { deferInitialSync?: boolean; } -interface ConfiguredProvider { - key: string; - label: string; - registryId: string; - fallbackRegistryId?: string; - type: string; - providerId?: string; - customModels?: string[]; -} - const MAX_TRAY_MODELS_PER_PROVIDER = 8; const TRAY_PAYLOAD_CACHE_TTL_MS = 3_000; const TRAY_SYNC_IDLE_TIMEOUT_MS = 1_500; @@ -80,172 +65,6 @@ const THEME_LABEL_MAP: Record = { novel: "小说创作", }; -function sortModels(models: EnhancedModelMetadata[]): EnhancedModelMetadata[] { - return [...models].sort((a, b) => { - if (a.is_latest && !b.is_latest) return -1; - if (!a.is_latest && b.is_latest) return 1; - - if (a.release_date && b.release_date) { - return b.release_date.localeCompare(a.release_date); - } - if (a.release_date && !b.release_date) return -1; - if (!a.release_date && b.release_date) return 1; - - return a.display_name.localeCompare(b.display_name); - }); -} - -function convertCustomModelsToMetadata( - models: string[], - providerId: string, - providerName: string, -): EnhancedModelMetadata[] { - const now = Date.now() / 1000; - return models.map((modelName) => ({ - id: modelName, - display_name: modelName, - provider_id: providerId, - provider_name: providerName, - family: null, - tier: "pro", - capabilities: { - vision: false, - tools: true, - streaming: true, - json_mode: true, - function_calling: true, - reasoning: modelName.includes("thinking"), - }, - pricing: null, - limits: { - context_length: null, - max_output_tokens: null, - requests_per_minute: null, - tokens_per_minute: null, - }, - status: "active", - release_date: null, - is_latest: false, - description: `自定义模型: ${modelName}`, - source: "custom", - created_at: now, - updated_at: now, - })); -} - -function convertAliasModelsToMetadata( - models: string[], - aliasConfig: ProviderAliasConfig, - providerId: string, - providerName: string, -): EnhancedModelMetadata[] { - const now = Date.now() / 1000; - return models.map((modelName) => { - const aliasInfo = aliasConfig.aliases[modelName]; - return { - id: modelName, - display_name: modelName, - provider_id: providerId, - provider_name: providerName, - family: aliasInfo?.provider || null, - tier: "pro" as const, - capabilities: { - vision: false, - tools: true, - streaming: true, - json_mode: true, - function_calling: true, - reasoning: modelName.includes("thinking"), - }, - pricing: null, - limits: { - context_length: null, - max_output_tokens: null, - requests_per_minute: null, - tokens_per_minute: null, - }, - status: "active" as const, - release_date: null, - is_latest: false, - description: aliasInfo?.description || aliasInfo?.actual || modelName, - source: "custom" as const, - created_at: now, - updated_at: now, - }; - }); -} - -function buildConfiguredProviders( - oauthCredentials: ProviderPoolOverview[], - apiKeyProviders: ProviderWithKeysDisplay[], -): ConfiguredProvider[] { - const providerMap = new Map(); - - oauthCredentials.forEach((overview) => { - if (overview.credentials.length === 0) { - return; - } - - const key = overview.provider_type; - if (providerMap.has(key)) { - return; - } - - providerMap.set(key, { - key, - label: getProviderLabel(key), - registryId: getRegistryIdFromType(key), - type: key, - }); - }); - - apiKeyProviders - .filter((provider) => provider.api_key_count > 0 && provider.enabled) - .forEach((provider) => { - let key = provider.id; - let label = provider.name; - - if (providerMap.has(key)) { - key = `${provider.id}_api_key`; - label = `${provider.name} API Key`; - } - - if (providerMap.has(key)) { - return; - } - - providerMap.set(key, { - key, - label, - registryId: provider.id, - fallbackRegistryId: getRegistryIdFromType(provider.type), - type: provider.type, - providerId: provider.id, - customModels: provider.custom_models, - }); - }); - - return Array.from(providerMap.values()); -} - -function dedupeModels( - models: EnhancedModelMetadata[], -): EnhancedModelMetadata[] { - const seen = new Set(); - const result: EnhancedModelMetadata[] = []; - - models.forEach((model) => { - const normalized = model.id.trim().toLowerCase(); - if (!normalized || seen.has(normalized)) { - return; - } - seen.add(normalized); - result.push(model); - }); - - return result; -} - function getTrayPayloadSignature( providerType: string, model: string, @@ -292,55 +111,14 @@ function scheduleTrayModelSync(task: () => void): () => void { function resolveProviderModels( provider: ConfiguredProvider, - registryModels: EnhancedModelMetadata[], + registryModels: Parameters[1], aliasConfigs: Record, -): EnhancedModelMetadata[] { - const combined: EnhancedModelMetadata[] = []; - - if (provider.customModels?.length) { - combined.push( - ...convertCustomModelsToMetadata( - provider.customModels, - provider.key, - provider.label, - ), - ); - } - - if (isAliasProvider(provider.key)) { - const aliasConfig = aliasConfigs[getAliasConfigKey(provider.key)]; - if (aliasConfig) { - combined.push( - ...convertAliasModelsToMetadata( - aliasConfig.models, - aliasConfig, - provider.key, - provider.label, - ), - ); - } - return dedupeModels(combined); - } - - const registryMatches = sortModels( - registryModels.filter((item) => item.provider_id === provider.registryId), - ); - if (registryMatches.length > 0) { - combined.push(...registryMatches); - return dedupeModels(combined); - } - - if (provider.fallbackRegistryId) { - combined.push( - ...sortModels( - registryModels.filter( - (item) => item.provider_id === provider.fallbackRegistryId, - ), - ), - ); - } - - return dedupeModels(combined); +) { + return buildProviderModelsFromRegistry( + provider, + registryModels, + aliasConfigs[getAliasConfigKey(provider.key)] ?? null, + ).models; } function resolveThemeLabel(theme?: string): string { @@ -476,34 +254,23 @@ export async function buildTrayPayload( const payloadPromise = (async () => { const sourceOptions = forceRefresh ? { forceRefresh: true } : undefined; - const [oauthCredentials, apiKeyProviders, registryModels, aliasConfigs] = - await Promise.all([ - loadTraySource( - () => providerPoolApi.getOverview(sourceOptions), - [] as ProviderPoolOverview[], - "OAuth Provider 概览", - ), - loadTraySource( - () => apiKeyProviderApi.getProviders(sourceOptions), - [] as ProviderWithKeysDisplay[], - "API Key Provider 列表", - ), - loadTraySource( - () => modelRegistryApi.getModelRegistry(sourceOptions), - [] as EnhancedModelMetadata[], - "模型注册表", - ), - loadTraySource( - () => modelRegistryApi.getAllAliasConfigs(sourceOptions), - {} as Record, - "别名模型配置", - ), - ]); - - const providers = buildConfiguredProviders( - oauthCredentials, - apiKeyProviders, - ); + const [providers, registryModels, aliasConfigs] = await Promise.all([ + loadTraySource( + () => loadConfiguredProviders({ forceRefresh }), + [] as ConfiguredProvider[], + "已配置 Provider 列表", + ), + loadTraySource( + () => modelRegistryApi.getModelRegistry(sourceOptions), + [] as EnhancedModelMetadata[], + "模型注册表", + ), + loadTraySource( + () => modelRegistryApi.getAllAliasConfigs(sourceOptions), + {} as Record, + "别名模型配置", + ), + ]); const currentProvider = providers.find((item) => item.key === providerType) || null; @@ -585,6 +352,25 @@ export function useTrayModelShortcuts({ }: UseTrayModelShortcutsOptions) { const lastSyncedSignatureRef = useRef(""); const initialSyncHandledRef = useRef(false); + const latestSelectionRef = useRef({ + providerType, + model, + }); + const latestMutatorsRef = useRef({ + setProviderType, + setModel, + }); + + useEffect(() => { + latestSelectionRef.current = { + providerType, + model, + }; + latestMutatorsRef.current = { + setProviderType, + setModel, + }; + }, [model, providerType, setModel, setProviderType]); useEffect(() => { const normalizedProviderType = providerType.trim(); @@ -648,17 +434,22 @@ export function useTrayModelShortcuts({ const nextProviderType = event.payload?.providerType?.trim() || ""; const nextModel = event.payload?.model?.trim() || ""; + const currentSelection = latestSelectionRef.current; + const currentMutators = latestMutatorsRef.current; if (!nextModel) { return; } - if (nextProviderType && nextProviderType !== providerType) { - setProviderType(nextProviderType); + if ( + nextProviderType && + nextProviderType !== currentSelection.providerType + ) { + currentMutators.setProviderType(nextProviderType); } - if (nextModel !== model) { - setModel(nextModel); + if (nextModel !== currentSelection.model) { + currentMutators.setModel(nextModel); } }) .then((unlisten) => { @@ -678,5 +469,5 @@ export function useTrayModelShortcuts({ dispose(); } }; - }, [model, providerType, setModel, setProviderType]); + }, []); } diff --git a/src/components/agent/chat/hooks/useWorkspaceProjectSelection.test.tsx b/src/components/agent/chat/hooks/useWorkspaceProjectSelection.test.tsx new file mode 100644 index 000000000..e3f65ea35 --- /dev/null +++ b/src/components/agent/chat/hooks/useWorkspaceProjectSelection.test.tsx @@ -0,0 +1,166 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { LAST_PROJECT_ID_KEY } from "./agentProjectStorage"; +import { useWorkspaceProjectSelection } from "./useWorkspaceProjectSelection"; + +interface HookHarness { + getValue: () => ReturnType; + rerender: (props?: { + externalProjectId?: string | null; + newChatAt?: number; + }) => void; + unmount: () => void; +} + +function mountHook( + initialProps: { + externalProjectId?: string | null; + newChatAt?: number; + } = {}, +): HookHarness { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let hookValue: ReturnType | null = null; + + function TestComponent(props: { + externalProjectId?: string | null; + newChatAt?: number; + }) { + hookValue = useWorkspaceProjectSelection(props); + return null; + } + + const render = (props?: { + externalProjectId?: string | null; + newChatAt?: number; + }) => { + act(() => { + root.render(); + }); + }; + + render(initialProps); + + return { + getValue: () => { + if (!hookValue) { + throw new Error("hook 尚未初始化"); + } + return hookValue; + }, + rerender: render, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +describe("useWorkspaceProjectSelection", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("应在新会话请求未处理前屏蔽最近项目恢复", () => { + localStorage.setItem(LAST_PROJECT_ID_KEY, JSON.stringify("project-local")); + const harness = mountHook({ + newChatAt: 123, + }); + + try { + expect(harness.getValue().projectId).toBeUndefined(); + expect(harness.getValue().shouldDisableSessionRestore).toBe(true); + + act(() => { + harness.getValue().markNewChatRequestHandled("123"); + harness.getValue().applyProjectSelection(null); + }); + + expect(harness.getValue().projectId).toBeUndefined(); + } finally { + harness.unmount(); + } + }); + + it("应统一处理项目选择与最近项目记忆", () => { + const harness = mountHook(); + + try { + act(() => { + harness.getValue().applyProjectSelection("project-a"); + }); + + expect(harness.getValue().projectId).toBe("project-a"); + expect(harness.getValue().getRememberedProjectId()).toBe("project-a"); + + act(() => { + harness.getValue().resetProjectSelection(); + }); + + expect(harness.getValue().projectId).toBeUndefined(); + expect(harness.getValue().getRememberedProjectId()).toBe("project-a"); + } finally { + harness.unmount(); + } + }); + + it("应在项目切换完成后恢复待执行的任务切换", () => { + const harness = mountHook(); + + try { + expect(harness.getValue().startTopicProjectResolution()).toBe(true); + expect(harness.getValue().startTopicProjectResolution()).toBe(false); + + act(() => { + harness.getValue().deferTopicSwitch("topic-1", "project-target"); + }); + + expect(harness.getValue().projectId).toBe("project-target"); + expect( + harness.getValue().consumePendingTopicSwitch("project-other"), + ).toBeNull(); + expect(harness.getValue().consumePendingTopicSwitch("project-target")).toEqual({ + topicId: "topic-1", + targetProjectId: "project-target", + }); + + harness.getValue().finishTopicProjectResolution(); + expect(harness.getValue().startTopicProjectResolution()).toBe(true); + } finally { + harness.unmount(); + } + }); + + it("有外部项目锁定时不应覆盖当前项目选择", () => { + const harness = mountHook({ + externalProjectId: "project-external", + }); + + try { + expect(harness.getValue().projectId).toBe("project-external"); + + act(() => { + harness.getValue().applyProjectSelection("project-local"); + }); + + expect(harness.getValue().projectId).toBe("project-external"); + expect(harness.getValue().getRememberedProjectId()).toBeNull(); + } finally { + harness.unmount(); + } + }); +}); diff --git a/src/components/agent/chat/hooks/useWorkspaceProjectSelection.ts b/src/components/agent/chat/hooks/useWorkspaceProjectSelection.ts new file mode 100644 index 000000000..b838f2857 --- /dev/null +++ b/src/components/agent/chat/hooks/useWorkspaceProjectSelection.ts @@ -0,0 +1,163 @@ +import { useCallback, useRef, useState } from "react"; +import { normalizeProjectId } from "../utils/topicProjectResolution"; +import { + LAST_PROJECT_ID_KEY, + loadPersistedProjectId, + savePersistedProjectId, +} from "./agentProjectStorage"; + +interface PendingTopicSwitchState { + topicId: string; + targetProjectId: string; +} + +interface UseWorkspaceProjectSelectionOptions { + externalProjectId?: string | null; + newChatAt?: number; + storageKey?: string; +} + +export function useWorkspaceProjectSelection( + options: UseWorkspaceProjectSelectionOptions = {}, +) { + const { + externalProjectId, + newChatAt, + storageKey = LAST_PROJECT_ID_KEY, + } = options; + const normalizedExternalProjectId = normalizeProjectId(externalProjectId); + const [internalProjectId, setInternalProjectId] = useState( + null, + ); + const handledNewChatRequestRef = useRef(null); + const pendingTopicSwitchRef = useRef(null); + const isResolvingTopicProjectRef = useRef(false); + + const incomingNewChatRequestKey = + typeof newChatAt === "number" ? String(newChatAt) : null; + const shouldDisableSessionRestore = incomingNewChatRequestKey !== null; + const shouldResetToFreshHomeContext = + !normalizedExternalProjectId && + incomingNewChatRequestKey !== null && + handledNewChatRequestRef.current !== incomingNewChatRequestKey; + + const projectId = + normalizedExternalProjectId ?? + (shouldResetToFreshHomeContext ? undefined : internalProjectId) ?? + undefined; + + const hasHandledNewChatRequest = useCallback( + (requestKey: string) => handledNewChatRequestRef.current === requestKey, + [], + ); + + const markNewChatRequestHandled = useCallback((requestKey: string) => { + handledNewChatRequestRef.current = requestKey; + }, []); + + const clearProjectSelectionRuntime = useCallback(() => { + pendingTopicSwitchRef.current = null; + isResolvingTopicProjectRef.current = false; + }, []); + + const rememberProjectId = useCallback( + (nextProjectId?: string | null) => { + const normalizedProjectId = normalizeProjectId(nextProjectId); + if (!normalizedProjectId) { + return; + } + + savePersistedProjectId(storageKey, normalizedProjectId); + }, + [storageKey], + ); + + const resetProjectSelection = useCallback(() => { + clearProjectSelectionRuntime(); + setInternalProjectId(null); + }, [clearProjectSelectionRuntime]); + + const applyProjectSelection = useCallback( + (nextProjectId?: string | null) => { + if (normalizedExternalProjectId) { + return; + } + + const normalizedProjectId = normalizeProjectId(nextProjectId); + clearProjectSelectionRuntime(); + rememberProjectId(normalizedProjectId); + setInternalProjectId(normalizedProjectId); + }, + [clearProjectSelectionRuntime, normalizedExternalProjectId, rememberProjectId], + ); + + const startTopicProjectResolution = useCallback(() => { + if (isResolvingTopicProjectRef.current) { + return false; + } + + isResolvingTopicProjectRef.current = true; + return true; + }, []); + + const finishTopicProjectResolution = useCallback(() => { + isResolvingTopicProjectRef.current = false; + }, []); + + const deferTopicSwitch = useCallback( + (topicId: string, targetProjectId: string) => { + const normalizedTargetProjectId = normalizeProjectId(targetProjectId); + if (!normalizedTargetProjectId) { + pendingTopicSwitchRef.current = null; + return; + } + + rememberProjectId(normalizedTargetProjectId); + pendingTopicSwitchRef.current = { + topicId, + targetProjectId: normalizedTargetProjectId, + }; + setInternalProjectId(normalizedTargetProjectId); + }, + [rememberProjectId], + ); + + const consumePendingTopicSwitch = useCallback( + (currentProjectId?: string | null) => { + const pending = pendingTopicSwitchRef.current; + if (!pending) { + return null; + } + + const normalizedCurrentProjectId = normalizeProjectId(currentProjectId); + if (normalizedCurrentProjectId !== pending.targetProjectId) { + return null; + } + + pendingTopicSwitchRef.current = null; + return pending; + }, + [], + ); + + const getRememberedProjectId = useCallback( + () => loadPersistedProjectId(storageKey), + [storageKey], + ); + + return { + projectId, + shouldDisableSessionRestore, + hasHandledNewChatRequest, + markNewChatRequestHandled, + rememberProjectId, + getRememberedProjectId, + applyProjectSelection, + resetProjectSelection, + clearProjectSelectionRuntime, + startTopicProjectResolution, + finishTopicProjectResolution, + deferTopicSwitch, + consumePendingTopicSwitch, + }; +} diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 960a771ba..4ba3f4a9e 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -13,6 +13,7 @@ const { mockUseTopicBranchBoard, mockUseTeamWorkspaceRuntime, mockUseCompatSubagentRuntime, + mockGenerateEphemeralTeamWithModel, mockGetProject, mockGetDefaultProject, mockGetOrCreateDefaultProject, @@ -36,6 +37,8 @@ const { mockExecutionRunGetThemeWorkbenchState, mockExecutionRunListThemeWorkbenchHistory, mockExecutionRunGet, + mockContentWorkflowCreate, + mockContentWorkflowGetByContent, mockSkillExecutionGetDetail, mockSkillsGetAll, mockSkillsGetLocal, @@ -50,6 +53,7 @@ const { mockUseTopicBranchBoard: vi.fn(), mockUseTeamWorkspaceRuntime: vi.fn(), mockUseCompatSubagentRuntime: vi.fn(), + mockGenerateEphemeralTeamWithModel: vi.fn(), mockGetProject: vi.fn(), mockGetDefaultProject: vi.fn(), mockGetOrCreateDefaultProject: vi.fn(), @@ -79,15 +83,21 @@ const { mockEmptyState: vi.fn((props?: { input?: string }) => (
{props?.input || ""}
)), - mockInputbar: vi.fn((_props?: Record) => ( -
- )), + mockInputbar: vi.fn( + (props?: { overlayAccessory?: ReactNode; input?: string }) => ( +
+ {props?.overlayAccessory} +
+ ), + ), mockMessageList: vi.fn((_props?: Record) => (
)), mockExecutionRunGetThemeWorkbenchState: vi.fn(), mockExecutionRunListThemeWorkbenchHistory: vi.fn(), mockExecutionRunGet: vi.fn(), + mockContentWorkflowCreate: vi.fn(), + mockContentWorkflowGetByContent: vi.fn(), mockSkillExecutionGetDetail: vi.fn(), mockSkillsGetAll: vi.fn(), mockSkillsGetLocal: vi.fn(), @@ -153,6 +163,10 @@ vi.mock("./hooks", () => ({ useCompatSubagentRuntime: mockUseCompatSubagentRuntime, })); +vi.mock("./utils/teamAutoGeneration", () => ({ + generateEphemeralTeamWithModel: mockGenerateEphemeralTeamWithModel, +})); + vi.mock("./hooks/useSessionFiles", () => ({ useSessionFiles: () => ({ saveFile: vi.fn(async () => undefined), @@ -405,11 +419,18 @@ vi.mock("./components/TeamWorkspaceDock", () => ({ withBottomOverlay, shellVisible, childSubagentSessions, + runtimeTeamState, + onActivateWorkbench, }: { placement?: "floating" | "inline"; withBottomOverlay?: boolean; shellVisible?: boolean; childSubagentSessions?: Array<{ id: string }>; + runtimeTeamState?: { + status?: string; + members?: Array<{ id: string }>; + } | null; + onActivateWorkbench?: () => void; }) => (
({ data-with-bottom-overlay={withBottomOverlay ? "true" : "false"} data-shell-visible={shellVisible ? "true" : "false"} data-child-count={String(childSubagentSessions?.length ?? 0)} - /> + data-runtime-status={runtimeTeamState?.status || ""} + data-runtime-member-count={String(runtimeTeamState?.members?.length ?? 0)} + > + {onActivateWorkbench ? ( + + ) : null} +
), })); @@ -459,7 +494,19 @@ vi.mock("@/components/general-chat/bridge", () => ({ vi.mock("@/components/artifact", () => ({ ArtifactList: () =>
, ArtifactRenderer: () =>
, - ArtifactToolbar: () =>
, + ArtifactToolbar: ({ onClose }: { onClose?: () => void }) => ( +
+ +
+ ), })); vi.mock("@/lib/artifact/store", () => ({ @@ -558,6 +605,13 @@ vi.mock("@/lib/api/executionRun", () => ({ mockExecutionRunListThemeWorkbenchHistory, })); +vi.mock("@/lib/api/content-workflow", () => ({ + contentWorkflowApi: { + create: mockContentWorkflowCreate, + getByContent: mockContentWorkflowGetByContent, + }, +})); + vi.mock("@/lib/api/skill-execution", () => ({ skillExecutionApi: { getSkillDetail: mockSkillExecutionGetDetail, @@ -721,10 +775,10 @@ function createMockThemeContextWorkspaceState( async function flushEffects(times = 6) { for (let i = 0; i < times; i += 1) { - await act(async () => { - await Promise.resolve(); - await vi.dynamicImportSettled(); + await new Promise((resolve) => { + window.setTimeout(resolve, 20); }); + act(() => {}); } } @@ -832,7 +886,7 @@ function mockBrowserAssistCompletedSession() { ); } -beforeEach(() => { +beforeEach(async () => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; @@ -849,6 +903,17 @@ beforeEach(() => { ); vi.clearAllMocks(); + const originalConsoleError = console.error.bind(console); + vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + const message = String(args[0] ?? ""); + if ( + message.includes("not wrapped in act") || + message.includes("A suspended resource finished loading inside a test") + ) { + return; + } + originalConsoleError(...args); + }); localStorage.clear(); sessionStorage.clear(); observedWorkspaceIds.length = 0; @@ -888,6 +953,17 @@ beforeEach(() => { next_offset: null, }); mockExecutionRunGet.mockResolvedValue(null); + mockContentWorkflowCreate.mockResolvedValue({ + id: "wf-default", + content_id: "content-default", + theme: "social-media", + mode: "guided", + steps: [], + current_step_index: 0, + created_at: Date.now(), + updated_at: Date.now(), + }); + mockContentWorkflowGetByContent.mockResolvedValue(null); mockSkillExecutionGetDetail.mockResolvedValue({ name: "social_post_with_cover", display_name: "社媒主稿与封面", @@ -1063,6 +1139,8 @@ beforeEach(() => { }; }, ); + + await import("./AgentChatWorkspace"); }); afterEach(() => { @@ -1078,6 +1156,7 @@ afterEach(() => { } localStorage.clear(); sessionStorage.clear(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -1470,6 +1549,366 @@ describe("AgentChatPage 通用工作台", () => { ).not.toBeNull(); }); + it("Team 组建中与仅完成编队时,不应自动切到 Team 画布", async () => { + let resolveGeneratedTeam: + | ((value: { + id: string; + source: "ephemeral"; + label: string; + description: string; + roles: Array<{ + id: string; + label: string; + summary: string; + profileId: string; + roleKey: string; + skillIds: string[]; + }>; + }) => void) + | null = null; + + mockGenerateEphemeralTeamWithModel.mockImplementation( + () => + new Promise((resolve) => { + resolveGeneratedTeam = resolve; + }), + ); + + const mounted = mountPage({ + projectId: "project-team-runtime", + theme: "general", + lockTheme: true, + }); + await flushEffects(10); + + let latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { + onToolStatesChange?: ( + next: + | Record + | ((prev: Record) => Record), + ) => void; + } + | undefined; + + act(() => { + latestInputbarProps?.onToolStatesChange?.((previous) => ({ + ...previous, + subagent: true, + })); + }); + await flushEffects(8); + + latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { + onSend?: ( + images?: unknown[], + webSearch?: boolean, + thinking?: boolean, + textOverride?: string, + executionStrategy?: "react" | "code_orchestrated" | "auto", + ) => Promise; + } + | undefined; + + await act(async () => { + await latestInputbarProps?.onSend?.( + [], + false, + false, + "请帮我拆解并推进这个修复任务", + "auto", + ); + }); + await flushEffects(8); + + expect(mockGenerateEphemeralTeamWithModel).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: "project-team-runtime", + providerType: "kiro", + model: "mock-model", + input: "请帮我拆解并推进这个修复任务", + }), + ); + expect( + mounted.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat"); + expect( + mounted.container + .querySelector('[data-testid="team-workspace-dock"]') + ?.getAttribute("data-runtime-status"), + ).toBe("forming"); + + await act(async () => { + resolveGeneratedTeam?.({ + id: "ephemeral-team", + source: "ephemeral", + label: "修复 Team", + description: "分析、执行、验证协作闭环。", + roles: [ + { + id: "explorer", + label: "分析", + summary: "负责定位问题边界。", + profileId: "code-explorer", + roleKey: "explorer", + skillIds: ["repo-exploration"], + }, + { + id: "executor", + label: "执行", + summary: "负责落地修复方案。", + profileId: "code-executor", + roleKey: "executor", + skillIds: ["bounded-implementation"], + }, + ], + }); + await Promise.resolve(); + }); + await flushEffects(10); + + expect( + mounted.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat"); + expect( + mounted.container + .querySelector('[data-testid="team-workspace-dock"]') + ?.getAttribute("data-runtime-status"), + ).toBe("formed"); + expect( + mounted.container + .querySelector('[data-testid="team-workspace-dock"]') + ?.getAttribute("data-child-count"), + ).toBe("0"); + }); + + it("用户手动点击 Team 入口时,组建中的 Team 也应直接切到 Team 画布", async () => { + let resolveGeneratedTeam: + | ((value: { + id: string; + source: "ephemeral"; + label: string; + description: string; + roles: Array<{ + id: string; + label: string; + summary: string; + profileId: string; + roleKey: string; + skillIds: string[]; + }>; + }) => void) + | null = null; + + mockGenerateEphemeralTeamWithModel.mockImplementation( + () => + new Promise((resolve) => { + resolveGeneratedTeam = resolve; + }), + ); + + const mounted = mountPage({ + projectId: "project-team-manual-open", + theme: "general", + lockTheme: true, + }); + await flushEffects(10); + + let latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { + onToolStatesChange?: ( + next: + | Record + | ((prev: Record) => Record), + ) => void; + } + | undefined; + + act(() => { + latestInputbarProps?.onToolStatesChange?.((previous) => ({ + ...previous, + subagent: true, + })); + }); + await flushEffects(8); + + latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { + onSend?: ( + images?: unknown[], + webSearch?: boolean, + thinking?: boolean, + textOverride?: string, + executionStrategy?: "react" | "code_orchestrated" | "auto", + ) => Promise; + } + | undefined; + + await act(async () => { + await latestInputbarProps?.onSend?.( + [], + false, + false, + "请组织一个协作团队推进这项修复", + "auto", + ); + }); + await flushEffects(8); + + expect( + mounted.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat"); + expect( + mounted.container + .querySelector('[data-testid="team-workspace-dock"]') + ?.getAttribute("data-runtime-status"), + ).toBe("forming"); + + clickButton(mounted.container, "team-workspace-dock-activate"); + await flushEffects(8); + + expect( + mounted.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat-canvas"); + + await act(async () => { + resolveGeneratedTeam?.({ + id: "ephemeral-team-manual", + source: "ephemeral", + label: "修复 Team", + description: "分析、执行、验证协作闭环。", + roles: [ + { + id: "explorer", + label: "分析", + summary: "负责定位问题边界。", + profileId: "code-explorer", + roleKey: "explorer", + skillIds: ["repo-exploration"], + }, + { + id: "executor", + label: "执行", + summary: "负责落地修复方案。", + profileId: "code-executor", + roleKey: "executor", + skillIds: ["bounded-implementation"], + }, + ], + }); + await Promise.resolve(); + }); + await flushEffects(8); + }); + + it("同一会话首次出现真实 Team 成员时,应自动切到 Team 画布", async () => { + const runtimeState = { + childSubagentSessions: [] as Array<{ + id: string; + name: string; + created_at: number; + updated_at: number; + session_type: "sub_agent"; + runtime_status: "running"; + task_summary: string; + role_hint: string; + }>, + }; + + mockUseAgentChatUnified.mockImplementation( + ({ workspaceId }: { workspaceId: string }) => { + observedWorkspaceIds.push(workspaceId); + return { + providerType: "kiro", + setProviderType: vi.fn(), + model: "mock-model", + setModel: vi.fn(), + executionStrategy: "auto", + setExecutionStrategy: vi.fn(), + messages: [], + currentTurnId: null, + turns: [], + threadItems: [], + todoItems: [], + childSubagentSessions: runtimeState.childSubagentSessions, + subagentParentContext: null, + queuedTurns: [], + isSending: false, + sendMessage: sharedSendMessageMock, + stopSending: vi.fn(async () => undefined), + promoteQueuedTurn: vi.fn(async () => false), + removeQueuedTurn: vi.fn(async () => false), + clearMessages: vi.fn(), + deleteMessage: vi.fn(), + editMessage: vi.fn(), + handlePermissionResponse: vi.fn(), + pendingActions: [], + triggerAIGuide: sharedTriggerAIGuideMock, + topics: [ + { + id: "topic-a", + title: "话题 A", + updatedAt: Date.now(), + }, + ], + sessionId: "session-1", + createFreshSession: vi.fn(async () => undefined), + switchTopic: sharedSwitchTopicMock, + deleteTopic: vi.fn(), + renameTopic: vi.fn(), + updateTopicSnapshot: vi.fn(), + workspacePathMissing: false, + fixWorkspacePathAndRetry: vi.fn(), + dismissWorkspacePathError: vi.fn(), + }; + }, + ); + + const mounted = mountPage({ + projectId: "project-team-real-graph", + theme: "general", + lockTheme: true, + }); + await flushEffects(10); + + expect( + mounted.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat"); + + runtimeState.childSubagentSessions = [ + { + id: "child-1", + name: "分析成员", + created_at: 1_710_000_000, + updated_at: 1_710_000_100, + session_type: "sub_agent", + runtime_status: "running", + task_summary: "分析问题边界", + role_hint: "explorer", + }, + ]; + + mounted.rerender(); + await flushEffects(10); + + expect( + mounted.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat-canvas"); + }); + it("已安装 skills 但未显式激活时,通用工作台不应展示技能区块", async () => { mockSkillsGetAll.mockResolvedValue([ { @@ -1746,6 +2185,117 @@ describe("AgentChatPage 通用工作台", () => { ); }); + it("手动关闭浏览器协助画布后,同 scope 会话更新不应自动重新打开", async () => { + mockBrowserAssistCompletedSession(); + + const harness = mountPage({ + theme: "general", + lockTheme: true, + }); + await flushEffects(10); + + expect( + harness.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat-canvas"); + + clickButton(harness.container, "toggle-canvas"); + await flushEffects(6); + + expect( + harness.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat"); + + mockUseAgentChatUnified.mockImplementation( + ({ workspaceId }: { workspaceId: string }) => { + observedWorkspaceIds.push(workspaceId); + return { + providerType: "kiro", + setProviderType: vi.fn(), + model: "mock-model", + setModel: vi.fn(), + executionStrategy: "auto", + setExecutionStrategy: vi.fn(), + messages: [ + { + id: "msg-browser-user-refresh", + role: "user", + content: "继续浏览器流程", + timestamp: new Date("2026-03-14T03:00:00.000Z"), + }, + { + id: "msg-browser-assistant-refresh", + role: "assistant", + content: "", + timestamp: new Date("2026-03-14T03:00:01.000Z"), + toolCalls: [ + { + id: "tool-browser-open-refresh", + name: "mcp__lime-browser__browser_navigate", + arguments: JSON.stringify({ + url: "https://www.rokid.com/news", + profile_key: "general_browser_assist", + }), + status: "completed", + startTime: new Date("2026-03-14T03:00:01.100Z"), + endTime: new Date("2026-03-14T03:00:02.000Z"), + result: { + success: true, + output: "已刷新页面", + metadata: { + result: { + session_id: "browser-session-1", + profile_key: "general_browser_assist", + page_info: { + title: "Rokid News", + url: "https://www.rokid.com/news", + }, + }, + }, + }, + }, + ], + }, + ], + isSending: false, + sendMessage: sharedSendMessageMock, + stopSending: vi.fn(async () => undefined), + clearMessages: vi.fn(), + deleteMessage: vi.fn(), + editMessage: vi.fn(), + handlePermissionResponse: vi.fn(), + triggerAIGuide: sharedTriggerAIGuideMock, + topics: [ + { + id: "topic-a", + title: "话题 A", + updatedAt: Date.now(), + }, + ], + sessionId: "session-1", + switchTopic: sharedSwitchTopicMock, + deleteTopic: vi.fn(), + renameTopic: vi.fn(), + workspacePathMissing: false, + fixWorkspacePathAndRetry: vi.fn(), + dismissWorkspacePathError: vi.fn(), + }; + }, + ); + + harness.rerender({}); + await flushEffects(10); + + expect( + harness.container + .querySelector('[data-testid="layout-transition"]') + ?.getAttribute("data-mode"), + ).toBe("chat"); + }); + it("显式新 URL 的浏览器请求应复用现有会话并导航到新页面", async () => { mockBrowserAssistCompletedSession(); mockBrowserExecuteAction.mockResolvedValueOnce({ @@ -2137,12 +2687,17 @@ describe("AgentChatPage 自动引导", () => { expect(sharedTriggerAIGuideMock).toHaveBeenCalledWith(); }); - it("存在 initialUserPrompt 时应优先发送首条意图", async () => { + it("存在 initialUserPrompt 时应先预填并等待确认", async () => { mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); const onInitialUserPromptConsumed = vi.fn(); const initialUserPrompt = "请先帮我写一篇社媒文案提纲。"; - renderPage({ + const container = renderPage({ projectId: "project-social-intent", contentId: "content-social-intent", theme: "social-media", @@ -2152,8 +2707,20 @@ describe("AgentChatPage 自动引导", () => { }); await flushEffects(12); + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { input?: string } + | undefined; + expect(latestInputbarProps?.input || "").toBe(initialUserPrompt); + expect( + container.querySelector('[data-testid="theme-workbench-entry-prompt"]'), + ).not.toBeNull(); + + clickButton(container, "theme-workbench-entry-continue"); + await flushEffects(12); + expect(sharedSendMessageMock).toHaveBeenCalledWith( - initialUserPrompt, + `/social_post_with_cover ${initialUserPrompt}`, [], false, false, @@ -2193,6 +2760,11 @@ describe("AgentChatPage 自动引导", () => { }); await flushEffects(12); + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + const contextContainer = mountedRoots.at(-1)?.container as HTMLDivElement; + clickButton(contextContainer, "theme-workbench-entry-continue"); + await flushEffects(12); + expect(sharedSendMessageMock).toHaveBeenCalledWith( `/social_post_with_cover [生效上下文]\n1. [素材] 品牌手册\n\n${initialUserPrompt}`, [], @@ -2215,6 +2787,11 @@ describe("AgentChatPage 自动引导", () => { it("存在 initialUserPrompt 时应使用当前选中模型发送", async () => { mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); const selectedModel = "gemini-2.5-pro"; const onInitialUserPromptConsumed = vi.fn(); const initialUserPrompt = "请生成面向 CTO 的社媒提纲"; @@ -2263,8 +2840,13 @@ describe("AgentChatPage 自动引导", () => { }); await flushEffects(12); + const selectedModelContainer = mountedRoots.at(-1)?.container as HTMLDivElement; + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + clickButton(selectedModelContainer, "theme-workbench-entry-continue"); + await flushEffects(12); + expect(sharedSendMessageMock).toHaveBeenCalledWith( - initialUserPrompt, + `/social_post_with_cover ${initialUserPrompt}`, [], false, false, @@ -2283,6 +2865,84 @@ describe("AgentChatPage 自动引导", () => { expect(onInitialUserPromptConsumed).toHaveBeenCalledTimes(1); }); + it("检测到未完成工作流时应提示继续或重新开始", async () => { + mockIsContentCreationTheme.mockReturnValue(true); + mockUseThemeContextWorkspace.mockReturnValue( + createMockThemeContextWorkspaceState({ + enabled: true, + }), + ); + mockContentWorkflowGetByContent.mockResolvedValue({ + id: "wf-resume-1", + content_id: "content-social-resume", + theme: "social-media", + mode: "guided", + current_step_index: 1, + created_at: Date.now() - 10_000, + updated_at: Date.now(), + steps: [ + { + id: "clarify", + type: "clarify", + title: "明确选题", + description: "", + behavior: { + skippable: true, + redoable: true, + auto_advance: false, + }, + status: "completed", + }, + { + id: "write", + type: "write", + title: "撰写主稿", + description: "", + behavior: { + skippable: false, + redoable: true, + auto_advance: false, + }, + status: "pending", + }, + ], + }); + + const container = renderPage({ + projectId: "project-social-resume", + contentId: "content-social-resume", + theme: "social-media", + lockTheme: true, + }); + await flushEffects(14); + + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + expect(container.textContent).toContain("发现上次未完成任务"); + expect(container.textContent).toContain("撰写主稿"); + + clickButton(container, "theme-workbench-entry-continue"); + await flushEffects(12); + + expect(sharedSendMessageMock).toHaveBeenCalledWith( + "/social_post_with_cover 请基于当前文稿与已有上下文,继续推进上次未完成的任务。优先继续“撰写主稿”阶段,不要从头重复已经完成的内容。先简要确认当前进度,再继续执行。", + [], + false, + false, + false, + undefined, + expect.any(String), + undefined, + expect.objectContaining({ + requestMetadata: expect.objectContaining({ + harness: expect.objectContaining({ + theme: "social-media", + session_mode: "theme_workbench", + }), + }), + }), + ); + }); + it("首条意图被父层消费后,发送中仍应保留 bootstrap 预览,避免空白对话框", async () => { mockUseAgentChatUnified.mockImplementation( ({ workspaceId }: { workspaceId: string }) => { @@ -2473,7 +3133,7 @@ describe("AgentChatPage 自动引导", () => { expect(container.querySelector('[data-testid="inputbar"]')).not.toBeNull(); }); - it("主题工作台在初始意图稍后注入时应自动发送首条创作请求", async () => { + it("主题工作台在初始意图稍后注入时应改为预填并等待确认", async () => { mockIsContentCreationTheme.mockReturnValue(true); mockUseThemeContextWorkspace.mockReturnValue( createMockThemeContextWorkspaceState({ @@ -2511,6 +3171,17 @@ describe("AgentChatPage 自动引导", () => { }); await flushEffects(10); + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + const latestInputbarProps = mockInputbar.mock.calls.at(-1)?.[0] as + | { input?: string } + | undefined; + expect(latestInputbarProps?.input || "").toBe( + "请基于当前上下文直接开始生成首版社媒主稿。", + ); + + clickButton(mounted.container, "theme-workbench-entry-continue"); + await flushEffects(10); + expect(sharedSendMessageMock).toHaveBeenCalledWith( "/social_post_with_cover 请基于当前上下文直接开始生成首版社媒主稿。", [], @@ -3994,7 +4665,7 @@ describe("AgentChatPage 小说主题工作台", () => { expect(sharedSendMessageMock).not.toHaveBeenCalled(); }); - it("小说主题工作台带初始意图时仍应自动发送首条请求", async () => { + it("小说主题工作台带初始意图时应改为预填并等待确认", async () => { mockIsContentCreationTheme.mockReturnValue(true); mockUseThemeContextWorkspace.mockReturnValue( createMockThemeContextWorkspaceState({ @@ -4015,6 +4686,11 @@ describe("AgentChatPage 小说主题工作台", () => { }); await flushEffects(12); + const container = mountedRoots.at(-1)?.container as HTMLDivElement; + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + clickButton(container, "theme-workbench-entry-continue"); + await flushEffects(12); + expect(sharedSendMessageMock).toHaveBeenCalledWith( initialUserPrompt, [], diff --git a/src/components/agent/chat/teamWorkspaceRuntime.test.ts b/src/components/agent/chat/teamWorkspaceRuntime.test.ts new file mode 100644 index 000000000..5dbf9a869 --- /dev/null +++ b/src/components/agent/chat/teamWorkspaceRuntime.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { summarizeTeamWorkspaceExecution } from "./teamWorkspaceRuntime"; + +describe("summarizeTeamWorkspaceExecution", () => { + it("主线程流结束后,只要 live team 仍在运行就应保持活跃", () => { + const summary = summarizeTeamWorkspaceExecution({ + currentSessionId: "parent-1", + currentSessionRuntimeStatus: "completed", + childSubagentSessions: [ + { + id: "child-1", + name: "研究员", + created_at: 1, + updated_at: 2, + session_type: "sub_agent", + runtime_status: "completed", + latest_turn_status: "completed", + }, + ], + liveRuntimeBySessionId: { + "child-1": { + runtimeStatus: "running", + latestTurnStatus: "running", + baseFingerprint: "child-1:2:completed:completed:0", + }, + }, + }); + + expect(summary.hasActiveRuntime).toBe(true); + expect(summary.runningSessionCount).toBe(1); + expect(summary.statusTitle).toContain("Team 运行中"); + }); + + it("某个子代理失败但仍有其他子代理运行时,不应提前收敛", () => { + const summary = summarizeTeamWorkspaceExecution({ + childSubagentSessions: [ + { + id: "child-1", + name: "研究员", + created_at: 1, + updated_at: 2, + session_type: "sub_agent", + runtime_status: "failed", + latest_turn_status: "failed", + }, + { + id: "child-2", + name: "执行者", + created_at: 3, + updated_at: 4, + session_type: "sub_agent", + runtime_status: "running", + latest_turn_status: "running", + }, + ], + }); + + expect(summary.hasActiveRuntime).toBe(true); + expect(summary.activeSessionCount).toBe(1); + expect(summary.runningSessionCount).toBe(1); + expect(summary.statusTitle).toContain("Team 运行中"); + }); + + it("所有 team 会话进入终态后,应返回非活跃状态", () => { + const summary = summarizeTeamWorkspaceExecution({ + childSubagentSessions: [ + { + id: "child-1", + name: "研究员", + created_at: 1, + updated_at: 2, + session_type: "sub_agent", + runtime_status: "completed", + latest_turn_status: "completed", + }, + { + id: "child-2", + name: "执行者", + created_at: 3, + updated_at: 4, + session_type: "sub_agent", + runtime_status: "failed", + latest_turn_status: "failed", + }, + ], + }); + + expect(summary.hasActiveRuntime).toBe(false); + expect(summary.activeSessionCount).toBe(0); + expect(summary.statusTitle).toBeNull(); + }); +}); diff --git a/src/components/agent/chat/teamWorkspaceRuntime.ts b/src/components/agent/chat/teamWorkspaceRuntime.ts index 52b225bbf..d99d035ac 100644 --- a/src/components/agent/chat/teamWorkspaceRuntime.ts +++ b/src/components/agent/chat/teamWorkspaceRuntime.ts @@ -1,5 +1,13 @@ import type { AgentThreadItem } from "@/lib/api/agentStream"; -import type { AsterSubagentSessionInfo } from "@/lib/api/agentRuntime"; +import type { + AsterSubagentParentContext, + AsterSubagentSessionInfo, +} from "@/lib/api/agentRuntime"; +import { + buildTeamDefinitionSummary, + type TeamDefinition, + type TeamRoleDefinition, +} from "./utils/teamDefinitions"; export type TeamWorkspaceRuntimeStatus = AsterSubagentSessionInfo["runtime_status"]; @@ -18,6 +26,7 @@ export interface TeamWorkspaceActivityEntry { export interface TeamWorkspaceLiveRuntimeState { runtimeStatus: TeamWorkspaceRuntimeStatus; latestTurnStatus: TeamWorkspaceRuntimeStatus; + queuedTurnCount?: number; baseFingerprint: string; } @@ -33,6 +42,16 @@ export interface TeamWorkspaceRuntimeCard { id: string; runtimeStatus?: TeamWorkspaceRuntimeStatus; latestTurnStatus?: TeamWorkspaceRuntimeStatus; + queuedTurnCount?: number; +} + +export interface TeamWorkspaceExecutionSummary { + totalSessionCount: number; + runningSessionCount: number; + queuedSessionCount: number; + activeSessionCount: number; + hasActiveRuntime: boolean; + statusTitle: string | null; } export interface TeamWorkspaceWaitSummary { @@ -51,6 +70,106 @@ export interface TeamWorkspaceControlSummary { updatedAt: number; } +export type TeamWorkspaceRuntimeFormationStatus = + | "forming" + | "formed" + | "failed"; + +export type TeamWorkspaceRuntimeMemberStatus = + | "planned" + | "spawning" + | "running" + | "waiting" + | "completed" + | "failed"; + +export interface TeamWorkspaceBlueprintSnapshot { + label?: string | null; + summary?: string | null; + roles: TeamRoleDefinition[]; +} + +export interface TeamWorkspaceRuntimeMember { + id: string; + label: string; + summary: string; + profileId?: string; + roleKey?: string; + skillIds: string[]; + status: TeamWorkspaceRuntimeMemberStatus; + sessionId?: string; + latestSnippet?: string | null; +} + +export interface TeamWorkspaceRuntimeFormationState { + requestId: string; + status: TeamWorkspaceRuntimeFormationStatus; + label?: string | null; + summary?: string | null; + members: TeamWorkspaceRuntimeMember[]; + blueprint?: TeamWorkspaceBlueprintSnapshot | null; + errorMessage?: string | null; + updatedAt: number; +} + +const FORMATION_STATUS_META = { + forming: { + label: "组建中", + title: "正在准备本轮 Team", + badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700", + }, + formed: { + label: "已形成", + title: "本轮 Team 已就绪", + badgeClassName: "border border-emerald-200 bg-emerald-50 text-emerald-700", + }, + failed: { + label: "失败", + title: "Team 生成失败", + badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700", + }, +} satisfies Record< + TeamWorkspaceRuntimeFormationStatus, + { + label: string; + title: string; + badgeClassName: string; + } +>; + +const MEMBER_STATUS_META = { + planned: { + label: "计划中", + badgeClassName: "border border-slate-200 bg-slate-50 text-slate-600", + }, + spawning: { + label: "拉起中", + badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700", + }, + running: { + label: "运行中", + badgeClassName: "border border-sky-200 bg-sky-50 text-sky-700", + }, + waiting: { + label: "等待中", + badgeClassName: "border border-amber-200 bg-amber-50 text-amber-700", + }, + completed: { + label: "已完成", + badgeClassName: "border border-emerald-200 bg-emerald-50 text-emerald-700", + }, + failed: { + label: "失败", + badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700", + }, +} satisfies Record< + TeamWorkspaceRuntimeMemberStatus, + { + label: string; + badgeClassName: string; + } +>; + const STATUS_META = { idle: { label: "待开始", @@ -77,7 +196,7 @@ const STATUS_META = { badgeClassName: "border border-rose-200 bg-rose-50 text-rose-700", }, closed: { - label: "已关闭", + label: "已停止", badgeClassName: "border border-slate-200 bg-slate-100 text-slate-600", }, } satisfies Record< @@ -145,7 +264,8 @@ function resolveActivityEntryStatusMeta( case "completed": return { label: "完成", - badgeClassName: "border border-emerald-200 bg-emerald-50 text-emerald-700", + badgeClassName: + "border border-emerald-200 bg-emerald-50 text-emerald-700", }; default: return { @@ -198,7 +318,8 @@ function resolveItemActivityDescriptor(item: AgentThreadItem): { return { title: item.output ? "检索结果" : "检索查询", detail: - normalizeActivityText(item.output) || normalizeActivityText(item.query), + normalizeActivityText(item.output) || + normalizeActivityText(item.query), }; case "warning": return { @@ -237,7 +358,7 @@ export function resolveTeamWorkspaceRuntimeStatusLabel( case "aborted": return "已中止"; case "closed": - return "已关闭"; + return "已停止"; case "not_found": return "未找到"; default: @@ -263,6 +384,147 @@ export function normalizeTeamWorkspaceRuntimeStatus( return status === "not_found" ? "closed" : status; } +export function isTeamWorkspaceActiveStatus( + status?: TeamWorkspaceResolvedRuntimeStatus, +): boolean { + return status === "running" || status === "queued"; +} + +function resolveExecutionSummaryStatusTitle(params: { + totalSessionCount: number; + runningSessionCount: number; + queuedSessionCount: number; +}) { + const { totalSessionCount, runningSessionCount, queuedSessionCount } = params; + if (runningSessionCount > 0) { + if (queuedSessionCount > 0) { + return totalSessionCount > 1 + ? `Team 运行中 · ${runningSessionCount} 运行 / ${queuedSessionCount} 排队` + : "Team 运行中"; + } + return totalSessionCount > 1 + ? `Team 运行中 · ${runningSessionCount}/${totalSessionCount}` + : "Team 运行中"; + } + + if (queuedSessionCount > 0) { + return totalSessionCount > 1 + ? `Team 排队中 · ${queuedSessionCount}/${totalSessionCount}` + : "Team 排队中"; + } + + return null; +} + +function buildExecutionSummarySnapshots(params: { + currentSessionId?: string | null; + currentSessionRuntimeStatus?: TeamWorkspaceRuntimeStatus; + currentSessionLatestTurnStatus?: TeamWorkspaceRuntimeStatus; + currentSessionQueuedTurnCount?: number; + childSubagentSessions?: AsterSubagentSessionInfo[]; + subagentParentContext?: AsterSubagentParentContext | null; +}) { + const snapshots = new Map(); + const currentSessionId = params.currentSessionId?.trim(); + + if (params.subagentParentContext && currentSessionId) { + snapshots.set(currentSessionId, { + id: currentSessionId, + runtimeStatus: params.currentSessionRuntimeStatus, + latestTurnStatus: params.currentSessionLatestTurnStatus, + queuedTurnCount: params.currentSessionQueuedTurnCount, + }); + } + + const relatedSessions = params.subagentParentContext + ? (params.subagentParentContext.sibling_subagent_sessions ?? []) + : (params.childSubagentSessions ?? []); + + relatedSessions.forEach((session) => { + if (snapshots.has(session.id)) { + return; + } + snapshots.set(session.id, { + id: session.id, + runtimeStatus: session.runtime_status, + latestTurnStatus: session.latest_turn_status, + queuedTurnCount: session.queued_turn_count, + updatedAt: session.updated_at, + }); + }); + + return Array.from(snapshots.values()); +} + +function resolveExecutionSummarySessionStatus(params: { + session: TeamWorkspaceRuntimeSessionSnapshot; + liveState?: TeamWorkspaceLiveRuntimeState; +}): TeamWorkspaceRuntimeStatus | undefined { + if (params.liveState?.runtimeStatus) { + return params.liveState.runtimeStatus; + } + + if (params.session.runtimeStatus) { + return params.session.runtimeStatus; + } + + if (params.session.latestTurnStatus) { + return params.session.latestTurnStatus; + } + + if ((params.session.queuedTurnCount ?? 0) > 0) { + return "queued"; + } + + return undefined; +} + +export function summarizeTeamWorkspaceExecution(params: { + currentSessionId?: string | null; + currentSessionRuntimeStatus?: TeamWorkspaceRuntimeStatus; + currentSessionLatestTurnStatus?: TeamWorkspaceRuntimeStatus; + currentSessionQueuedTurnCount?: number; + childSubagentSessions?: AsterSubagentSessionInfo[]; + subagentParentContext?: AsterSubagentParentContext | null; + liveRuntimeBySessionId?: Record; +}): TeamWorkspaceExecutionSummary { + const snapshots = buildExecutionSummarySnapshots(params); + let runningSessionCount = 0; + let queuedSessionCount = 0; + + snapshots.forEach((session) => { + const status = resolveExecutionSummarySessionStatus({ + session, + liveState: params.liveRuntimeBySessionId?.[session.id], + }); + + if (status === "running") { + runningSessionCount += 1; + return; + } + + if (status === "queued") { + queuedSessionCount += 1; + } + }); + + const activeSessionCount = runningSessionCount + queuedSessionCount; + const totalSessionCount = snapshots.length; + + return { + totalSessionCount, + runningSessionCount, + queuedSessionCount, + activeSessionCount, + hasActiveRuntime: activeSessionCount > 0, + statusTitle: resolveExecutionSummaryStatusTitle({ + totalSessionCount, + runningSessionCount, + queuedSessionCount, + }), + }; +} + export function buildTeamWorkspaceSessionFingerprint( session?: TeamWorkspaceRuntimeSessionSnapshot | null, ) { @@ -347,5 +609,67 @@ export function applyLiveRuntimeState( ...session, runtimeStatus: liveState.runtimeStatus, latestTurnStatus: liveState.latestTurnStatus, + queuedTurnCount: liveState.queuedTurnCount ?? session.queuedTurnCount, }; } + +export function createTeamWorkspaceBlueprintSnapshot( + team?: TeamDefinition | null, +): TeamWorkspaceBlueprintSnapshot | null { + if (!team) { + return null; + } + + return { + label: team.label?.trim() || null, + summary: buildTeamDefinitionSummary(team) || null, + roles: team.roles.map((role) => ({ + ...role, + skillIds: role.skillIds ? [...role.skillIds] : [], + })), + }; +} + +export function createRuntimeFormationStateFromTeam(params: { + requestId: string; + status: TeamWorkspaceRuntimeFormationStatus; + runtimeTeam?: TeamDefinition | null; + blueprintTeam?: TeamDefinition | null; + errorMessage?: string | null; + updatedAt?: number; +}): TeamWorkspaceRuntimeFormationState { + const runtimeTeam = params.runtimeTeam || null; + + return { + requestId: params.requestId, + status: params.status, + label: runtimeTeam?.label?.trim() || null, + summary: runtimeTeam ? buildTeamDefinitionSummary(runtimeTeam) || null : null, + members: (runtimeTeam?.roles ?? []).map((role, index) => ({ + id: role.id?.trim() || `runtime-member-${index + 1}`, + label: role.label?.trim() || `角色 ${index + 1}`, + summary: role.summary?.trim() || `${role.label || `角色 ${index + 1}`}负责当前子任务。`, + profileId: role.profileId?.trim() || undefined, + roleKey: role.roleKey?.trim() || undefined, + skillIds: role.skillIds ? [...role.skillIds] : [], + status: "planned", + sessionId: undefined, + latestSnippet: null, + })), + blueprint: createTeamWorkspaceBlueprintSnapshot(params.blueprintTeam), + errorMessage: params.errorMessage?.trim() || null, + updatedAt: params.updatedAt ?? Date.now(), + }; +} + +export function resolveRuntimeFormationStatusMeta( + status: TeamWorkspaceRuntimeFormationStatus, +) { + return FORMATION_STATUS_META[status]; +} + +export function resolveRuntimeMemberStatusMeta( + status: TeamWorkspaceRuntimeMemberStatus, +) { + return MEMBER_STATUS_META[status]; +} diff --git a/src/components/agent/chat/utils/agentThreadGrouping.ts b/src/components/agent/chat/utils/agentThreadGrouping.ts index 6ce8e8f20..5e35e6187 100644 --- a/src/components/agent/chat/utils/agentThreadGrouping.ts +++ b/src/components/agent/chat/utils/agentThreadGrouping.ts @@ -1,5 +1,6 @@ import { parseAIResponse } from "@/components/content-creator/a2ui/parser"; import type { AgentThreadItem, AgentThreadItemStatus } from "../types"; +import { resolveInternalImageTaskDisplayName } from "./internalImagePlaceholder"; export type AgentThreadGroupKind = | "thinking" @@ -445,7 +446,11 @@ function summarizeSubagentItem(item: AgentThreadItem): string | null { if (item.type !== "subagent_activity") { return null; } - return shortenText(item.title || item.summary || item.status_label); + return shortenText( + resolveInternalImageTaskDisplayName(item.title) || + item.summary || + item.status_label, + ); } function summarizeAlertItem(item: AgentThreadItem): string | null { diff --git a/src/components/agent/chat/utils/clawWorkspaceProviderSelection.ts b/src/components/agent/chat/utils/clawWorkspaceProviderSelection.ts new file mode 100644 index 000000000..c5c16ca1f --- /dev/null +++ b/src/components/agent/chat/utils/clawWorkspaceProviderSelection.ts @@ -0,0 +1,128 @@ +import { + loadConfiguredProviders, + type ConfiguredProvider, +} from "@/hooks/useConfiguredProviders"; +import { loadProviderModels } from "@/hooks/useProviderModels"; +import { type EnhancedModelMetadata } from "@/lib/types/modelRegistry"; +import { filterModelsByTheme } from "./modelThemePolicy"; +import { resolveProviderModelCompatibility } from "./providerModelCompatibility"; + +export interface ResolveClawWorkspaceProviderSelectionInput { + currentProviderType?: string | null; + currentModel?: string | null; + theme?: string; +} + +export interface ClawWorkspaceProviderSelection { + providerType: string; + model: string; +} + +function normalizeValue(value?: string | null): string { + return (value || "").trim().toLowerCase(); +} + +function resolveExactModelId( + models: EnhancedModelMetadata[], + targetModelId?: string | null, +): string | null { + const normalizedTarget = normalizeValue(targetModelId); + if (!normalizedTarget) { + return null; + } + + const matchedModel = models.find( + (model) => normalizeValue(model.id) === normalizedTarget, + ); + return matchedModel?.id ?? null; +} + +function resolvePreferredModelId( + provider: ConfiguredProvider, + models: EnhancedModelMetadata[], + currentModel?: string | null, + theme?: string, +): string | null { + if (models.length === 0) { + return null; + } + + const themedModels = filterModelsByTheme(theme, models).models; + const candidateModels = themedModels.length > 0 ? themedModels : models; + + if (candidateModels.length === 0) { + return null; + } + + const resolveCompatibleModelId = (modelId?: string | null): string | null => { + const exactModelId = resolveExactModelId(candidateModels, modelId); + const compatibilityResult = resolveProviderModelCompatibility({ + providerType: provider.key, + configuredProviderType: provider.type, + model: exactModelId ?? modelId ?? "", + }); + + return ( + resolveExactModelId(candidateModels, compatibilityResult.model) ?? + exactModelId + ); + }; + + const retainedCurrentModel = resolveCompatibleModelId(currentModel); + if (retainedCurrentModel) { + return retainedCurrentModel; + } + + for (const candidateModel of candidateModels) { + const compatibleModelId = resolveCompatibleModelId(candidateModel.id); + if (compatibleModelId) { + return compatibleModelId; + } + } + + return candidateModels[0]?.id ?? null; +} + +export async function resolveClawWorkspaceProviderSelection( + input: ResolveClawWorkspaceProviderSelectionInput, +): Promise { + const { currentProviderType, currentModel, theme } = input; + const configuredProviders = await loadConfiguredProviders(); + + if (configuredProviders.length === 0) { + return null; + } + + const currentProvider = configuredProviders.find( + (provider) => provider.key === currentProviderType, + ); + const orderedProviders = currentProvider + ? [ + currentProvider, + ...configuredProviders.filter( + (provider) => provider.key !== currentProvider.key, + ), + ] + : configuredProviders; + + for (const provider of orderedProviders) { + const providerModels = await loadProviderModels(provider); + const preferredModel = resolvePreferredModelId( + provider, + providerModels, + provider.key === currentProvider?.key ? currentModel : null, + theme, + ); + + if (!preferredModel) { + continue; + } + + return { + providerType: provider.key, + model: preferredModel, + }; + } + + return null; +} diff --git a/src/components/agent/chat/utils/harnessRequestMetadata.test.ts b/src/components/agent/chat/utils/harnessRequestMetadata.test.ts index 595bb5bd6..1698c1586 100644 --- a/src/components/agent/chat/utils/harnessRequestMetadata.test.ts +++ b/src/components/agent/chat/utils/harnessRequestMetadata.test.ts @@ -57,6 +57,7 @@ describe("harnessRequestMetadata", () => { expect.objectContaining({ id: "explorer", label: "分析", + role_key: undefined, }), expect.objectContaining({ id: "executor", @@ -86,6 +87,40 @@ describe("harnessRequestMetadata", () => { expect(metadata.gate_key).toBeUndefined(); }); + it("应保留 Team 角色的 profileId、roleKey 与 skillIds", () => { + const metadata = buildHarnessRequestMetadata({ + theme: "general", + creationMode: "guided", + chatMode: "agent", + webSearchEnabled: false, + thinkingEnabled: true, + taskModeEnabled: true, + subagentModeEnabled: true, + sessionMode: "default", + selectedTeamRoles: [ + { + id: "explorer", + label: "分析", + summary: "负责定位问题。", + profileId: "code-explorer", + roleKey: "explorer", + skillIds: ["repo-exploration", "source-grounding"], + }, + ], + }); + + expect(metadata.selected_team_roles).toEqual([ + { + id: "explorer", + label: "分析", + summary: "负责定位问题。", + profile_id: "code-explorer", + role_key: "explorer", + skill_ids: ["repo-exploration", "source-grounding"], + }, + ]); + }); + it("需要人工确认的浏览器任务应标记 user step", () => { const metadata = buildHarnessRequestMetadata({ theme: "general", diff --git a/src/components/agent/chat/utils/internalImagePlaceholder.ts b/src/components/agent/chat/utils/internalImagePlaceholder.ts new file mode 100644 index 000000000..116a552a5 --- /dev/null +++ b/src/components/agent/chat/utils/internalImagePlaceholder.ts @@ -0,0 +1,140 @@ +import type { ContentPart, Message } from "../types"; + +const BRACKET_IMAGE_PLACEHOLDER_RE = /\[\s*Image\s*#\d+\s*\]/gi; +const BARE_IMAGE_PLACEHOLDER_RE = /(^|[\s,,;;])Image\s*#\d+(?=$|[\s,,;;])/gi; +const ONLY_IMAGE_PLACEHOLDERS_RE = + /^\s*(?:(?:\[\s*Image\s*#\d+\s*\]|Image\s*#\d+)\s*[,,;;]?\s*)+$/i; +const BRACKET_IMAGE_PLACEHOLDER_TEST_RE = /\[\s*Image\s*#\d+\s*\]/i; +const BARE_IMAGE_PLACEHOLDER_TEST_RE = + /(^|[\s,,;;])Image\s*#\d+(?=$|[\s,,;;])/i; +const EXACT_IMAGE_TASK_LABEL_RE = /^\[?\s*Image\s*#(\d+)\s*\]?$/i; + +function collapseDisplayWhitespace(value: string): string { + return value + .replace(/\s+([,。!?、;:,.!?;:])/g, "$1") + .replace(/([(【《“‘([<])\s+/g, "$1") + .replace(/\s+([)】》”’)\]>])/g, "$1") + .replace(/([\u4e00-\u9fff])\s+([\u4e00-\u9fff])/g, "$1$2") + .replace(/\s+/g, " ") + .trim(); +} + +function replaceImagePlaceholders(text: string, replacement: string): string { + const withBracketPlaceholders = text.replace( + BRACKET_IMAGE_PLACEHOLDER_RE, + ` ${replacement} `, + ); + return withBracketPlaceholders.replace( + BARE_IMAGE_PLACEHOLDER_RE, + (_match, prefix: string) => `${prefix}${replacement}`, + ); +} + +export function containsInternalImagePlaceholder(text: string): boolean { + return ( + BRACKET_IMAGE_PLACEHOLDER_TEST_RE.test(text) || + BARE_IMAGE_PLACEHOLDER_TEST_RE.test(text) + ); +} + +export function isOnlyInternalImagePlaceholderText(text: string): boolean { + return ONLY_IMAGE_PLACEHOLDERS_RE.test(text.trim()); +} + +export function resolveInternalImageTaskDisplayName( + value: string | null | undefined, +): string | null { + const normalized = value?.trim(); + if (!normalized) { + return null; + } + + const match = normalized.match(EXACT_IMAGE_TASK_LABEL_RE); + if (!match) { + return normalized; + } + + const index = match[1]?.trim(); + return index ? `图片任务 ${index}` : "图片任务"; +} + +interface SanitizeMessageTextOptions { + role: Message["role"]; + hasImages?: boolean; +} + +export function sanitizeMessageTextForDisplay( + text: string, + options: SanitizeMessageTextOptions, +): string { + const normalized = text.trim(); + if (!normalized) { + return ""; + } + + if (!containsInternalImagePlaceholder(normalized)) { + return normalized; + } + + if ( + isOnlyInternalImagePlaceholderText(normalized) && + ((options.role === "user" && options.hasImages) || + options.role === "assistant") + ) { + return ""; + } + + return collapseDisplayWhitespace(replaceImagePlaceholders(normalized, "图片")); +} + +export function sanitizeMessageTextForPreview( + text: string, + options: SanitizeMessageTextOptions, +): string { + const sanitized = sanitizeMessageTextForDisplay(text, options); + if (sanitized) { + return sanitized; + } + + if (options.role === "user" && options.hasImages) { + return "已附加图片"; + } + + if ( + options.role === "assistant" && + isOnlyInternalImagePlaceholderText(text) + ) { + return "图片处理中"; + } + + return ""; +} + +export function sanitizeContentPartsForDisplay( + parts: ContentPart[] | undefined, + options: SanitizeMessageTextOptions, +): ContentPart[] | undefined { + if (!parts || parts.length === 0) { + return parts; + } + + const sanitizedParts = parts.flatMap((part) => { + if (part.type !== "text") { + return [part]; + } + + const sanitizedText = sanitizeMessageTextForDisplay(part.text, options); + if (!sanitizedText) { + return []; + } + + return [ + { + ...part, + text: sanitizedText, + }, + ]; + }); + + return sanitizedParts.length > 0 ? sanitizedParts : undefined; +} diff --git a/src/components/agent/chat/utils/teamAutoGeneration.ts b/src/components/agent/chat/utils/teamAutoGeneration.ts new file mode 100644 index 000000000..3f7a122ad --- /dev/null +++ b/src/components/agent/chat/utils/teamAutoGeneration.ts @@ -0,0 +1,305 @@ +import { safeListen } from "@/lib/dev-bridge"; +import { + createAgentRuntimeSession, + deleteAgentRuntimeSession, + getAgentRuntimeSession, + initAsterAgent, + submitAgentRuntimeTurn, + type AsterExecutionStrategy, +} from "@/lib/api/agentRuntime"; +import { parseStreamEvent } from "@/lib/api/agentStream"; +import { mapProviderName } from "../hooks/agentChatCoreUtils"; +import { + createTeamDefinitionId, + normalizeTeamDefinition, + type TeamDefinition, +} from "./teamDefinitions"; + +interface GenerateTeamWithModelOptions { + workspaceId: string; + providerType: string; + model: string; + executionStrategy?: AsterExecutionStrategy; + activeTheme?: string; + input: string; + blueprintTeam?: TeamDefinition | null; + timeoutMs?: number; +} + +interface GeneratedTeamPayload { + label?: string; + description?: string; + roles?: Array<{ + id?: string; + label?: string; + summary?: string; + profileId?: string; + roleKey?: string; + skillIds?: string[]; + }>; + team?: GeneratedTeamPayload; +} + +function buildTeamGenerationPrompt( + input: string, + activeTheme?: string, + blueprintTeam?: TeamDefinition | null, +): string { + const blueprintPayload = blueprintTeam + ? { + label: blueprintTeam.label, + description: blueprintTeam.description, + roles: blueprintTeam.roles.map((role) => ({ + id: role.id, + label: role.label, + summary: role.summary, + profileId: role.profileId, + roleKey: role.roleKey, + skillIds: role.skillIds ?? [], + })), + } + : null; + + return [ + "请根据下面的任务,为 GUI Team 模式生成一个“本轮 Team 配置”。", + "要求:", + "1. 仅输出 JSON,不要 Markdown,不要代码块,不要额外解释。", + "2. 生成 2~4 个角色,角色名称与职责用中文。", + "3. roleKey 优先从以下集合中选择:explorer、executor、verifier、researcher、planner、reviewer、writer。", + "4. profileId 仅可使用以下值:code-explorer、code-executor、code-verifier、research-analyst、doc-writer、content-ideator、content-reviewer。", + "5. skillIds 仅可使用以下值:repo-exploration、bounded-implementation、verification-report、source-grounding、structured-writing。", + "6. 输出结构必须满足:", + JSON.stringify( + { + label: "本轮 Team 名称", + description: "一句话描述适用场景", + roles: [ + { + id: "role-id", + label: "角色名称", + summary: "角色职责", + profileId: "code-explorer", + roleKey: "explorer", + skillIds: ["repo-exploration", "source-grounding"], + }, + ], + }, + null, + 2, + ), + `当前主题:${activeTheme?.trim() || "general"}`, + blueprintPayload + ? "7. 如果下面提供了“参考蓝图 Team”,请把它视为偏好与约束来源;你可以按当前任务动态调整角色结构,但不要完全脱离参考蓝图。" + : "7. 如果没有参考蓝图,请直接围绕当前任务组织最合适的本轮 Team。", + blueprintPayload + ? `参考蓝图 Team:${JSON.stringify(blueprintPayload, null, 2)}` + : "参考蓝图 Team:无", + "如果任务本身不复杂,也仍然请输出一个最轻量可用的两角色 Team。", + `任务描述:${input.trim()}`, + ].join("\n"); +} + +function extractAssistantText(detail: Awaited>): string { + const assistantMessages = [...detail.messages] + .filter((message) => message.role === "assistant") + .sort((left, right) => right.timestamp - left.timestamp); + + for (const message of assistantMessages) { + const text = message.content + .map((item) => item.text || item.output || item.error || "") + .join("\n") + .trim(); + if (text) { + return text; + } + } + + return ""; +} + +function extractJsonObject(raw: string): string { + const trimmed = raw.trim(); + const codeFenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = codeFenceMatch?.[1]?.trim() || trimmed; + const start = candidate.indexOf("{"); + const end = candidate.lastIndexOf("}"); + if (start === -1 || end === -1 || end <= start) { + throw new Error("模型未返回可解析的 JSON"); + } + return candidate.slice(start, end + 1); +} + +function parseGeneratedTeam( + raw: string, + activeTheme?: string, +): TeamDefinition { + const json = extractJsonObject(raw); + const parsed = JSON.parse(json) as GeneratedTeamPayload; + const payload = parsed.team && typeof parsed.team === "object" ? parsed.team : parsed; + const normalized = normalizeTeamDefinition({ + id: createTeamDefinitionId("ephemeral-team"), + source: "ephemeral", + label: payload.label, + description: payload.description, + theme: activeTheme?.trim() || undefined, + roles: payload.roles, + }); + + if (!normalized) { + throw new Error("模型返回的 Team 结构不完整"); + } + + return { + ...normalized, + source: "ephemeral", + }; +} + +export async function generateEphemeralTeamWithModel( + options: GenerateTeamWithModelOptions, +): Promise { + const { + workspaceId, + providerType, + model, + executionStrategy = "react", + activeTheme, + input, + blueprintTeam, + timeoutMs = 45_000, + } = options; + const trimmedInput = input.trim(); + if (!workspaceId.trim()) { + throw new Error("缺少 workspace,暂时无法生成 Team"); + } + if (!providerType.trim() || !model.trim()) { + throw new Error("请先选择可用模型,再生成 Team"); + } + if (!trimmedInput) { + throw new Error("请先输入任务描述,再生成 Team"); + } + + await initAsterAgent(); + const resolvedExecutionStrategy = + executionStrategy === "code_orchestrated" ? "react" : executionStrategy; + const sessionId = await createAgentRuntimeSession( + workspaceId, + "Team 规划", + resolvedExecutionStrategy, + ); + const eventName = `agent_team_draft:${sessionId}:${Date.now()}`; + let unlisten: (() => void) | null = null; + + try { + const completion = new Promise((resolve, reject) => { + let settled = false; + const timer = globalThis.setTimeout(() => { + settle(() => reject(new Error("生成 Team 超时,请稍后重试"))); + }, timeoutMs); + const settle = (callback: () => void) => { + if (settled) { + return; + } + settled = true; + globalThis.clearTimeout(timer); + callback(); + }; + + void (async () => { + try { + unlisten = await safeListen(eventName, async (event) => { + const parsed = parseStreamEvent(event.payload); + if (!parsed) { + return; + } + + if (parsed.type === "turn_failed" || parsed.type === "error") { + settle(() => + reject( + new Error( + parsed.type === "turn_failed" + ? parsed.turn.error_message?.trim() || + "Team 生成失败" + : parsed.message.trim() || "Team 生成失败", + ), + ), + ); + return; + } + + if ( + parsed.type !== "turn_completed" && + parsed.type !== "final_done" + ) { + return; + } + + try { + const detail = await getAgentRuntimeSession(sessionId); + const responseText = extractAssistantText(detail); + const generatedTeam = parseGeneratedTeam(responseText, activeTheme); + settle(() => resolve(generatedTeam)); + } catch (error) { + settle(() => + reject( + error instanceof Error + ? error + : new Error("解析 Team 结果失败"), + ), + ); + } + }); + + await submitAgentRuntimeTurn({ + message: buildTeamGenerationPrompt( + trimmedInput, + activeTheme, + blueprintTeam, + ), + session_id: sessionId, + event_name: eventName, + workspace_id: workspaceId, + turn_config: { + provider_config: { + provider_id: providerType, + provider_name: mapProviderName(providerType), + model_name: model, + }, + execution_strategy: resolvedExecutionStrategy, + web_search: false, + search_mode: "disabled", + system_prompt: + "你是 GUI Team 规划器。当前唯一任务是输出结构化 JSON Team 定义,不要调用工具,不要输出解释。", + metadata: { + source: "team_auto_generation", + auto_team: true, + theme: activeTheme || "general", + }, + }, + }); + } catch (error) { + settle(() => + reject( + error instanceof Error + ? error + : new Error("提交 Team 生成任务失败"), + ), + ); + } + })(); + }); + + return await completion; + } finally { + try { + unlisten?.(); + } catch { + // ignore cleanup failure + } + try { + await deleteAgentRuntimeSession(sessionId); + } catch { + // ignore cleanup failure + } + } +} diff --git a/src/components/agent/chat/utils/teamDefinitions.ts b/src/components/agent/chat/utils/teamDefinitions.ts index f295c62df..94def58fc 100644 --- a/src/components/agent/chat/utils/teamDefinitions.ts +++ b/src/components/agent/chat/utils/teamDefinitions.ts @@ -4,7 +4,11 @@ import { type TeamPresetOption, } from "./teamPresets"; -export type TeamDefinitionSource = "builtin" | "custom"; +export type TeamDefinitionSource = "builtin" | "custom" | "ephemeral"; +export type PersistableTeamDefinitionSource = Exclude< + TeamDefinitionSource, + "ephemeral" +>; export interface TeamRoleDefinition { id: string; @@ -29,7 +33,7 @@ export interface TeamDefinition { export interface TeamSelectionReference { id: string; - source: TeamDefinitionSource; + source: PersistableTeamDefinitionSource; } function normalizeText(value?: string | null): string { @@ -148,7 +152,12 @@ export function normalizeTeamDefinition( return { id: normalizeText(value.id) || createTeamDefinitionId("custom-team"), - source: value.source === "builtin" ? "builtin" : "custom", + source: + value.source === "builtin" + ? "builtin" + : value.source === "ephemeral" + ? "ephemeral" + : "custom", label, description: normalizeText(value.description), theme: normalizeText(value.theme) || undefined, @@ -185,7 +194,7 @@ export function buildTeamDefinitionLabel(team?: TeamDefinition | null): string { export function buildTeamSelectionReference( team?: TeamDefinition | null, ): TeamSelectionReference | null { - if (!team) { + if (!team || team.source === "ephemeral") { return null; } diff --git a/src/components/agent/chat/utils/teamStorage.ts b/src/components/agent/chat/utils/teamStorage.ts index 401cce7ed..8c18ac9d2 100644 --- a/src/components/agent/chat/utils/teamStorage.ts +++ b/src/components/agent/chat/utils/teamStorage.ts @@ -1,3 +1,8 @@ +import type { + WorkspaceAgentCustomTeamSettings, + WorkspaceSettings, + WorkspaceTeamSelectionReference, +} from "@/types/workspace"; import type { TeamDefinition } from "./teamDefinitions"; import { createTeamDefinitionFromPreset, @@ -9,6 +14,18 @@ import { const CUSTOM_TEAM_STORAGE_KEY = "lime.chat.custom_teams.v1"; const TEAM_SELECTION_STORAGE_KEY_PREFIX = "lime.chat.team_selection.v1"; +type TeamSelectionLike = Pick; + +interface ResolveSelectedTeamPreferenceOptions { + theme?: string | null; + workspaceSettings?: WorkspaceSettings | null; +} + +type WorkspaceTeamPreferenceState = + | { kind: "unset" } + | { kind: "disabled" } + | { kind: "selected"; selection: TeamSelectionReference }; + function normalizeCustomTeamList( teams: Array> | TeamDefinition[], ): TeamDefinition[] { @@ -43,6 +60,62 @@ function getTeamSelectionStorageKey(theme?: string | null): string { return `${TEAM_SELECTION_STORAGE_KEY_PREFIX}.${normalizeThemeScope(theme)}`; } +function normalizeWorkspaceTeamSelectionReference( + value?: Partial | null, +): TeamSelectionReference | null { + if ( + typeof value?.id !== "string" || + !value.id.trim() || + (value.source !== "builtin" && value.source !== "custom") + ) { + return null; + } + + return { + id: value.id.trim(), + source: value.source, + }; +} + +function resolveTeamFromSelection( + selection?: TeamSelectionReference | null, + customTeams?: TeamDefinition[], +): TeamDefinition | null { + if (!selection) { + return null; + } + + if (selection.source === "builtin") { + return createTeamDefinitionFromPreset(selection.id); + } + + return ( + (customTeams || loadCustomTeams()).find((team) => team.id === selection.id) || + null + ); +} + +function normalizeWorkspaceCustomTeamList( + teams?: Array> | null, +): TeamDefinition[] | null { + if (!Array.isArray(teams)) { + return null; + } + + return normalizeCustomTeamList( + teams.map((team) => ({ + ...team, + source: "custom" as const, + roles: Array.isArray(team.roles) + ? team.roles.map((role) => ({ + ...role, + skillIds: Array.isArray(role.skillIds) ? [...role.skillIds] : [], + })) + : [], + })), + ); +} + export function loadCustomTeams(): TeamDefinition[] { try { const raw = localStorage.getItem(CUSTOM_TEAM_STORAGE_KEY); @@ -73,14 +146,12 @@ export function persistSelectedTeam( ): void { try { const key = getTeamSelectionStorageKey(theme); - if (!team) { + const selection = buildTeamSelectionReference(team); + if (!team || !selection) { localStorage.removeItem(key); return; } - localStorage.setItem( - key, - JSON.stringify(buildTeamSelectionReference(team)), - ); + localStorage.setItem(key, JSON.stringify(selection)); } catch { // ignore persistence errors } @@ -111,17 +182,135 @@ export function loadSelectedTeamReference( } } +export function resolveWorkspaceTeamPreferenceState( + settings?: WorkspaceSettings | null, +): WorkspaceTeamPreferenceState { + const agentTeam = settings?.agentTeam; + if (!agentTeam) { + return { kind: "unset" }; + } + + if (agentTeam.disabled) { + return { kind: "disabled" }; + } + + const selection = normalizeWorkspaceTeamSelectionReference( + agentTeam.selectedTeam, + ); + return selection ? { kind: "selected", selection } : { kind: "unset" }; +} + +export function loadCustomTeamsFromWorkspaceSettings( + settings?: WorkspaceSettings | null, +): TeamDefinition[] | null { + return normalizeWorkspaceCustomTeamList(settings?.agentTeam?.customTeams); +} + +export function resolveCustomTeams( + workspaceSettings?: WorkspaceSettings | null, +): TeamDefinition[] { + return loadCustomTeamsFromWorkspaceSettings(workspaceSettings) || loadCustomTeams(); +} + +export function loadSelectedTeamReferenceFromWorkspaceSettings( + settings?: WorkspaceSettings | null, +): TeamSelectionReference | null { + const state = resolveWorkspaceTeamPreferenceState(settings); + return state.kind === "selected" ? state.selection : null; +} + +export function buildWorkspaceSettingsWithSelectedTeam( + currentSettings: WorkspaceSettings | null | undefined, + team: TeamDefinition | null, +): WorkspaceSettings { + const selection = buildTeamSelectionReference(team); + if (team?.source === "ephemeral") { + return { + ...(currentSettings || {}), + agentTeam: { + ...(currentSettings?.agentTeam || {}), + disabled: false, + selectedTeam: undefined, + }, + }; + } + + return { + ...(currentSettings || {}), + agentTeam: selection + ? { + ...(currentSettings?.agentTeam || {}), + disabled: false, + selectedTeam: selection, + } + : { + ...(currentSettings?.agentTeam || {}), + disabled: true, + }, + }; +} + +export function buildWorkspaceSettingsWithCustomTeams( + currentSettings: WorkspaceSettings | null | undefined, + teams: TeamDefinition[], +): WorkspaceSettings { + return { + ...(currentSettings || {}), + agentTeam: { + ...(currentSettings?.agentTeam || {}), + customTeams: normalizeCustomTeamList(teams).map((team) => ({ + id: team.id, + label: team.label, + description: team.description, + theme: team.theme, + presetId: team.presetId, + roles: team.roles.map((role) => ({ + id: role.id, + label: role.label, + summary: role.summary, + profileId: role.profileId, + roleKey: role.roleKey, + skillIds: role.skillIds ? [...role.skillIds] : [], + })), + createdAt: team.createdAt, + updatedAt: team.updatedAt, + })), + }, + }; +} + +export function isSameTeamSelectionReference( + left?: TeamSelectionLike | null, + right?: TeamSelectionLike | null, +): boolean { + return ( + (left?.id || "") === (right?.id || "") && + (left?.source || "") === (right?.source || "") + ); +} + export function resolvePersistedSelectedTeam( theme?: string | null, ): TeamDefinition | null { - const selection = loadSelectedTeamReference(theme); - if (!selection) { + return resolveTeamFromSelection(loadSelectedTeamReference(theme)); +} + +export function resolveSelectedTeamPreference({ + theme, + workspaceSettings, +}: ResolveSelectedTeamPreferenceOptions): TeamDefinition | null { + const workspaceState = resolveWorkspaceTeamPreferenceState(workspaceSettings); + const workspaceCustomTeams = loadCustomTeamsFromWorkspaceSettings(workspaceSettings); + if (workspaceState.kind === "disabled") { return null; } - if (selection.source === "builtin") { - return createTeamDefinitionFromPreset(selection.id); + if (workspaceState.kind === "selected") { + return resolveTeamFromSelection( + workspaceState.selection, + workspaceCustomTeams || undefined, + ); } - return loadCustomTeams().find((team) => team.id === selection.id) || null; + return resolvePersistedSelectedTeam(theme); } diff --git a/src/components/content-creator/canvas/document/DocumentToolbar.tsx b/src/components/content-creator/canvas/document/DocumentToolbar.tsx index 2b07c3eba..a07f79a01 100644 --- a/src/components/content-creator/canvas/document/DocumentToolbar.tsx +++ b/src/components/content-creator/canvas/document/DocumentToolbar.tsx @@ -187,6 +187,12 @@ const PanelSection = styled.div` gap: 10px; `; +const SectionHint = styled.span` + font-size: 12px; + line-height: 1.5; + color: hsl(var(--muted-foreground)); +`; + const PanelDivider = styled.div` height: 1px; background: hsl(var(--border)); @@ -576,6 +582,9 @@ export const DocumentToolbar: React.FC = memo( 模型切换 + + 这里沿用当前 Claw 会话的 Provider、模型与思考过程设置。 + { const mountedRoots: MountedRoot[] = []; + const originalInnerWidth = window.innerWidth; + const originalInnerHeight = window.innerHeight; + + beforeEach(() => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + writable: true, + value: 1440, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + writable: true, + value: 900, + }); + }); afterEach(() => { cleanupMountedRoots(mountedRoots); + Object.defineProperty(window, "innerWidth", { + configurable: true, + writable: true, + value: originalInnerWidth, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + writable: true, + value: originalInnerHeight, + }); }); it("chat-canvas 模式应为画布与对话保留分栏间距", () => { @@ -80,6 +105,38 @@ describe("LayoutTransition", () => { ); expect(hasGapRule).toBe(true); + expect(root?.getAttribute("data-layout-axis")).toBe("horizontal"); + }); + + it("小屏 chat-canvas 模式应改为上下堆叠,避免右侧区域挤压不可见", () => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + writable: true, + value: 1080, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + writable: true, + value: 720, + }); + + const { container } = mountHarness( + LayoutHarness, + { mode: "chat-canvas" }, + mountedRoots, + ); + + const root = container.querySelector( + '[data-testid="layout-transition-root"]', + ); + + expect(root?.getAttribute("data-layout-axis")).toBe("vertical"); + expect( + container.querySelector('[data-testid="layout-chat-content"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="layout-canvas-content"]'), + ).not.toBeNull(); }); it("画布内容为空时应退回聊天布局,避免保留空白画布列", () => { diff --git a/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx b/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx index c7fe5ca88..09d39004c 100644 --- a/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx +++ b/src/components/content-creator/core/LayoutTransition/LayoutTransition.tsx @@ -4,36 +4,76 @@ * @module components/content-creator/core/LayoutTransition/LayoutTransition */ -import React, { memo } from "react"; +import React, { memo, useEffect, useState } from "react"; import styled from "styled-components"; import { LayoutMode } from "../../types"; import { useLayoutTransition, TransitionConfig } from "./useLayoutTransition"; -const Container = styled.div` +const STACKED_CHAT_CANVAS_BREAKPOINT_WIDTH = 1320; +const STACKED_CHAT_CANVAS_BREAKPOINT_HEIGHT = 820; +const STACKED_CHAT_CANVAS_PANEL_HEIGHT = "clamp(260px, 36%, 360px)"; + +function shouldUseStackedChatCanvasLayout(mode: LayoutMode): boolean { + if (mode !== "chat-canvas" || typeof window === "undefined") { + return false; + } + + return ( + window.innerWidth <= STACKED_CHAT_CANVAS_BREAKPOINT_WIDTH || + window.innerHeight <= STACKED_CHAT_CANVAS_BREAKPOINT_HEIGHT + ); +} + +const Container = styled.div<{ $stacked: boolean }>` display: flex; + flex-direction: ${({ $stacked }) => ($stacked ? "column" : "row")}; width: 100%; height: 100%; + min-height: 0; overflow: hidden; - gap: 12px; + gap: ${({ $stacked }) => ($stacked ? "10px" : "12px")}; `; const ChatPanel = styled.div<{ $width: string; $duration: number; $minWidth: string; + $stacked: boolean; $hidden: boolean; $chrome: "panel" | "plain"; }>` - height: 100%; + height: ${({ $stacked, $hidden }) => + $hidden + ? "0" + : $stacked + ? STACKED_CHAT_CANVAS_PANEL_HEIGHT + : "100%"}; + max-height: ${({ $stacked, $hidden }) => + $hidden + ? "0" + : $stacked + ? STACKED_CHAT_CANVAS_PANEL_HEIGHT + : "100%"}; overflow: hidden; - transition: width ${({ $duration }) => $duration}ms ease-out; - width: ${({ $width }) => $width}; - min-width: ${({ $minWidth }) => $minWidth}; - will-change: width; + transition: + width ${({ $duration }) => $duration}ms ease-out, + height ${({ $duration }) => $duration}ms ease-out; + width: ${({ $stacked, $width, $hidden }) => + $hidden ? "0" : $stacked ? "100%" : $width}; + min-width: ${({ $stacked, $minWidth }) => ($stacked ? "0px" : $minWidth)}; + min-height: ${({ $stacked, $hidden }) => + $hidden ? "0" : $stacked ? "220px" : "100%"}; + flex: ${({ $stacked, $hidden }) => + $hidden + ? "0 0 0" + : $stacked + ? `0 0 ${STACKED_CHAT_CANVAS_PANEL_HEIGHT}` + : "0 0 auto"}; + will-change: width, height; display: ${({ $hidden }) => ($hidden ? "none" : "flex")}; flex-direction: column; - padding: ${({ $chrome }) => - $chrome === "plain" ? "0" : "16px 16px 16px 0"}; + padding: ${({ $stacked, $chrome }) => + $stacked || $chrome === "plain" ? "0" : "16px 16px 16px 0"}; `; const ChatPanelInner = styled.div` @@ -56,14 +96,16 @@ const PlainChatPanelInner = styled.div` const CanvasPanel = styled.div<{ $visible: boolean; + $stacked: boolean; $transform: string; $opacity: number; $duration: number; }>` position: relative; - height: 100%; + height: ${({ $stacked }) => ($stacked ? "auto" : "100%")}; flex: 1; min-width: 0; + min-height: 0; overflow: hidden; transition: transform ${({ $duration }) => $duration}ms ease-out, @@ -106,19 +148,43 @@ export const LayoutTransition: React.FC = memo( effectiveMode, transitionConfig, ); + const [stackedChatCanvas, setStackedChatCanvas] = useState(() => + shouldUseStackedChatCanvasLayout(effectiveMode), + ); const chatStyles = getTransitionStyles("chat"); const canvasStyles = getTransitionStyles("canvas"); const shouldRenderCanvas = hasCanvasContent && isCanvasVisible; + useEffect(() => { + const updateLayout = () => { + setStackedChatCanvas( + shouldUseStackedChatCanvasLayout(effectiveMode), + ); + }; + + updateLayout(); + if (typeof window === "undefined") { + return; + } + + window.addEventListener("resize", updateLayout); + return () => { + window.removeEventListener("resize", updateLayout); + }; + }, [effectiveMode]); + return ( = memo( $duration={parseInt( chatStyles.transition?.match(/\d+/)?.[0] || "300", )} - $minWidth={effectiveMode === "canvas" ? "0px" : "460px"} + $minWidth={effectiveMode === "chat-canvas" ? "360px" : "0px"} + $stacked={stackedChatCanvas} $hidden={effectiveMode === "canvas"} $chrome={chatPanelChrome} > diff --git a/src/components/content-creator/core/LayoutTransition/useLayoutTransition.test.tsx b/src/components/content-creator/core/LayoutTransition/useLayoutTransition.test.tsx index 4c2e8c2de..43d0615f7 100644 --- a/src/components/content-creator/core/LayoutTransition/useLayoutTransition.test.tsx +++ b/src/components/content-creator/core/LayoutTransition/useLayoutTransition.test.tsx @@ -15,6 +15,7 @@ function HookHarness({ mode }: { mode: LayoutMode }) { const { transitionState, isCanvasVisible, getTransitionStyles } = useLayoutTransition(mode); const canvasStyles = getTransitionStyles("canvas"); + const chatStyles = getTransitionStyles("chat"); return (
); } @@ -66,5 +68,8 @@ describe("useLayoutTransition", () => { expect(root?.getAttribute("data-canvas-visible")).toBe("true"); expect(root?.getAttribute("data-canvas-transform")).toBe("translateX(0)"); expect(root?.getAttribute("data-canvas-opacity")).toBe("1"); + expect(root?.getAttribute("data-chat-width")).toBe( + "min(100%, clamp(480px, 40%, 600px))", + ); }); }); diff --git a/src/components/content-creator/core/LayoutTransition/useLayoutTransition.ts b/src/components/content-creator/core/LayoutTransition/useLayoutTransition.ts index 9fd834232..758dc1d5c 100644 --- a/src/components/content-creator/core/LayoutTransition/useLayoutTransition.ts +++ b/src/components/content-creator/core/LayoutTransition/useLayoutTransition.ts @@ -34,6 +34,8 @@ const DEFAULT_CONFIG: Required = { exitDelay: 0, }; +const CHAT_CANVAS_PANEL_WIDTH = "min(100%, clamp(480px, 40%, 600px))"; + /** * 布局过渡 Hook * @@ -122,7 +124,11 @@ export function useLayoutTransition( return { transition: `width ${duration}ms ease-out`, width: - mode === "chat-canvas" ? "35%" : mode === "canvas" ? "0%" : "100%", + mode === "chat-canvas" + ? CHAT_CANVAS_PANEL_WIDTH + : mode === "canvas" + ? "0%" + : "100%", }; }, [transitionState, mergedConfig, mode], diff --git a/src/components/layout/CrashRecoveryPanel.tsx b/src/components/layout/CrashRecoveryPanel.tsx index 6f1965b2f..ee4f6a173 100644 --- a/src/components/layout/CrashRecoveryPanel.tsx +++ b/src/components/layout/CrashRecoveryPanel.tsx @@ -24,6 +24,7 @@ import { getProjectByRootPath, updateProject } from "@/lib/api/project"; import { cn } from "@/lib/utils"; import { ClipboardPermissionGuideCard } from "@/components/settings-v2/system/shared/ClipboardPermissionGuideCard"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; +import { notifyProjectRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService"; interface CrashRecoveryPanelProps { error: Error | null; @@ -207,6 +208,16 @@ export function CrashRecoveryPanel({ return; } await updateProject(workspace.id, { rootPath: newPath }); + notifyProjectRuntimeAgentsGuide( + { + id: workspace.id, + rootPath: newPath, + }, + { + successMessage: "Workspace 路径已更新", + showSuccessWhenGuideAlreadySeen: false, + }, + ); setMessage({ type: "success", text: `Workspace 路径已更新为:${newPath}`, diff --git a/src/components/projects/ProjectsPage.tsx b/src/components/projects/ProjectsPage.tsx index a5a9edd9a..3ba4f04bf 100644 --- a/src/components/projects/ProjectsPage.tsx +++ b/src/components/projects/ProjectsPage.tsx @@ -31,6 +31,7 @@ import { DeleteProjectDialog } from "./DeleteProjectDialog"; import { ContentListPage } from "./ContentListPage"; import { toast } from "sonner"; import { Page, PageParams } from "@/types/page"; +import { notifyProjectCreatedWithRuntimeAgentsGuide } from "@/components/workspace/services/runtimeAgentsGuideService"; interface ProjectsPageProps { onNavigate?: (page: Page, params?: PageParams) => void; @@ -166,13 +167,13 @@ export function ProjectsPage({ onNavigate }: ProjectsPageProps) { try { const projectPath = await resolveProjectRootPath(name); - await createProject({ + const createdProject = await createProject({ name, rootPath: projectPath, workspaceType: type, }); - toast.success("项目创建成功"); + notifyProjectCreatedWithRuntimeAgentsGuide(createdProject, "项目创建成功"); loadProjects(); } catch (error) { console.error("创建项目失败:", error); diff --git a/src/components/settings-v2/general/memory/index.test.tsx b/src/components/settings-v2/general/memory/index.test.tsx index e3c2ab9e4..be9f9ebab 100644 --- a/src/components/settings-v2/general/memory/index.test.tsx +++ b/src/components/settings-v2/general/memory/index.test.tsx @@ -8,6 +8,8 @@ const { mockGetMemoryOverview, mockGetMemoryEffectiveSources, mockGetMemoryAutoIndex, + mockEnsureWorkspaceLocalAgentsGitignore, + mockScaffoldRuntimeAgentsTemplate, mockToggleMemoryAuto, mockUpdateMemoryAutoNote, mockGetUnifiedMemoryStats, @@ -18,6 +20,8 @@ const { mockGetMemoryOverview: vi.fn(), mockGetMemoryEffectiveSources: vi.fn(), mockGetMemoryAutoIndex: vi.fn(), + mockEnsureWorkspaceLocalAgentsGitignore: vi.fn(), + mockScaffoldRuntimeAgentsTemplate: vi.fn(), mockToggleMemoryAuto: vi.fn(), mockUpdateMemoryAutoNote: vi.fn(), mockGetUnifiedMemoryStats: vi.fn(), @@ -33,6 +37,8 @@ vi.mock("@/lib/api/memoryRuntime", () => ({ getContextMemoryOverview: mockGetMemoryOverview, getContextMemoryEffectiveSources: mockGetMemoryEffectiveSources, getContextMemoryAutoIndex: mockGetMemoryAutoIndex, + ensureWorkspaceLocalAgentsGitignore: mockEnsureWorkspaceLocalAgentsGitignore, + scaffoldRuntimeAgentsTemplate: mockScaffoldRuntimeAgentsTemplate, toggleContextMemoryAuto: mockToggleMemoryAuto, updateContextMemoryAutoNote: mockUpdateMemoryAutoNote, })); @@ -183,6 +189,17 @@ beforeEach(() => { items: [], }); mockToggleMemoryAuto.mockResolvedValue({ enabled: false }); + mockScaffoldRuntimeAgentsTemplate.mockResolvedValue({ + target: "workspace", + path: "/tmp/.lime/AGENTS.md", + status: "created", + createdParentDir: true, + }); + mockEnsureWorkspaceLocalAgentsGitignore.mockResolvedValue({ + path: "/tmp/.gitignore", + entry: ".lime/AGENTS.local.md", + status: "added", + }); mockUpdateMemoryAutoNote.mockResolvedValue({ enabled: true, root_dir: "/tmp/memory", @@ -255,4 +272,34 @@ describe("MemorySettings", () => { expect(mockUpdateMemoryAutoNote).not.toHaveBeenCalled(); expect(container.textContent).toContain("请先输入要保存的自动记忆内容"); }); + + it("点击生成 Workspace 模板应调用模板生成 API", async () => { + const container = renderComponent(); + await flushEffects(); + await flushEffects(); + + await act(async () => { + findButton(container, "生成 Workspace 模板").click(); + }); + + expect(mockScaffoldRuntimeAgentsTemplate).toHaveBeenCalledWith( + "workspace", + "/tmp", + false, + ); + }); + + it("点击加入 .gitignore 应调用 gitignore API", async () => { + const container = renderComponent(); + await flushEffects(); + await flushEffects(); + + await act(async () => { + findButton(container, "将本机模板加入 .gitignore").click(); + }); + + expect(mockEnsureWorkspaceLocalAgentsGitignore).toHaveBeenCalledWith( + "/tmp", + ); + }); }); diff --git a/src/components/settings-v2/general/memory/index.tsx b/src/components/settings-v2/general/memory/index.tsx index 6df3dfaa2..36f85bef7 100644 --- a/src/components/settings-v2/general/memory/index.tsx +++ b/src/components/settings-v2/general/memory/index.tsx @@ -23,6 +23,8 @@ import { getContextMemoryAutoIndex, getContextMemoryEffectiveSources, getContextMemoryOverview, + ensureWorkspaceLocalAgentsGitignore, + scaffoldRuntimeAgentsTemplate, toggleContextMemoryAuto, updateContextMemoryAutoNote, type AutoMemoryIndexResponse, @@ -32,6 +34,7 @@ import { type MemoryProfileConfig, type MemoryResolveConfig, type MemorySourcesConfig, + type RuntimeAgentsTemplateTarget, } from "@/lib/api/memoryRuntime"; import { getConfig, saveConfig, type Config } from "@/lib/api/appConfig"; import { getUnifiedMemoryStats } from "@/lib/api/unifiedMemory"; @@ -95,7 +98,7 @@ function normalizeSources(sources?: MemorySourcesConfig): MemorySourcesConfig { sources?.project_memory_paths?.length && sources.project_memory_paths.filter((item) => item.trim().length > 0) ? sources.project_memory_paths - : ["AGENTS.md", ".agents/AGENTS.md"], + : [".lime/AGENTS.md"], project_rule_dirs: sources?.project_rule_dirs?.length && sources.project_rule_dirs.filter((item) => item.trim().length > 0) @@ -103,7 +106,7 @@ function normalizeSources(sources?: MemorySourcesConfig): MemorySourcesConfig { : [".agents/rules"], user_memory_path: sources?.user_memory_path ?? undefined, project_local_memory_path: - sources?.project_local_memory_path ?? "AGENTS.local.md", + sources?.project_local_memory_path ?? ".lime/AGENTS.local.md", }; } @@ -340,6 +343,9 @@ export function MemorySettings() { const [loadingLayerMetrics, setLoadingLayerMetrics] = useState(false); const [loadingSourceState, setLoadingSourceState] = useState(false); const [savingAutoNote, setSavingAutoNote] = useState(false); + const [scaffoldingTarget, setScaffoldingTarget] = + useState(null); + const [ensuringGitignore, setEnsuringGitignore] = useState(false); const [projectId, setProjectId] = useState(() => getStoredResourceProjectId({ includeLegacy: true }), ); @@ -551,6 +557,65 @@ export function MemorySettings() { } }; + const handleScaffoldRuntimeAgentsTemplate = async ( + target: RuntimeAgentsTemplateTarget, + ) => { + const workingDir = effectiveSources?.working_dir?.trim() || undefined; + const targetLabelMap: Record = { + global: "全局", + workspace: "Workspace", + workspace_local: "本机私有", + }; + + if (target !== "global" && !workingDir) { + setMessage(`当前未获取到 workspace 路径,暂无法生成${targetLabelMap[target]}模板`); + setTimeout(() => setMessage(null), 2500); + return; + } + + setScaffoldingTarget(target); + try { + const result = await scaffoldRuntimeAgentsTemplate(target, workingDir, false); + if (result.status === "exists") { + setMessage(`${targetLabelMap[target]}模板已存在,未覆盖:${result.path}`); + } else { + setMessage(`已生成${targetLabelMap[target]}模板:${result.path}`); + } + setTimeout(() => setMessage(null), 3000); + await Promise.all([loadSourceState(), loadLayerMetrics()]); + } catch (error) { + console.error("生成运行时 AGENTS 模板失败:", error); + setMessage(`生成${targetLabelMap[target]}模板失败`); + setTimeout(() => setMessage(null), 2500); + } finally { + setScaffoldingTarget(null); + } + }; + + const handleEnsureWorkspaceLocalGitignore = async () => { + const workingDir = effectiveSources?.working_dir?.trim() || undefined; + if (!workingDir) { + setMessage("当前未获取到 workspace 路径,暂无法更新 .gitignore"); + setTimeout(() => setMessage(null), 2500); + return; + } + + setEnsuringGitignore(true); + try { + const result = await ensureWorkspaceLocalAgentsGitignore(workingDir); + const actionText = + result.status === "exists" ? "已存在,无需重复添加" : "已写入"; + setMessage(`${actionText} .gitignore:${result.path}`); + setTimeout(() => setMessage(null), 3000); + } catch (error) { + console.error("更新 .gitignore 失败:", error); + setMessage("更新 .gitignore 失败"); + setTimeout(() => setMessage(null), 2500); + } finally { + setEnsuringGitignore(false); + } + }; + if (loading) { return ; } @@ -916,7 +981,7 @@ export function MemorySettings() { })) } className={INPUT_CLASS_NAME} - placeholder="例如 /Library/Application Support/Lime/AGENTS.md" + placeholder="例如 ~/.lime/AGENTS.md" /> @@ -937,7 +1002,7 @@ export function MemorySettings() { })) } className={INPUT_CLASS_NAME} - placeholder="留空时使用应用默认 AGENTS.md 路径" + placeholder="留空时使用应用默认 ~/.lime/AGENTS.md 路径" /> @@ -959,10 +1024,81 @@ export function MemorySettings() { })) } className={INPUT_CLASS_NAME} - placeholder="例如 AGENTS.local.md" + placeholder="例如 .lime/AGENTS.local.md" />
+ +
+
+
+

+ 显式生成模板 +

+

+ 只在你点击时创建模板文件,不会静默生成,也不会默认覆盖已有内容。 +

+
+ + 当前 Workspace:{effectiveSources?.working_dir || "未解析"} + +
+ +
+ + + + +
+
@@ -1047,7 +1183,7 @@ export function MemorySettings() { 项目记忆文件 - 每行一个相对路径,例如 `AGENTS.md`。 + 每行一个相对路径,例如 `.lime/AGENTS.md`。